Add immersive focus mode for task editing #300
@@ -449,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={handleParticleEnded}
|
onEnded={handleParticleEnded}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
|
immersive
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
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,11 +39,28 @@ interface TaskParticleViewProps {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress?: (ratio: number) => void;
|
onProgress?: (ratio: number) => void;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -56,6 +75,7 @@ export function TaskParticleView({
|
|||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
immersive = false,
|
||||||
}: TaskParticleViewProps) {
|
}: TaskParticleViewProps) {
|
||||||
const { networkId } = parseParticlePath(containerPath);
|
const { networkId } = parseParticlePath(containerPath);
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
@@ -74,6 +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}`);
|
||||||
|
|
||||||
|
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({
|
useFixedDwell({
|
||||||
id: particle.id,
|
id: particle.id,
|
||||||
durationS: DWELL_DURATION_S,
|
durationS: DWELL_DURATION_S,
|
||||||
@@ -166,8 +213,20 @@ export function TaskParticleView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||||
|
{immersive && editing && (
|
||||||
|
<div
|
||||||
|
className="animate-in fade-in-0 fixed inset-0 z-30 bg-black/50 backdrop-blur-md duration-200"
|
||||||
|
onMouseDown={exitFocus}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md"
|
className={cn(
|
||||||
|
'scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md transition-shadow',
|
||||||
|
immersive &&
|
||||||
|
editing &&
|
||||||
|
'relative z-40 shadow-2xl shadow-black/50 ring-1 ring-white/15',
|
||||||
|
)}
|
||||||
onFocusCapture={() => setEditing(true)}
|
onFocusCapture={() => setEditing(true)}
|
||||||
onBlurCapture={(e) => {
|
onBlurCapture={(e) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
||||||
@@ -251,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 -translate-x-1/2 text-xs text-white/40"
|
||||||
|
>
|
||||||
|
to finish
|
||||||
|
</KeyHint>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user