refactor(desktop): make task focus mode self-contained
Handle editing focus entirely within TaskParticleView instead of threading editing state up to StreamView and special-casing the navigation hook. While editing, the card now registers a capture-phase key listener — the same pattern the app's other focus overlays use — that consumes Escape (blurring the field, which flushes the draft and resumes playback) and swallows stream-navigation keys so they can't pull focus to another particle. An "Esc to finish" hint shows while editing. This restores use-stream-navigation-keys to its original form and reduces the StreamView change to a single `immersive` prop. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM
This commit is contained in:
@@ -277,7 +277,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||||
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||||
const [taskEditing, setTaskEditing] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmitTextReaction = useCallback(
|
const handleSubmitTextReaction = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -292,12 +291,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
navigate(`/${networkId}`);
|
navigate(`/${networkId}`);
|
||||||
}, [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({
|
useStreamNavigationKeys({
|
||||||
next,
|
next,
|
||||||
prev,
|
prev,
|
||||||
@@ -306,8 +299,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
mediaRef,
|
mediaRef,
|
||||||
onExit: handleExitNavigate,
|
onExit: handleExitNavigate,
|
||||||
onToggleViewMode: toggleViewMode,
|
onToggleViewMode: toggleViewMode,
|
||||||
editing: taskEditing,
|
|
||||||
onExitEditing: handleExitEditing,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenHuddle = useCallback(() => {
|
const handleOpenHuddle = useCallback(() => {
|
||||||
@@ -459,7 +450,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
onEnded={handleParticleEnded}
|
onEnded={handleParticleEnded}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
immersive
|
immersive
|
||||||
onEditingChange={setTaskEditing}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell';
|
|||||||
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
|
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
import { isTypingTarget } from '@/lib/keyboard';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
|
|
||||||
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||||
|
|
||||||
@@ -37,15 +39,28 @@ interface TaskParticleViewProps {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress?: (ratio: number) => void;
|
onProgress?: (ratio: number) => void;
|
||||||
/** Dim and blur the surrounding stream chrome while a field is focused. */
|
/**
|
||||||
|
* Enable focus mode while a field is focused: dim and blur the surrounding
|
||||||
|
* stream, and trap stream keys (Escape leaves the editor, navigation keys
|
||||||
|
* stay put). Off in the standalone leaf view, which has no stream chrome.
|
||||||
|
*/
|
||||||
immersive?: boolean;
|
immersive?: boolean;
|
||||||
/** Notify the parent when focus enters or leaves the card (focus mode). */
|
|
||||||
onEditingChange?: (editing: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DWELL_DURATION_S = 8;
|
const DWELL_DURATION_S = 8;
|
||||||
const UNASSIGNED = 'unassigned';
|
const UNASSIGNED = 'unassigned';
|
||||||
|
|
||||||
|
// Stream-navigation keys to swallow while editing so they can't pull focus to
|
||||||
|
// another particle (only when focus isn't already in a text field).
|
||||||
|
const NAV_KEYS = new Set([
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowDown',
|
||||||
|
'l',
|
||||||
|
'L',
|
||||||
|
]);
|
||||||
|
|
||||||
function useParticleDocPath(
|
function useParticleDocPath(
|
||||||
containerPath: ParticlePath,
|
containerPath: ParticlePath,
|
||||||
particleId: string,
|
particleId: string,
|
||||||
@@ -61,7 +76,6 @@ export function TaskParticleView({
|
|||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
immersive = false,
|
immersive = false,
|
||||||
onEditingChange,
|
|
||||||
}: TaskParticleViewProps) {
|
}: TaskParticleViewProps) {
|
||||||
const { networkId } = parseParticlePath(containerPath);
|
const { networkId } = parseParticlePath(containerPath);
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
@@ -80,19 +94,33 @@ export function TaskParticleView({
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
useSuspendPlayback(editing, `task-edit-${particle.id}`);
|
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(() => {
|
const exitFocus = useCallback(() => {
|
||||||
(document.activeElement as HTMLElement | null)?.blur();
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// While editing, the card acts like the app's other focus overlays: a
|
||||||
|
// capture-phase listener consumes Escape (which blurs the field — flushing
|
||||||
|
// the draft and resuming playback) and swallows stream-navigation keys, so
|
||||||
|
// the stream's own handlers never see them. Self-contained, so no editing
|
||||||
|
// state has to be threaded back up to the stream.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!immersive || !editing) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
exitFocus();
|
||||||
|
} else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) {
|
||||||
|
// Arrows still move the caret inside text fields; only block them when
|
||||||
|
// focus is on a non-text control (checkbox, assignee select).
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||||
|
}, [immersive, editing, exitFocus]);
|
||||||
|
|
||||||
useFixedDwell({
|
useFixedDwell({
|
||||||
id: particle.id,
|
id: particle.id,
|
||||||
durationS: DWELL_DURATION_S,
|
durationS: DWELL_DURATION_S,
|
||||||
@@ -282,6 +310,16 @@ export function TaskParticleView({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{immersive && editing && (
|
||||||
|
<KeyHint
|
||||||
|
keys="Esc"
|
||||||
|
onClick={exitFocus}
|
||||||
|
title="Finish editing (or press Esc)"
|
||||||
|
className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 flex -translate-x-1/2 items-center rounded-full bg-black/40 px-3 py-1.5 text-white/70 backdrop-blur-sm hover:bg-black/60 hover:text-white"
|
||||||
|
>
|
||||||
|
to finish
|
||||||
|
</KeyHint>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,11 @@ interface UseStreamNavigationKeysOptions {
|
|||||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
onToggleViewMode: () => 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, and list-view toggle. While a
|
* Arrow keys (with shift+arrow seek), Escape. Skipped while playback is
|
||||||
* particle is being edited the stream enters focus mode: Escape leaves the
|
* paused for any reason (overlay, hold-space, compose).
|
||||||
* editor (resuming playback) and the other keys are suppressed so they can't
|
|
||||||
* navigate away mid-edit.
|
|
||||||
*/
|
*/
|
||||||
export function useStreamNavigationKeys({
|
export function useStreamNavigationKeys({
|
||||||
next,
|
next,
|
||||||
@@ -32,23 +26,9 @@ export function useStreamNavigationKeys({
|
|||||||
mediaRef,
|
mediaRef,
|
||||||
onExit,
|
onExit,
|
||||||
onToggleViewMode,
|
onToggleViewMode,
|
||||||
editing,
|
|
||||||
onExitEditing,
|
|
||||||
}: UseStreamNavigationKeysOptions) {
|
}: UseStreamNavigationKeysOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
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;
|
if (isTypingTarget(e)) return;
|
||||||
|
|
||||||
const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1;
|
const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1;
|
||||||
@@ -96,7 +76,5 @@ export function useStreamNavigationKeys({
|
|||||||
mediaRef,
|
mediaRef,
|
||||||
onExit,
|
onExit,
|
||||||
onToggleViewMode,
|
onToggleViewMode,
|
||||||
editing,
|
|
||||||
onExitEditing,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user