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
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { deleteField } from 'firebase/firestore';
|
|
import { Plus, X } from 'lucide-react';
|
|
import type { ChecklistItem, Particle } from '@/api/types';
|
|
import {
|
|
particlePath,
|
|
parseParticlePath,
|
|
toFirestoreDocPath,
|
|
type ParticlePath,
|
|
} from '@/lib/particle-path';
|
|
import {
|
|
updateParticle,
|
|
updateParticleProperties,
|
|
} from '@/lib/firestore-particles';
|
|
import { cn } from '@/lib/utils';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { HumanAvatar } from '@/components/human-avatar';
|
|
import { useNetwork } from '@/hooks/use-networks';
|
|
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' }>;
|
|
|
|
interface TaskParticleViewProps {
|
|
particle: TaskParticle;
|
|
containerPath: ParticlePath;
|
|
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,
|
|
): string {
|
|
const { networkId, segments } = parseParticlePath(containerPath);
|
|
return toFirestoreDocPath(particlePath(networkId, [...segments, particleId]));
|
|
}
|
|
|
|
export function TaskParticleView({
|
|
particle,
|
|
containerPath,
|
|
paused,
|
|
onEnded,
|
|
onProgress,
|
|
immersive = false,
|
|
}: TaskParticleViewProps) {
|
|
const { networkId } = parseParticlePath(containerPath);
|
|
const network = useNetwork(networkId);
|
|
const docPath = useParticleDocPath(containerPath, particle.id);
|
|
|
|
const {
|
|
title,
|
|
notes,
|
|
checklist = [],
|
|
assigned_to,
|
|
done,
|
|
} = particle.properties;
|
|
|
|
// Suspend playback while any field inside the card has focus so typing
|
|
// doesn't race the dwell timer or get eaten by global key handlers.
|
|
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,
|
|
paused,
|
|
onEnded,
|
|
onProgress,
|
|
});
|
|
|
|
const titleField = useLiveDraftField({
|
|
remoteValue: title,
|
|
commit: (value) =>
|
|
updateParticleProperties<'task'>(docPath, { title: value }),
|
|
});
|
|
const notesField = useLiveDraftField({
|
|
remoteValue: notes ?? '',
|
|
commit: (value) =>
|
|
updateParticleProperties<'task'>(docPath, { notes: value }),
|
|
});
|
|
|
|
// Checklist writes replace the whole array (merged against the latest live
|
|
// value); concurrent edits to the same checklist are last-write-wins.
|
|
const checklistRef = useRef(checklist);
|
|
useEffect(() => {
|
|
checklistRef.current = checklist;
|
|
}, [checklist]);
|
|
|
|
const writeChecklist = useCallback(
|
|
(items: ChecklistItem[]) => {
|
|
// Advance the local base before the write so a second edit issued before
|
|
// the next snapshot composes on top of this one instead of dropping it.
|
|
checklistRef.current = items;
|
|
return updateParticleProperties<'task'>(docPath, { checklist: items });
|
|
},
|
|
[docPath],
|
|
);
|
|
|
|
const handleToggleDone = useCallback(
|
|
(checked: boolean) =>
|
|
updateParticleProperties<'task'>(docPath, { done: checked }),
|
|
[docPath],
|
|
);
|
|
|
|
const handleToggleItem = useCallback(
|
|
(index: number, checked: boolean) => {
|
|
const items = checklistRef.current.map((item, i) =>
|
|
i === index ? { ...item, done: checked } : item,
|
|
);
|
|
void writeChecklist(items);
|
|
},
|
|
[writeChecklist],
|
|
);
|
|
|
|
const handleCommitItemText = useCallback(
|
|
(index: number, text: string) => {
|
|
const items = checklistRef.current.map((item, i) =>
|
|
i === index ? { ...item, text } : item,
|
|
);
|
|
void writeChecklist(items);
|
|
},
|
|
[writeChecklist],
|
|
);
|
|
|
|
const handleRemoveItem = useCallback(
|
|
(index: number) => {
|
|
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
|
|
},
|
|
[writeChecklist],
|
|
);
|
|
|
|
const handleAddItem = useCallback(
|
|
(text: string) => {
|
|
void writeChecklist([...checklistRef.current, { text, done: false }]);
|
|
},
|
|
[writeChecklist],
|
|
);
|
|
|
|
const handleAssign = useCallback(
|
|
(value: string) => {
|
|
if (value === UNASSIGNED) {
|
|
void updateParticle(docPath, 'properties.assigned_to', deleteField());
|
|
} else {
|
|
void updateParticleProperties<'task'>(docPath, { assigned_to: value });
|
|
}
|
|
},
|
|
[docPath],
|
|
);
|
|
|
|
const assignee = resolveHumanDisplay(assigned_to, network?.humans);
|
|
const doneCount = checklist.filter((item) => item.done).length;
|
|
|
|
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={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);
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<Checkbox
|
|
checked={done}
|
|
onCheckedChange={(checked) => handleToggleDone(checked === true)}
|
|
className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
|
aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
|
|
/>
|
|
<input
|
|
value={titleField.value}
|
|
onChange={(e) => titleField.onChange(e.target.value)}
|
|
onFocus={titleField.onFocus}
|
|
onBlur={titleField.onBlur}
|
|
placeholder="Task title"
|
|
className={cn(
|
|
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
|
|
done && 'text-white/50 line-through',
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<Textarea
|
|
value={notesField.value}
|
|
onChange={(e) => notesField.onChange(e.target.value)}
|
|
onFocus={notesField.onFocus}
|
|
onBlur={notesField.onBlur}
|
|
placeholder="Add notes…"
|
|
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent"
|
|
/>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
{checklist.length > 0 && (
|
|
<span className="text-xs text-white/40">
|
|
{doneCount} / {checklist.length} done
|
|
</span>
|
|
)}
|
|
{checklist.map((item, index) => (
|
|
<ChecklistItemRow
|
|
// Index keys + whole-array writes are an accepted tradeoff:
|
|
// concurrent removal while someone types can shift focus.
|
|
key={index}
|
|
item={item}
|
|
onToggle={(checked) => handleToggleItem(index, checked)}
|
|
onCommitText={(text) => handleCommitItemText(index, text)}
|
|
onRemove={() => handleRemoveItem(index)}
|
|
/>
|
|
))}
|
|
<AddChecklistItemRow onAdd={handleAddItem} />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={assigned_to ?? UNASSIGNED}
|
|
onValueChange={handleAssign}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
|
|
>
|
|
{assigned_to && assignee.exists && (
|
|
<HumanAvatar
|
|
size="sm"
|
|
initials={assignee.initials}
|
|
avatarObjectId={assignee.avatarObjectId}
|
|
/>
|
|
)}
|
|
<SelectValue placeholder="Unassigned" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
|
{network?.humans?.map((human) => (
|
|
<SelectItem key={human.id} value={human.id}>
|
|
{human.email_prefix}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function ChecklistItemRow({
|
|
item,
|
|
onToggle,
|
|
onCommitText,
|
|
onRemove,
|
|
}: {
|
|
item: ChecklistItem;
|
|
onToggle: (checked: boolean) => void;
|
|
onCommitText: (text: string) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const textField = useLiveDraftField({
|
|
remoteValue: item.text,
|
|
commit: onCommitText,
|
|
});
|
|
|
|
return (
|
|
<div className="group/item flex items-center gap-2.5">
|
|
<Checkbox
|
|
checked={item.done}
|
|
onCheckedChange={(checked) => onToggle(checked === true)}
|
|
className="border-white/30 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
|
aria-label={
|
|
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
|
|
}
|
|
/>
|
|
<input
|
|
value={textField.value}
|
|
onChange={(e) => textField.onChange(e.target.value)}
|
|
onFocus={textField.onFocus}
|
|
onBlur={textField.onBlur}
|
|
placeholder="Subtask"
|
|
className={cn(
|
|
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
|
|
item.done && 'text-white/40 line-through',
|
|
)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100"
|
|
aria-label="Remove subtask"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
|
|
const [draft, setDraft] = useState('');
|
|
|
|
const submit = () => {
|
|
const text = draft.trim();
|
|
if (!text) return;
|
|
onAdd(text);
|
|
setDraft('');
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5">
|
|
<Plus className="size-4 text-white/30" />
|
|
<input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
submit();
|
|
}
|
|
}}
|
|
onBlur={submit}
|
|
placeholder="Add subtask…"
|
|
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|