Add immersive focus mode for task editing #300
@@ -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