Files
hyperframes/packages/studio/src/utils/studioHelpers.ts
T
Vance Ingalls e820e18092 fix(studio): atomic timing pin, expanded-list Hide All, repeated-host matching, pointercancel revert
Fixes real bugs from two independent re-reviews (#2225 @ 65954c3804,
#2416 @ beaf4ffbf6):

- FlatTimingRow's pinRange committed a pinned start+duration range through
  TWO sequential onSetAttribute calls. Each resolves domEditSelection fresh
  from current hook state, so a selection change between the two awaits
  could misdirect the second write at the newly-selected element instead of
  the one being edited, and a failure of just the second call left the pair
  half-applied (inconsistent inferred/explicit state). Added
  commitDataAttributes/handleDomAttributesCommit (mirroring
  onCommitAnimatedProperties's same-shaped fix for GSAP property batches):
  one PatchOperation[] persist call against an explicit, caller-supplied
  selection — not the "current" one — threaded through as the new optional
  onSetAttributes prop. pinRange uses it when provided, falls back to the
  old sequential behavior otherwise.

- Hide All silently dropped nested sub-composition children: a selection
  inside a sub-comp with no timeline-store entry of its own resolves to a
  virtual `sourceFile#domId` key (the fallback branch exists so the
  expansion hook can later resolve it via clipParentMap), but
  toggleTimelineElementHidden only searched the RAW store list, which never
  contains that key. useTimelineElementVisibilityEditing now resolves
  against useExpandedTimelineElements() instead, matching the track-based
  toggle's existing approach — the expanded list synthesizes a real,
  patchable TimelineElement (matching key/domId/sourceFile) for each visible
  child whenever its host is currently expanded.

- Two composition hosts importing the same sub-composition collapsed to
  the first one: findMatchingTimelineElementId ORed domId/selector/
  compositionSrc matches with equal priority in a single per-element scan,
  so `.find()` could stop at an EARLIER, unrelated host that merely shared
  the compositionSrc, before the scan ever reached the correct domId/
  selector match further down the list. Restructured to try domId, then
  selector, across the WHOLE list first; compositionSrc-only matching is
  now a true last resort for when neither identifies a specific element.

- FlatSlider's native pointercancel handler (a platform-level gesture abort
  — scroll/touch takeover, pen leaving range) manually duplicated the
  pointer-capture release logic instead of calling cancelDrag, so it never
  reverted to the pre-drag value — leaving whatever intermediate position
  the pointer last reached committed, unlike the Escape/right-click paths
  added in the previous round. Now calls cancelDrag directly.

- useColorGradingController's flushPendingPersist read identityKeyRef.current
  fresh at flush time rather than a value snapshotted when the edit was
  scheduled. Defensive fix: added pendingPersistIdentityRef, set alongside
  pendingPersistValueRef in commitColorGrading, read by flushPendingPersist
  instead of the live ref — closes the gap regardless of how unlikely the
  actual race is given the identity-cleanup effect's existing eager-flush
  behavior.

Two prior findings re-verified as already fixed further up this same
Graphite stack (not re-fixed here, per established stack-order handling):
metadata-cache negative-caching (267cdfce1) and cross-file
selectionIdentityKey (6f40e03a1), both landing after #2225's reviewed head.

StudioRightPanel.tsx crossed the 600-line file-size gate after wiring the
new onSetAttributes prop through; extracted the inspector split-pane resize
handlers (previously inlined) into their own useInspectorSplitResize hook.

New regression tests: repeated-composition-host resolution, atomic vs.
fallback pinRange commit paths, pointercancel revert. Full studio suite
still at the known pre-existing 55-failure baseline, zero new regressions.
Typecheck/oxlint/oxfmt clean.
2026-07-14 16:28:33 -07:00

404 lines
13 KiB
TypeScript

import type { TimelineElement } from "../player/store/playerStore";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { TimelineAssetKind } from "./timelineAssetDrop";
import { roundToCenti } from "./rounding";
export interface EditingFile {
path: string;
content: string | null;
}
export interface AppToast {
message: string;
tone: "error" | "info";
}
export type RightPanelTab =
| "layers"
| "design"
| "renders"
| "block-params"
| "slideshow"
| "variables";
export type RightInspectorPane = "layers" | "design";
export interface RightInspectorPanes {
layers: boolean;
design: boolean;
}
export interface AgentModalAnchorPoint {
x: number;
y: number;
}
export function getTimelineElementLabel(element: TimelineElement): string {
return element.label || element.id || element.tag;
}
function normalizeProjectAssetPath(value: string): string {
const trimmed = value.trim();
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
return decodeURIComponent(maybeUrl)
.replace(/\\/g, "/")
.replace(/^\.?\//, "");
}
export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
fromParts.pop();
while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
fromParts.shift();
targetParts.shift();
}
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
}
function isAbsoluteFilePath(value: string): boolean {
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
}
export function toProjectAbsolutePath(
projectDir: string | null,
sourceFile: string,
): string | undefined {
const trimmedSource = sourceFile.trim();
if (!trimmedSource) return undefined;
const normalizedSource = trimmedSource.replace(/\\/g, "/");
if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalizedRoot) return undefined;
return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
}
export function normalizeDomEditStyleValue(property: string, value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
if (
["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
/^-?\d+(\.\d+)?$/.test(trimmed)
) {
return `${trimmed}px`;
}
return trimmed;
}
export function isImageBackgroundValue(value: string): boolean {
return /^url\(/i.test(value.trim());
}
export function isManualGeometryStyleProperty(property: string): boolean {
return property === "left" || property === "top" || property === "width" || property === "height";
}
export function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
if (!target || typeof target !== "object") return null;
const maybeNode = target as {
nodeType?: number;
parentElement?: Element | null;
};
if (maybeNode.nodeType === 1) return target as HTMLElement;
if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
return maybeNode.parentElement as HTMLElement;
}
return null;
}
export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
const el = getEventTargetElement(target);
if (!el) return false;
return Boolean(
el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
);
}
export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
const isMac =
typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
const modifier = isMac ? "Cmd" : "Ctrl";
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
}
type ElementMatchSelection = Pick<
DomEditSelection,
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
>;
function matchesByDomId(
selection: ElementMatchSelection,
element: TimelineElement,
selectionSourceFile: string,
): boolean {
if (!selection.id) return false;
return (
element.domId === selection.id && (element.sourceFile || "index.html") === selectionSourceFile
);
}
function matchesByCompositionHost(
selection: ElementMatchSelection,
element: TimelineElement,
): boolean {
if (!selection.isCompositionHost || !selection.compositionSrc) return false;
return element.compositionSrc === selection.compositionSrc;
}
function matchesBySelector(selection: ElementMatchSelection, element: TimelineElement): boolean {
if (!selection.selector) return false;
return (
element.selector === selection.selector &&
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
(element.sourceFile ?? "index.html") === selection.sourceFile
);
}
export function findMatchingTimelineElementId(
selection: ElementMatchSelection,
elements: TimelineElement[],
): string | null {
const selectionSourceFile = selection.sourceFile || "index.html";
// Priority matters, not just "any of the three": a composition-host
// selection always carries its OWN id/selector too (computed generically
// for any element), so two repeated hosts sharing the same compositionSrc
// are still individually addressable by id/selector. Checking
// matchesByCompositionHost with equal priority in a single OR-per-element
// scan let `.find()` stop at an EARLIER, unrelated host that merely shares
// the compositionSrc, before the scan ever reached the correct id/selector
// match further down the list — collapsing every repeated host to the
// first one. Try id, then selector, across the WHOLE list first; only fall
// back to the coarser compositionSrc-only match when neither identifies a
// specific element.
const byId = selection.id
? elements.find((el) => matchesByDomId(selection, el, selectionSourceFile))
: undefined;
if (byId) return byId.key ?? byId.id;
const bySelector = selection.selector
? elements.find((el) => matchesBySelector(selection, el))
: undefined;
if (bySelector) return bySelector.key ?? bySelector.id;
const byHost = elements.find((el) => matchesByCompositionHost(selection, el));
if (byHost) return byHost.key ?? byHost.id;
// Child inside a sub-composition: return a qualified ID so the expansion
// hook can resolve the child via clipParentMap even though no timeline
// element exists for it yet (the expansion creates it on the fly).
if (selection.id && selectionSourceFile !== "index.html") {
return `${selectionSourceFile}#${selection.id}`;
}
return null;
}
/**
* A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
* inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
* ancestor that IS a clip so the timeline still selects + inline-expands around it.
*/
export function findTimelineIdByAncestor(
element: Element | null | undefined,
elements: TimelineElement[],
sourceFile: string,
): string | null {
let ancestor = element?.parentElement ?? null;
while (ancestor) {
const id = ancestor.id;
if (id) {
const match = elements.find(
(el) => el.domId === id && (el.sourceFile ?? "index.html") === sourceFile,
);
if (match) return match.key ?? match.id;
}
ancestor = ancestor.parentElement;
}
return null;
}
/**
* Resolve the timeline element id for a DOM selection: direct match first, then
* nearest clip ancestor. The ancestor lookup resolves against the selection's own
* source file, falling back to the active composition path, then index.html — so a
* sub-composition selection with no explicit sourceFile resolves against the comp
* currently open, not always the root file.
*/
export function resolveTimelineIdForSelection(
selection: DomEditSelection,
elements: TimelineElement[],
activeCompPath: string | null,
): string | null {
return (
findMatchingTimelineElementId(selection, elements) ??
findTimelineIdByAncestor(
selection.element,
elements,
selection.sourceFile || activeCompPath || "index.html",
)
);
}
/**
* Resolve every multi-selected element to its scope-qualified timeline key
* (dropping unresolvable ones). Selections carry bare DOM ids/selectors, but
* the visibility toggle matches keys like "index.html#hero" — and callers must
* hide all keys in ONE call so the file is patched in a single atomic write
* (per-element calls would clobber each other's reads).
*/
export function timelineKeysForSelections(
selections: readonly DomEditSelection[],
elements: TimelineElement[],
activeCompPath: string | null,
): string[] {
return selections
.map((selection) => resolveTimelineIdForSelection(selection, elements, activeCompPath))
.filter((key): key is string => key !== null);
}
export type ToggleHiddenHandler = (
elementKey: string | readonly string[],
hidden: boolean,
) => Promise<void> | void;
export function resolveTimelineSelectionSeekTime(
currentTime: number,
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
): number | null {
if (!element) return null;
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return null;
const start = Math.max(0, element.start);
const end = Math.max(start, start + Math.max(0, element.duration));
const time = Number.isFinite(currentTime) ? currentTime : start;
return clampNumber(time, start, end);
}
export function clampNumber(value: number, min: number, max: number): number {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
// fallow-ignore-next-line unused-export
export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
export function collectHtmlIds(source: string): string[] {
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
}
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
image: 3,
video: 5,
audio: 5,
};
export async function resolveDroppedAssetDuration(
projectId: string,
assetPath: string,
kind: TimelineAssetKind,
): Promise<number> {
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
const media = document.createElement(kind === "video" ? "video" : "audio");
media.preload = "metadata";
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
const duration = await new Promise<number>((resolve) => {
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
const finalize = (value: number) => {
window.clearTimeout(timeout);
resolve(value);
};
media.addEventListener(
"loadedmetadata",
() => {
const raw = Number(media.duration);
finalize(
Number.isFinite(raw) && raw > 0
? roundToCenti(raw)
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
);
},
{ once: true },
);
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
once: true,
});
});
media.src = "";
media.load();
return duration;
}
export async function resolveDroppedAssetDimensions(
projectId: string,
assetPath: string,
kind: TimelineAssetKind,
): Promise<{ width: number; height: number } | null> {
if (kind === "audio") return null;
const src = `/api/projects/${projectId}/preview/${assetPath}`;
if (kind === "image") {
return new Promise((resolve) => {
const img = new Image();
const timeout = window.setTimeout(() => resolve(null), 3000);
img.addEventListener(
"load",
() => {
window.clearTimeout(timeout);
resolve(
img.naturalWidth > 0 && img.naturalHeight > 0
? { width: img.naturalWidth, height: img.naturalHeight }
: null,
);
},
{ once: true },
);
img.addEventListener(
"error",
() => {
window.clearTimeout(timeout);
resolve(null);
},
{ once: true },
);
img.src = src;
});
}
return new Promise((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
const timeout = window.setTimeout(() => resolve(null), 3000);
const finalize = (value: { width: number; height: number } | null) => {
window.clearTimeout(timeout);
video.src = "";
video.load();
resolve(value);
};
video.addEventListener(
"loadedmetadata",
() => {
finalize(
video.videoWidth > 0 && video.videoHeight > 0
? { width: video.videoWidth, height: video.videoHeight }
: null,
);
},
{ once: true },
);
video.addEventListener("error", () => finalize(null), { once: true });
video.src = src;
});
}