diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 6abe7e3..e309990 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -277,6 +277,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id); const [showKeybindings, setShowKeybindings] = useState(false); const [textReactionOpen, setTextReactionOpen] = useState(false); + const [taskEditing, setTaskEditing] = useState(false); const handleSubmitTextReaction = useCallback( (text: string) => { @@ -291,6 +292,12 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { navigate(`/${networkId}`); }, [navigate, networkId]); + // Leaving task edit focus: blur the active field, which clears the card's + // editing state and resumes playback. + const handleExitEditing = useCallback(() => { + (document.activeElement as HTMLElement | null)?.blur(); + }, []); + useStreamNavigationKeys({ next, prev, @@ -299,6 +306,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { mediaRef, onExit: handleExitNavigate, onToggleViewMode: toggleViewMode, + editing: taskEditing, + onExitEditing: handleExitEditing, }); const handleOpenHuddle = useCallback(() => { @@ -449,6 +458,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { paused={paused} onEnded={handleParticleEnded} onProgress={setProgress} + immersive + onEditingChange={setTaskEditing} /> ); default: diff --git a/js/desktop/src/features/particles/task-particle-view.tsx b/js/desktop/src/features/particles/task-particle-view.tsx index cc73772..2cb967f 100644 --- a/js/desktop/src/features/particles/task-particle-view.tsx +++ b/js/desktop/src/features/particles/task-particle-view.tsx @@ -37,6 +37,10 @@ interface TaskParticleViewProps { paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; + /** Dim and blur the surrounding stream chrome while a field is focused. */ + immersive?: boolean; + /** Notify the parent when focus enters or leaves the card (focus mode). */ + onEditingChange?: (editing: boolean) => void; } const DWELL_DURATION_S = 8; @@ -56,6 +60,8 @@ export function TaskParticleView({ paused, onEnded, onProgress, + immersive = false, + onEditingChange, }: TaskParticleViewProps) { const { networkId } = parseParticlePath(containerPath); const network = useNetwork(networkId); @@ -74,6 +80,19 @@ export function TaskParticleView({ const [editing, setEditing] = useState(false); useSuspendPlayback(editing, `task-edit-${particle.id}`); + // Mirror focus state to the stream so it can lock navigation and route + // Escape to "leave the editor" instead of "exit the stream". Reset on + // unmount so a particle change mid-edit doesn't strand the stream in + // focus mode. + useEffect(() => { + onEditingChange?.(editing); + return () => onEditingChange?.(false); + }, [editing, onEditingChange]); + + const exitFocus = useCallback(() => { + (document.activeElement as HTMLElement | null)?.blur(); + }, []); + useFixedDwell({ id: particle.id, durationS: DWELL_DURATION_S, @@ -166,8 +185,20 @@ export function TaskParticleView({ return (
+ {immersive && editing && ( +
+ )}
setEditing(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); diff --git a/js/desktop/src/hooks/use-stream-navigation-keys.ts b/js/desktop/src/hooks/use-stream-navigation-keys.ts index 3f07340..a1637dd 100644 --- a/js/desktop/src/hooks/use-stream-navigation-keys.ts +++ b/js/desktop/src/hooks/use-stream-navigation-keys.ts @@ -12,11 +12,17 @@ interface UseStreamNavigationKeysOptions { mediaRef: RefObject; onExit: () => void; onToggleViewMode: () => void; + /** True while the current particle is being edited (focus mode). */ + editing: boolean; + /** Leave edit focus mode (blur the field) and resume playback. */ + onExitEditing: () => void; } /** - * Arrow keys (with shift+arrow seek), Escape. Skipped while playback is - * paused for any reason (overlay, hold-space, compose). + * Arrow keys (with shift+arrow seek), Escape, and list-view toggle. While a + * particle is being edited the stream enters focus mode: Escape leaves the + * editor (resuming playback) and the other keys are suppressed so they can't + * navigate away mid-edit. */ export function useStreamNavigationKeys({ next, @@ -26,9 +32,23 @@ export function useStreamNavigationKeys({ mediaRef, onExit, onToggleViewMode, + editing, + onExitEditing, }: UseStreamNavigationKeysOptions) { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { + // While editing the current particle the stream is in a focused state: + // Escape leaves the editor and resumes playback instead of exiting the + // stream, and navigation keys stay put rather than pulling focus to a + // different particle. + if (editing) { + if (e.key === 'Escape') { + e.preventDefault(); + onExitEditing(); + } + return; + } + if (isTypingTarget(e)) return; const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1; @@ -76,5 +96,7 @@ export function useStreamNavigationKeys({ mediaRef, onExit, onToggleViewMode, + editing, + onExitEditing, ]); }