Add immersive focus mode for task editing #300
@@ -449,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
paused={paused}
|
||||
onEnded={handleParticleEnded}
|
||||
onProgress={setProgress}
|
||||
immersive
|
||||
/>
|
||||
);
|
||||
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,11 +39,28 @@ interface TaskParticleViewProps {
|
||||
paused: boolean;
|
||||
onEnded: () => 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 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,
|
||||
@@ -56,6 +75,7 @@ export function TaskParticleView({
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
immersive = false,
|
||||
}: TaskParticleViewProps) {
|
||||
const { networkId } = parseParticlePath(containerPath);
|
||||
const network = useNetwork(networkId);
|
||||
@@ -74,6 +94,33 @@ export function TaskParticleView({
|
||||
const [editing, setEditing] = useState(false);
|
||||
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({
|
||||
id: particle.id,
|
||||
durationS: DWELL_DURATION_S,
|
||||
@@ -166,8 +213,20 @@ export function TaskParticleView({
|
||||
|
||||
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)]">
|
||||
{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
|
||||
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)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
||||
@@ -251,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 -translate-x-1/2 text-xs text-white/40"
|
||||
>
|
||||
to finish
|
||||
</KeyHint>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user