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 <noreply@anthropic.com> 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 [showKeybindings, setShowKeybindings] = useState(false);
|
||||
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||
const [taskEditing, setTaskEditing] = useState(false);
|
||||
|
||||
const handleSubmitTextReaction = useCallback(
|
||||
(text: string) => {
|
||||
@@ -292,12 +291,6 @@ 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,
|
||||
@@ -306,8 +299,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
mediaRef,
|
||||
onExit: handleExitNavigate,
|
||||
onToggleViewMode: toggleViewMode,
|
||||
editing: taskEditing,
|
||||
onExitEditing: handleExitEditing,
|
||||
});
|
||||
|
||||
const handleOpenHuddle = useCallback(() => {
|
||||
@@ -459,7 +450,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
onEnded={handleParticleEnded}
|
||||
onProgress={setProgress}
|
||||
immersive
|
||||
onEditingChange={setTaskEditing}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell';
|
||||
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { isTypingTarget } from '@/lib/keyboard';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
|
||||
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||
|
||||
@@ -37,15 +39,28 @@ interface TaskParticleViewProps {
|
||||
paused: boolean;
|
||||
onEnded: () => 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;
|
||||
/** Notify the parent when focus enters or leaves the card (focus mode). */
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
}
|
||||
|
||||
const DWELL_DURATION_S = 8;
|
||||
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(
|
||||
containerPath: ParticlePath,
|
||||
particleId: string,
|
||||
@@ -61,7 +76,6 @@ export function TaskParticleView({
|
||||
onEnded,
|
||||
onProgress,
|
||||
immersive = false,
|
||||
onEditingChange,
|
||||
}: TaskParticleViewProps) {
|
||||
const { networkId } = parseParticlePath(containerPath);
|
||||
const network = useNetwork(networkId);
|
||||
@@ -80,19 +94,33 @@ 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();
|
||||
}, []);
|
||||
|
||||
// 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({
|
||||
id: particle.id,
|
||||
durationS: DWELL_DURATION_S,
|
||||
@@ -282,6 +310,16 @@ export function TaskParticleView({
|
||||
</Select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,17 +12,11 @@ interface UseStreamNavigationKeysOptions {
|
||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||
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, 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.
|
||||
* Arrow keys (with shift+arrow seek), Escape. Skipped while playback is
|
||||
* paused for any reason (overlay, hold-space, compose).
|
||||
*/
|
||||
export function useStreamNavigationKeys({
|
||||
next,
|
||||
@@ -32,23 +26,9 @@ 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;
|
||||
@@ -96,7 +76,5 @@ export function useStreamNavigationKeys({
|
||||
mediaRef,
|
||||
onExit,
|
||||
onToggleViewMode,
|
||||
editing,
|
||||
onExitEditing,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user