feat(desktop): immersive focus mode for editing tasks
Editing a task in the stream was ambiguous: nothing signalled that you were editing, playback chrome stayed live, Escape exited the whole stream, and arrow keys could navigate to another particle mid-edit. Focusing any field now enters a focus mode: the surrounding stream chrome dims and blurs while the task card lifts above it with a soft ring, so the task you're editing is unmistakable. Escape (or clicking outside the card) leaves the editor and resumes playback from where it paused instead of exiting the stream, and arrow/list keys no longer pull focus to another particle while editing. Text particles already get this via their full-screen edit overlay, so the two editing flows are now consistent. Addresses #286 Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
<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);
|
||||
|
||||
@@ -12,11 +12,17 @@ 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. 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,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user