Add immersive focus mode for task editing #300
@@ -277,6 +277,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
|
||||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||||
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
const [textReactionOpen, setTextReactionOpen] = useState(false);
|
||||||
|
const [taskEditing, setTaskEditing] = useState(false);
|
||||||
|
|
||||||
const handleSubmitTextReaction = useCallback(
|
const handleSubmitTextReaction = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -291,6 +292,12 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
navigate(`/${networkId}`);
|
navigate(`/${networkId}`);
|
||||||
}, [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({
|
useStreamNavigationKeys({
|
||||||
next,
|
next,
|
||||||
prev,
|
prev,
|
||||||
@@ -299,6 +306,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
mediaRef,
|
mediaRef,
|
||||||
onExit: handleExitNavigate,
|
onExit: handleExitNavigate,
|
||||||
onToggleViewMode: toggleViewMode,
|
onToggleViewMode: toggleViewMode,
|
||||||
|
editing: taskEditing,
|
||||||
|
onExitEditing: handleExitEditing,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenHuddle = useCallback(() => {
|
const handleOpenHuddle = useCallback(() => {
|
||||||
@@ -449,6 +458,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={handleParticleEnded}
|
onEnded={handleParticleEnded}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
|
immersive
|
||||||
|
onEditingChange={setTaskEditing}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ interface TaskParticleViewProps {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress?: (ratio: number) => 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;
|
const DWELL_DURATION_S = 8;
|
||||||
@@ -56,6 +60,8 @@ export function TaskParticleView({
|
|||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
immersive = false,
|
||||||
|
onEditingChange,
|
||||||
}: TaskParticleViewProps) {
|
}: TaskParticleViewProps) {
|
||||||
const { networkId } = parseParticlePath(containerPath);
|
const { networkId } = parseParticlePath(containerPath);
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
@@ -74,6 +80,19 @@ 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}`);
|
||||||
|
|
||||||
|
// 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({
|
useFixedDwell({
|
||||||
id: particle.id,
|
id: particle.id,
|
||||||
durationS: DWELL_DURATION_S,
|
durationS: DWELL_DURATION_S,
|
||||||
@@ -166,8 +185,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
|
<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="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)}
|
onFocusCapture={() => setEditing(true)}
|
||||||
onBlurCapture={(e) => {
|
onBlurCapture={(e) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ interface UseStreamNavigationKeysOptions {
|
|||||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
onToggleViewMode: () => 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
|
* Arrow keys (with shift+arrow seek), Escape, and list-view toggle. While a
|
||||||
* paused for any reason (overlay, hold-space, compose).
|
* 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({
|
export function useStreamNavigationKeys({
|
||||||
next,
|
next,
|
||||||
@@ -26,9 +32,23 @@ export function useStreamNavigationKeys({
|
|||||||
mediaRef,
|
mediaRef,
|
||||||
onExit,
|
onExit,
|
||||||
onToggleViewMode,
|
onToggleViewMode,
|
||||||
|
editing,
|
||||||
|
onExitEditing,
|
||||||
}: UseStreamNavigationKeysOptions) {
|
}: UseStreamNavigationKeysOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
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;
|
if (isTypingTarget(e)) return;
|
||||||
|
|
||||||
const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1;
|
const hasNext = currentIndex >= 0 && currentIndex < childrenLength - 1;
|
||||||
@@ -76,5 +96,7 @@ export function useStreamNavigationKeys({
|
|||||||
mediaRef,
|
mediaRef,
|
||||||
onExit,
|
onExit,
|
||||||
onToggleViewMode,
|
onToggleViewMode,
|
||||||
|
editing,
|
||||||
|
onExitEditing,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user