Files
hyperframes/packages/studio/src/components/editor/propertyPanelCommitField.tsx
T
Miguel Ángel 2b65b4efce fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
2026-07-17 14:15:30 -04:00

179 lines
5.5 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { adjustNumericToken, parseNumericToken } from "./propertyPanelHelpers";
import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
function arrowDirection(key: string): 1 | -1 | null {
if (key === "ArrowUp") return 1;
if (key === "ArrowDown") return -1;
return null;
}
export function CommitField({
value,
disabled,
liveCommit,
align = "left",
onPreview,
onCommit,
}: {
value: string;
disabled?: boolean;
liveCommit?: boolean;
align?: "left" | "right";
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
}) {
const [draft, setDraft] = useState(value);
const valueRef = useRef(value);
const draftRef = useRef(draft);
const inputRef = useRef<HTMLInputElement>(null);
const focusedRef = useRef(false);
const dirtyRef = useRef(false);
valueRef.current = value;
draftRef.current = draft;
const gestureActiveRef = useRef(false);
const gestureSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const gestureTransaction = useInspectorGestureTransaction({
sourceValue: value,
onPreview: (nextValue) => {
setDraft(nextValue);
onPreview?.(nextValue);
},
onCommit,
});
const gestureTransactionRef = useRef(gestureTransaction);
gestureTransactionRef.current = gestureTransaction;
const clearGestureSettleTimer = () => {
if (!gestureSettleTimerRef.current) return;
clearTimeout(gestureSettleTimerRef.current);
gestureSettleTimerRef.current = null;
};
const settleGesture = () => {
clearGestureSettleTimer();
if (!gestureActiveRef.current) return false;
gestureActiveRef.current = false;
gestureTransaction.settle();
return true;
};
const scheduleGestureSettle = () => {
clearGestureSettleTimer();
gestureSettleTimerRef.current = setTimeout(() => {
gestureSettleTimerRef.current = null;
if (!gestureActiveRef.current) return;
gestureActiveRef.current = false;
gestureTransactionRef.current.settle();
}, 250);
};
const cancelGesture = () => {
clearGestureSettleTimer();
gestureActiveRef.current = false;
gestureTransaction.cancel();
};
const commitDraft = (nextValue: string) => {
setDraft(nextValue);
onPreview?.(nextValue);
if (nextValue !== valueRef.current) onCommit(nextValue);
};
const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!gestureActiveRef.current) return false;
event.preventDefault();
event.stopPropagation();
cancelGesture();
return true;
};
const previewNumericKeyStep = (event: React.KeyboardEvent<HTMLInputElement>) => {
const direction = arrowDirection(event.key);
if (direction === null) return;
const nextDraft = adjustNumericToken(draftRef.current, direction, event);
if (!nextDraft) return;
event.preventDefault();
dirtyRef.current = false;
gestureActiveRef.current = true;
gestureTransaction.preview(nextDraft);
scheduleGestureSettle();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
cancelGestureFromKeyEvent(event);
return;
}
if (event.key === "Escape") {
cancelGestureFromKeyEvent(event);
return;
}
if (event.key === "Enter") {
event.currentTarget.blur();
return;
}
previewNumericKeyStep(event);
};
useEffect(() => {
if (focusedRef.current && dirtyRef.current) return;
setDraft(value);
}, [value]);
useEffect(() => {
const el = inputRef.current;
if (!el) return;
const handler = (event: WheelEvent) => {
if (disabled || document.activeElement !== el) return;
const delta = event.deltaY === 0 ? event.deltaX : event.deltaY;
if (delta === 0) return;
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, event);
if (!nextDraft) return;
event.preventDefault();
event.stopPropagation();
dirtyRef.current = false;
gestureActiveRef.current = true;
gestureTransactionRef.current.preview(nextDraft);
scheduleGestureSettle();
};
el.addEventListener("wheel", handler, { passive: false });
return () => {
el.removeEventListener("wheel", handler);
clearGestureSettleTimer();
};
}, [disabled]);
return (
<input
ref={inputRef}
type="text"
value={draft}
disabled={disabled}
onFocus={() => {
focusedRef.current = true;
}}
onChange={(event) => {
settleGesture();
dirtyRef.current = true;
setDraft(event.target.value);
if (liveCommit) onPreview?.(event.target.value);
}}
onBlur={() => {
if (settleGesture()) {
focusedRef.current = false;
return;
}
const wasDirty = dirtyRef.current;
focusedRef.current = false;
dirtyRef.current = false;
if (wasDirty && (!liveCommit || parseNumericToken(draft))) {
commitDraft(draft);
} else {
setDraft(valueRef.current);
if (wasDirty && liveCommit) onPreview?.(valueRef.current);
}
}}
onKeyDown={handleKeyDown}
title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
align === "right" ? "text-right" : "text-left"
}`}
/>
);
}