mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): persist element positions in HTML, fix resize overlay drift and GSAP double-translation (#829)
* feat(studio): add pasteboard background to preview viewport Adds bg-neutral-800 to the preview viewport so the area outside the canvas is visually distinct from the composition content — consistent with professional video editors (Premiere, DaVinci, Figma). * feat(studio): pasteboard background and canvas outline around preview - NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard color surrounding the canvas — distinct from the app chrome (#0a0a0a) - Player wrapper: drop bg-black so the pasteboard shows around the canvas (loading overlays still cover the area with bg-black during load) - Player: set host background to transparent via inline style (overrides :host { background: #000 } in shadow DOM), and inject a style rule into the open shadow root so .hfp-container has overflow:visible and the canvas iframe gets a thin white ring + soft drop-shadow — making the canvas boundary legible against the pasteboard * feat(studio): disable manual positioning JSON by default, add toggle Manual edits were always stored in `.hyperframes/studio-manual-edits.json`, making it hard to share source without the sidecar file and easy to accidentally reposition elements via drag. Changes: - `enabled` field added to `StudioManualEditManifest` (defaults to `false` when absent — existing projects are unaffected until they opt in) - Drag handles, resize, and rotation handles are hidden when disabled - Layout X/Y/W/H/R fields in the Design panel are read-only when disabled - "Manual positioning" toggle added at the bottom of the Design panel, visible whether or not an element is selected - Toggle state is persisted to `.hyperframes/studio-manual-edits.json` so each project can opt in independently - `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard cap (env off → feature off regardless of project setting) * feat(studio): enable manual positioning by default (opt-out) * feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle * feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style persistence baked directly into the HTML source. Drag/resize/rotation values are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`, `--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via `persistDomEditOperations` — no re-apply step needed on load. Key changes: - `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the property/attribute from the HTML tag instead of setting it - `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live element state into `PatchOperation[]` for HTML source writes; add `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers) - `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure - `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs seek hooks via `reapplyPositionEditsAfterSeek` - `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh) - `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled` gates to just `canApplyManualOffset` — every draggable element is always draggable - `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props - `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`, `roundRotationAngle`, and snapshot/CSS-property types * fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test * fix(studio): strip GSAP-cached translate from transform on path offset apply * fix(studio): remove Reset edits button from design panel * feat(studio): wire reloadPreview into manifest persistence; drop stale group-selection refresh - Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads via the refresh-key path instead of directly touching the iframe. - Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers; HTML is now the source of truth so no stale-ref refresh is needed. - Add `manualEditsRenderScript` helper; export via studio-api and apply it in `htmlCompiler` during HTML compilation. * fix(studio): prevent root composition from being selected; correct overlay drift on resize - Guard `getDomLayerPatchTarget` against elements with `data-composition-id` so the root composition div is never returned as a visual selection target. - Apply the same guard to the raw `elementFromPoint` fallback in `getPreviewTargetFromPointer`, which was the actual escape path. - Thread `iframeRef` into gesture handler opts; after applying draft dimensions during resize, re-read the element BCR via `toOverlayRect` and update the overlay box position to compensate for visual drift on elements with centered transform-origin (e.g. GSAP scale tweens). * fix(studio): correct resize overlay for scaled elements; block invisible element selection - Resize: use BCR from `toOverlayRect` for both position and size after applying draft dimensions — GSAP scale makes visual size diverge from raw CSS size, BCR is the only accurate source during a gesture. - Click selection: add `isElementComputedVisible` guard to the `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements cannot be selected even though the browser hit-test returns them. * fix(studio): reload preview on external file changes via SSE/HMR Share the app-level domEditSaveTimestampRef with useManifestPersistence so the SSE/HMR handler can suppress echoes from all studio saves (code tab, timeline, DOM edits), then call reloadPreview() for non-motion external changes that aren't echoes of our own saves. * fix(studio): suppress post-resize click to keep selection on resized element * fix(studio): serve registry blocks without index.html in preview Blocks ship as {id}.html + assets/ with no index.html. The preview route hard-coded index.html so these projects returned 404 and their assets (e.g. korea-map.png, map-nyc-paris.png) were never served. Add resolveProjectMainHtml() that falls back to {id}.html, thread the resolved compositionPath through transformPreviewHtml and injectStudioPreviewAugmentations, and update listProjects() in the vite adapter to surface block directories in the project list. * fix(render): preserve studio drag/resize/rotation offsets in rendered video Three issues caused studio-edited positions to be lost during rendering: 1. The seek-reapply script used setInterval to wrap window.__hf.seek, but Puppeteer's page.evaluate() calls don't yield the event loop for macrotasks — the interval never fired, so reapplyAll() never ran after GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek property, wrapping it synchronously the instant the bridge assigns it. 2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from <video> to proxy <img> during render) included "transform" but not "translate", "rotate", or "scale" — the CSS Transforms Level 2 individual properties used by studio drag/resize/rotation. The proxy was positioned at offsetLeft/ offsetTop without the translate offset. 3. getViewportMatrix (HDR compositor) only read cs.transform, missing individual transform properties entirely. Added composeIndividualTransforms to build the translate × rotate × scale matrix and compose it before the legacy transform matrix. * fix(studio): select elements with pointer-events: none in preview Compositions often set pointer-events: none on scenes, avatar wrappers, and decorative layers. elementsFromPoint() skips these elements entirely, making them unselectable in the Studio. Fix: temporarily inject a * { pointer-events: auto !important } stylesheet during hit-testing, then remove it immediately after. Also adds a pointer_events_none lint rule (info severity, visible with --verbose) so authors know which selectors may affect Studio selection.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
packages/studio/src/player/hooks/useTimelinePlayer.ts
|
||||
packages/studio/src/hooks/useManifestPersistence.ts
|
||||
packages/studio/src/player/components/PlayerControls.tsx
|
||||
packages/studio/src/components/editor/manualEdits.test.ts
|
||||
packages/studio/src/components/editor/manualEditsDom.ts
|
||||
|
||||
@@ -24,6 +24,9 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [
|
||||
"mask-repeat",
|
||||
"transform",
|
||||
"transform-origin",
|
||||
"translate",
|
||||
"rotate",
|
||||
"scale",
|
||||
"box-sizing",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -319,4 +319,56 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// pointer_events_none
|
||||
({ tags, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const reported = new Set<string>();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
|
||||
const inlineStyle = readAttr(tag.raw, "style") ?? "";
|
||||
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
|
||||
const id = readAttr(tag.raw, "id");
|
||||
const key = id ?? tag.raw;
|
||||
if (reported.has(key)) continue;
|
||||
reported.add(key);
|
||||
findings.push({
|
||||
code: "pointer_events_none",
|
||||
severity: "info",
|
||||
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
|
||||
elementId: id || undefined,
|
||||
fixHint:
|
||||
"If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
for (const style of styles) {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(style.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
root.walkDecls("pointer-events", (decl) => {
|
||||
if (decl.value.trim().toLowerCase() !== "none") return;
|
||||
const rule = decl.parent;
|
||||
if (!rule || rule.type !== "rule") return;
|
||||
const selector = (rule as postcss.Rule).selector;
|
||||
if (reported.has(selector)) return;
|
||||
reported.add(selector);
|
||||
findings.push({
|
||||
code: "pointer_events_none",
|
||||
severity: "info",
|
||||
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
|
||||
selector,
|
||||
fixHint:
|
||||
"If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,6 +12,217 @@ export function createStudioManualEditsRenderBodyScript(
|
||||
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a self-contained IIFE string that re-applies studio position edits
|
||||
* (translate, rotate) after every GSAP seek by querying data attributes baked
|
||||
* into the HTML. Works without a JSON manifest — positions are already inlined
|
||||
* as CSS custom properties on the elements.
|
||||
*/
|
||||
export function createStudioPositionSeekReapplyScript(): string {
|
||||
return `(${studioPositionSeekReapplyRuntime.toString()})();`;
|
||||
}
|
||||
|
||||
function studioPositionSeekReapplyRuntime(): void {
|
||||
const OFFSET_X_PROP = "--hf-studio-offset-x";
|
||||
const OFFSET_Y_PROP = "--hf-studio-offset-y";
|
||||
const ROTATION_PROP = "--hf-studio-rotation";
|
||||
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
|
||||
const ROTATION_ATTR = "data-hf-studio-rotation";
|
||||
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
||||
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
|
||||
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
|
||||
|
||||
if (
|
||||
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
|
||||
!document.querySelector("[" + ROTATION_ATTR + '="true"]')
|
||||
)
|
||||
return;
|
||||
|
||||
const splitTopLevelWhitespace = (value: string): string[] => {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const char of value.trim()) {
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(char) && depth === 0) {
|
||||
if (current) parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current);
|
||||
return parts;
|
||||
};
|
||||
|
||||
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
|
||||
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
|
||||
if (!original || original === "none") return x + " " + y;
|
||||
const parts = splitTopLevelWhitespace(original);
|
||||
if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y;
|
||||
if (parts.length >= 2) {
|
||||
const z = parts.length >= 3 ? " " + parts[2] : "";
|
||||
return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z;
|
||||
}
|
||||
return x + " " + y;
|
||||
};
|
||||
|
||||
const isSimpleRotateAngle = (value: string): boolean =>
|
||||
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
|
||||
|
||||
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
|
||||
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
|
||||
if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue;
|
||||
return "calc(" + original + " + " + rotationValue + ")";
|
||||
};
|
||||
|
||||
const reapplyAll = (): void => {
|
||||
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
|
||||
for (let i = 0; i < offsetEls.length; i++) {
|
||||
const el = offsetEls[i] as HTMLElement;
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
const x = el.style.getPropertyValue(OFFSET_X_PROP);
|
||||
const y = el.style.getPropertyValue(OFFSET_Y_PROP);
|
||||
if (x || y) {
|
||||
el.style.setProperty(
|
||||
"translate",
|
||||
composeTranslate(
|
||||
el,
|
||||
"var(" + OFFSET_X_PROP + ", 0px)",
|
||||
"var(" + OFFSET_Y_PROP + ", 0px)",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]');
|
||||
for (let i = 0; i < rotEls.length; i++) {
|
||||
const el = rotEls[i] as HTMLElement;
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
const rot = el.style.getPropertyValue(ROTATION_PROP);
|
||||
if (rot) {
|
||||
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runtimeWindow = window as Window & {
|
||||
__hf?: Record<string, unknown>;
|
||||
__player?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const isWrapped = (fn: (time: number) => unknown): boolean =>
|
||||
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_PROP]);
|
||||
|
||||
const markWrapped = (fn: (time: number) => unknown): void => {
|
||||
try {
|
||||
Object.defineProperty(fn, WRAPPED_PROP, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: true,
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
(fn as unknown as Record<string, unknown>)[WRAPPED_PROP] = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => {
|
||||
const fn = get();
|
||||
if (typeof fn !== "function") return false;
|
||||
const seek = fn as (time: number) => unknown;
|
||||
if (isWrapped(seek)) {
|
||||
reapplyAll();
|
||||
return true;
|
||||
}
|
||||
const wrapped = function (this: unknown, time: number): unknown {
|
||||
const result = seek.call(this, time);
|
||||
reapplyAll();
|
||||
return result;
|
||||
};
|
||||
markWrapped(wrapped);
|
||||
set(wrapped);
|
||||
reapplyAll();
|
||||
return true;
|
||||
};
|
||||
|
||||
const wrapSeekFunctions = (): boolean => {
|
||||
const a = wrapFn(
|
||||
() => runtimeWindow.__hf?.["seek"],
|
||||
(fn) => {
|
||||
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
|
||||
},
|
||||
);
|
||||
const b = wrapFn(
|
||||
() => runtimeWindow.__player?.["renderSeek"],
|
||||
(fn) => {
|
||||
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
|
||||
},
|
||||
);
|
||||
return a || b;
|
||||
};
|
||||
|
||||
const installSeekTrap = (
|
||||
obj: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
getter: () => unknown,
|
||||
setter: (fn: (time: number) => unknown) => void,
|
||||
): void => {
|
||||
if (!obj) return;
|
||||
try {
|
||||
let current = obj[key];
|
||||
Object.defineProperty(obj, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return current;
|
||||
},
|
||||
set(value: unknown) {
|
||||
current = value;
|
||||
if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) {
|
||||
wrapFn(getter, setter);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* non-configurable — fall back to polling */
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true });
|
||||
} else {
|
||||
reapplyAll();
|
||||
}
|
||||
|
||||
wrapSeekFunctions();
|
||||
installSeekTrap(
|
||||
runtimeWindow.__hf,
|
||||
"seek",
|
||||
() => runtimeWindow.__hf?.["seek"],
|
||||
(fn) => {
|
||||
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
|
||||
},
|
||||
);
|
||||
installSeekTrap(
|
||||
runtimeWindow.__player as Record<string, unknown> | undefined,
|
||||
"renderSeek",
|
||||
() => runtimeWindow.__player?.["renderSeek"],
|
||||
(fn) => {
|
||||
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
|
||||
},
|
||||
);
|
||||
let remaining = 120;
|
||||
const interval = setInterval(() => {
|
||||
wrapSeekFunctions();
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) clearInterval(interval);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function studioManualEditsRenderRuntime(
|
||||
manifestContent: string,
|
||||
activeCompositionPath: string | null,
|
||||
|
||||
@@ -8,6 +8,7 @@ export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screens
|
||||
export {
|
||||
STUDIO_MANUAL_EDITS_PATH,
|
||||
createStudioManualEditsRenderBodyScript,
|
||||
createStudioPositionSeekReapplyScript,
|
||||
type StudioManualEditsRenderScriptOptions,
|
||||
} from "./helpers/manualEditsRenderScript.js";
|
||||
export {
|
||||
|
||||
@@ -143,6 +143,21 @@ async function transformPreviewHtml(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProjectMainHtml(
|
||||
projectDir: string,
|
||||
projectId: string,
|
||||
): { html: string; compositionPath: string } | null {
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
if (existsSync(indexPath)) {
|
||||
return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" };
|
||||
}
|
||||
const blockHtmlPath = join(projectDir, `${projectId}.html`);
|
||||
if (existsSync(blockHtmlPath)) {
|
||||
return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const previewCacheHeaders = (etag: string) => ({
|
||||
"Cache-Control": "private, no-cache",
|
||||
@@ -163,10 +178,12 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
|
||||
try {
|
||||
let bundled = await adapter.bundle(project.dir);
|
||||
let mainCompositionPath = "index.html";
|
||||
if (!bundled) {
|
||||
const indexPath = resolve(project.dir, "index.html");
|
||||
if (!existsSync(indexPath)) return c.text("not found", 404);
|
||||
bundled = readFileSync(indexPath, "utf-8");
|
||||
const main = resolveProjectMainHtml(project.dir, project.id);
|
||||
if (!main) return c.text("not found", 404);
|
||||
bundled = main.html;
|
||||
mainCompositionPath = main.compositionPath;
|
||||
}
|
||||
|
||||
// Inject runtime if not already present (check URL pattern and bundler attribute)
|
||||
@@ -187,21 +204,21 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
}
|
||||
|
||||
bundled = injectStudioPreviewAugmentations(
|
||||
await transformPreviewHtml(bundled, adapter, project, "index.html"),
|
||||
await transformPreviewHtml(bundled, adapter, project, mainCompositionPath),
|
||||
adapter,
|
||||
project.dir,
|
||||
"index.html",
|
||||
mainCompositionPath,
|
||||
);
|
||||
return c.html(bundled, 200, previewCacheHeaders(etag));
|
||||
} catch {
|
||||
const file = resolve(project.dir, "index.html");
|
||||
if (existsSync(file)) {
|
||||
const main = resolveProjectMainHtml(project.dir, project.id);
|
||||
if (main) {
|
||||
return c.html(
|
||||
injectStudioPreviewAugmentations(
|
||||
await transformPreviewHtml(readFileSync(file, "utf-8"), adapter, project, "index.html"),
|
||||
await transformPreviewHtml(main.html, adapter, project, main.compositionPath),
|
||||
adapter,
|
||||
project.dir,
|
||||
"index.html",
|
||||
main.compositionPath,
|
||||
),
|
||||
200,
|
||||
previewCacheHeaders(etag),
|
||||
|
||||
@@ -525,10 +525,16 @@ export async function queryElementStacking(
|
||||
if (!htmlEl) continue;
|
||||
mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop);
|
||||
const cs = window.getComputedStyle(htmlEl);
|
||||
if (cs.transform && cs.transform !== "none") {
|
||||
const origin = cs.transformOrigin.split(" ");
|
||||
const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
|
||||
const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
|
||||
const individualTransform = composeIndividualTransforms(cs);
|
||||
const hasIndividual = individualTransform !== null;
|
||||
const hasTransform = cs.transform && cs.transform !== "none";
|
||||
if (hasIndividual || hasTransform) {
|
||||
mat = mat.translate(ox, oy);
|
||||
if (hasIndividual) mat = mat.multiply(individualTransform);
|
||||
if (hasTransform) {
|
||||
try {
|
||||
const t = new DOMMatrix(cs.transform);
|
||||
if (
|
||||
@@ -539,16 +545,46 @@ export async function queryElementStacking(
|
||||
Number.isFinite(t.e) &&
|
||||
Number.isFinite(t.f)
|
||||
) {
|
||||
mat = mat.translate(ox, oy).multiply(t).translate(-ox, -oy);
|
||||
mat = mat.multiply(t);
|
||||
}
|
||||
} catch {
|
||||
// DOMMatrix constructor throws on malformed input — skip ancestor.
|
||||
// DOMMatrix constructor throws on malformed input — skip.
|
||||
}
|
||||
}
|
||||
mat = mat.translate(-ox, -oy);
|
||||
}
|
||||
}
|
||||
return mat.toString();
|
||||
}
|
||||
|
||||
function composeIndividualTransforms(cs: CSSStyleDeclaration): DOMMatrix | null {
|
||||
const translate = cs.getPropertyValue("translate").trim();
|
||||
const rotate = cs.getPropertyValue("rotate").trim();
|
||||
const scale = cs.getPropertyValue("scale").trim();
|
||||
const hasTranslate = translate && translate !== "none";
|
||||
const hasRotate = rotate && rotate !== "none";
|
||||
const hasScale = scale && scale !== "none";
|
||||
if (!hasTranslate && !hasRotate && !hasScale) return null;
|
||||
let m = new DOMMatrix();
|
||||
if (hasTranslate) {
|
||||
const parts = translate.split(/\s+/);
|
||||
const tx = parseFloat(parts[0] ?? "0") || 0;
|
||||
const ty = parseFloat(parts[1] ?? "0") || 0;
|
||||
if (tx !== 0 || ty !== 0) m = m.translate(tx, ty);
|
||||
}
|
||||
if (hasRotate) {
|
||||
const deg = parseFloat(rotate) || 0;
|
||||
if (deg !== 0) m = m.rotate(deg);
|
||||
}
|
||||
if (hasScale) {
|
||||
const parts = scale.split(/\s+/);
|
||||
const sx = parseFloat(parts[0] ?? "1") || 1;
|
||||
const sy = parseFloat(parts[1] ?? String(sx)) || sx;
|
||||
if (sx !== 1 || sy !== 1) m = m.scale(sx, sy);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function resolveLength(value: string, basis: number): number {
|
||||
if (value.endsWith("%")) {
|
||||
const pct = parseFloat(value) / 100;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import type { Page } from "puppeteer-core";
|
||||
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
||||
import { createStudioPositionSeekReapplyScript } from "@hyperframes/core/studio-api/manual-edits-render-script";
|
||||
|
||||
export interface CompiledComposition {
|
||||
html: string;
|
||||
@@ -993,7 +994,20 @@ export async function compileForRender(
|
||||
// Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png).
|
||||
// These can't be served by the file server, so we map them to paths the
|
||||
// orchestrator will copy into the compiled output directory.
|
||||
const { html, externalAssets } = collectExternalAssets(assembledHtml, projectDir);
|
||||
const { html: htmlWithAssets, externalAssets } = collectExternalAssets(assembledHtml, projectDir);
|
||||
|
||||
// Inject studio position seek re-apply script when positions are baked into HTML.
|
||||
// GSAP overwrites the `translate` CSS property on every frame seek; this script
|
||||
// re-asserts the CSS custom property var() form after each seek so dragged
|
||||
// positions survive frame-by-frame rendering without a JSON sidecar.
|
||||
const HF_POSITION_ATTRS = ['data-hf-studio-path-offset="true"', 'data-hf-studio-rotation="true"'];
|
||||
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr));
|
||||
const html = hasPositionEdits
|
||||
? htmlWithAssets.replace(
|
||||
/<\/body>/i,
|
||||
`<script>${createStudioPositionSeekReapplyScript()}</script></body>`,
|
||||
)
|
||||
: htmlWithAssets;
|
||||
|
||||
// Parse main HTML elements
|
||||
const mainVideos = parseVideoElements(html);
|
||||
|
||||
@@ -144,6 +144,8 @@ export function StudioApp() {
|
||||
recordEdit: editHistory.recordEdit,
|
||||
previewIframeRef,
|
||||
activeCompPathRef,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview: () => setRefreshKey((k) => k + 1),
|
||||
});
|
||||
|
||||
const timelineEditing = useTimelineEditing({
|
||||
@@ -197,12 +199,9 @@ export function StudioApp() {
|
||||
setRightPanelTab: panelLayout.setRightPanelTab,
|
||||
showToast,
|
||||
refreshPreviewDocumentVersion,
|
||||
commitStudioManualEditManifestOptimistically:
|
||||
manifestPersistence.commitStudioManualEditManifestOptimistically,
|
||||
queueDomEditSave: manifestPersistence.queueDomEditSave,
|
||||
commitStudioMotionManifestOptimistically:
|
||||
manifestPersistence.commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview:
|
||||
manifestPersistence.applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview,
|
||||
readProjectFile: fileManager.readProjectFile,
|
||||
writeProjectFile: fileManager.writeProjectFile,
|
||||
|
||||
@@ -59,7 +59,6 @@ export function StudioRightPanel({
|
||||
handleDomTextFieldStyleCommit,
|
||||
handleDomAddTextField,
|
||||
handleDomRemoveTextField,
|
||||
handleDomManualEditsReset,
|
||||
handleAskAgent,
|
||||
handleDomMotionCommit,
|
||||
handleDomMotionClear,
|
||||
@@ -173,7 +172,6 @@ export function StudioRightPanel({
|
||||
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
|
||||
onAddTextField={handleDomAddTextField}
|
||||
onRemoveTextField={handleDomRemoveTextField}
|
||||
onResetManualEdits={handleDomManualEditsReset}
|
||||
onAskAgent={handleAskAgent}
|
||||
onImportAssets={handleImportFiles}
|
||||
fontAssets={fontAssets}
|
||||
|
||||
@@ -113,6 +113,7 @@ describe("DomEditOverlay", () => {
|
||||
capabilities: {
|
||||
canEditText: true,
|
||||
canEditLayout: true,
|
||||
canMove: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: false,
|
||||
canApplyManualRotation: false,
|
||||
|
||||
@@ -138,6 +138,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
|
||||
const gestures = createDomEditOverlayGestureHandlers({
|
||||
overlayRef,
|
||||
iframeRef,
|
||||
boxRef,
|
||||
selectionRef,
|
||||
overlayRectRef,
|
||||
@@ -307,7 +308,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!allowCanvasMovement || e.shiftKey) return;
|
||||
if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
|
||||
gestures.startGroupDrag(e);
|
||||
}}
|
||||
onMouseDown={suppressBoxMouseDown}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from "react";
|
||||
import { Eye, Layers, MessageSquare, Move, RotateCcw, X } from "../../icons/SystemIcons";
|
||||
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
|
||||
import {
|
||||
collectDomEditLayerItems,
|
||||
getDomEditLayerKey,
|
||||
@@ -46,7 +46,6 @@ interface PropertyPanelProps {
|
||||
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
||||
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
|
||||
onRemoveTextField: (fieldKey: string) => void;
|
||||
onResetManualEdits: (element: DomEditSelection) => void;
|
||||
onAskAgent: () => void;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
fontAssets?: ImportedFontAsset[];
|
||||
@@ -134,7 +133,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onSetTextFieldStyle,
|
||||
onAddTextField,
|
||||
onRemoveTextField,
|
||||
onResetManualEdits,
|
||||
onAskAgent,
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
@@ -146,7 +144,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
|
||||
<div className="flex h-full flex-col bg-neutral-900">
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
{multiSelectCount > 1 ? (
|
||||
<>
|
||||
<Layers size={18} className="mb-3 text-neutral-600" />
|
||||
@@ -154,8 +153,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
{multiSelectCount} elements selected
|
||||
</p>
|
||||
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
||||
Select a single element to edit its properties. Click an element in the preview or use
|
||||
the timeline layer panel.
|
||||
Select a single element to edit its properties. Click an element in the preview or
|
||||
use the timeline layer panel.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -165,12 +164,13 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
Select an element in the preview.
|
||||
</p>
|
||||
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
||||
The inspector is tuned for element edits with safer geometry controls, color picking,
|
||||
and cleaner grouped layer controls.
|
||||
The inspector is tuned for element edits with safer geometry controls, color
|
||||
picking, and cleaner grouped layer controls.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,15 +253,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<MessageSquare size={15} />
|
||||
<span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResetManualEdits(element)}
|
||||
title="Reset move, size, and rotation edits"
|
||||
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-neutral-500 hover:text-white"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
<span>Reset edits</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ export function getDomLayerPatchTarget(
|
||||
activeCompositionPath: string | null,
|
||||
): Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile"> | null {
|
||||
if (!isInspectableLayerElement(el)) return null;
|
||||
if (el.hasAttribute("data-composition-id")) return null;
|
||||
|
||||
const selector = buildStableSelector(el);
|
||||
if (!selector) return null;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Window } from "happy-dom";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import {
|
||||
STUDIO_OFFSET_X_PROP,
|
||||
STUDIO_OFFSET_Y_PROP,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
STUDIO_WIDTH_PROP,
|
||||
applyStudioBoxSize,
|
||||
applyStudioBoxSizeDraft,
|
||||
applyStudioManualEditManifest,
|
||||
applyStudioPathOffset,
|
||||
applyStudioPathOffsetDraft,
|
||||
applyStudioRotation,
|
||||
@@ -16,22 +14,17 @@ import {
|
||||
beginStudioManualEditGesture,
|
||||
captureStudioBoxSize,
|
||||
captureStudioRotation,
|
||||
emptyStudioManualEditManifest,
|
||||
clearStudioBoxSize,
|
||||
clearStudioPathOffset,
|
||||
clearStudioRotation,
|
||||
endStudioManualEditGesture,
|
||||
installStudioManualEditSeekReapply,
|
||||
isStudioManualEditManifestPath,
|
||||
parseStudioManualEditManifest,
|
||||
readStudioFileChangePath,
|
||||
readStudioBoxSize,
|
||||
readStudioFileChangePath,
|
||||
readStudioPathOffset,
|
||||
readStudioRotation,
|
||||
removeStudioManualEditsForSelection,
|
||||
restoreStudioBoxSize,
|
||||
restoreStudioRotation,
|
||||
serializeStudioManualEditManifest,
|
||||
upsertStudioBoxSizeEdit,
|
||||
upsertStudioPathOffsetEdit,
|
||||
upsertStudioRotationEdit,
|
||||
} from "./manualEdits";
|
||||
|
||||
function createDocument(markup: string): Document {
|
||||
@@ -40,36 +33,6 @@ function createDocument(markup: string): Document {
|
||||
return window.document;
|
||||
}
|
||||
|
||||
function createSelection(): DomEditSelection {
|
||||
return {
|
||||
element: {} as HTMLElement,
|
||||
id: "card",
|
||||
selector: "#card",
|
||||
selectorIndex: undefined,
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
compositionSrc: undefined,
|
||||
isCompositionHost: false,
|
||||
label: "Card",
|
||||
tagName: "div",
|
||||
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
||||
textContent: null,
|
||||
dataAttributes: {},
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canMove: false,
|
||||
canResize: false,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockBoundingRect(element: HTMLElement, width: number, height: number): void {
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
@@ -95,149 +58,13 @@ function mockComputedStyle(element: HTMLElement, values: Record<string, string>)
|
||||
}
|
||||
|
||||
describe("studio manual edits", () => {
|
||||
it("upserts path offsets by stable target", () => {
|
||||
const manifest = upsertStudioPathOffsetEdit(
|
||||
emptyStudioManualEditManifest(),
|
||||
createSelection(),
|
||||
{
|
||||
x: 12.4,
|
||||
y: 30.6,
|
||||
},
|
||||
);
|
||||
const updated = upsertStudioPathOffsetEdit(manifest, createSelection(), {
|
||||
x: 20,
|
||||
y: 42,
|
||||
});
|
||||
|
||||
expect(updated.edits).toHaveLength(1);
|
||||
expect(updated.edits[0]).toMatchObject({
|
||||
kind: "path-offset",
|
||||
target: { sourceFile: "index.html", selector: "#card", id: "card" },
|
||||
x: 20,
|
||||
y: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("upserts box sizes without replacing path offsets for the same target", () => {
|
||||
const selection = createSelection();
|
||||
const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
|
||||
x: 12,
|
||||
y: 30,
|
||||
});
|
||||
const updated = upsertStudioBoxSizeEdit(manifest, selection, {
|
||||
width: 240.4,
|
||||
height: 120.6,
|
||||
});
|
||||
const resized = upsertStudioBoxSizeEdit(updated, selection, {
|
||||
width: 260,
|
||||
height: 140,
|
||||
});
|
||||
|
||||
expect(resized.edits).toHaveLength(2);
|
||||
expect(resized.edits).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }),
|
||||
expect.objectContaining({ kind: "box-size", width: 260, height: 140 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("upserts rotations without replacing other manual edits for the same target", () => {
|
||||
const selection = createSelection();
|
||||
const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
|
||||
x: 12,
|
||||
y: 30,
|
||||
});
|
||||
const resized = upsertStudioBoxSizeEdit(manifest, selection, {
|
||||
width: 240,
|
||||
height: 120,
|
||||
});
|
||||
const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32.34 });
|
||||
const updated = upsertStudioRotationEdit(rotated, selection, { angle: -14.96 });
|
||||
|
||||
expect(updated.edits).toHaveLength(3);
|
||||
expect(updated.edits).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }),
|
||||
expect.objectContaining({ kind: "box-size", width: 240, height: 120 }),
|
||||
expect.objectContaining({ kind: "rotation", angle: -15 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes all manual edits for the selected target", () => {
|
||||
const selection = createSelection();
|
||||
const otherSelection = {
|
||||
...createSelection(),
|
||||
id: "other-card",
|
||||
selector: "#other-card",
|
||||
label: "Other card",
|
||||
};
|
||||
const moved = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, {
|
||||
x: 12,
|
||||
y: 30,
|
||||
});
|
||||
const resized = upsertStudioBoxSizeEdit(moved, selection, {
|
||||
width: 240,
|
||||
height: 120,
|
||||
});
|
||||
const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32 });
|
||||
const manifest = upsertStudioPathOffsetEdit(rotated, otherSelection, { x: 4, y: 8 });
|
||||
|
||||
const updated = removeStudioManualEditsForSelection(manifest, selection);
|
||||
|
||||
expect(updated.edits).toHaveLength(1);
|
||||
expect(updated.edits[0]).toMatchObject({
|
||||
kind: "path-offset",
|
||||
target: { id: "other-card", selector: "#other-card" },
|
||||
x: 4,
|
||||
y: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips valid manifest entries and drops invalid entries", () => {
|
||||
const content = serializeStudioManualEditManifest({
|
||||
version: 1,
|
||||
edits: [
|
||||
{
|
||||
kind: "path-offset",
|
||||
target: { sourceFile: "index.html", selector: "#card", id: "card" },
|
||||
x: 10,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
kind: "box-size",
|
||||
target: { sourceFile: "index.html", selector: "#card", id: "card" },
|
||||
width: 320,
|
||||
height: 180,
|
||||
},
|
||||
{
|
||||
kind: "rotation",
|
||||
target: { sourceFile: "index.html", selector: "#card", id: "card" },
|
||||
angle: 22.5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parseStudioManualEditManifest(content).edits).toHaveLength(3);
|
||||
expect(parseStudioManualEditManifest('{ "edits": [{ "kind": "path-offset" }] }').edits).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes manual edit manifest file-change payloads", () => {
|
||||
it("recognizes studio file-change payloads", () => {
|
||||
expect(readStudioFileChangePath({ path: ".hyperframes/studio-manual-edits.json" })).toBe(
|
||||
".hyperframes/studio-manual-edits.json",
|
||||
);
|
||||
expect(readStudioFileChangePath({ data: '{"path":"nested/file.html"}' })).toBe(
|
||||
"nested/file.html",
|
||||
);
|
||||
expect(
|
||||
isStudioManualEditManifestPath(
|
||||
"/Users/example/project/.hyperframes/studio-manual-edits.json",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isStudioManualEditManifestPath("index.html")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies offsets through CSS translate longhand", () => {
|
||||
@@ -293,9 +120,8 @@ describe("studio manual edits", () => {
|
||||
applyStudioPathOffset(card, { x: 14, y: -8 });
|
||||
applyStudioRotation(card, { angle: 12 });
|
||||
|
||||
expect(
|
||||
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
|
||||
).toBe(0);
|
||||
clearStudioPathOffset(card);
|
||||
clearStudioRotation(card);
|
||||
|
||||
expect(card.style.getPropertyValue("translate")).toBe("");
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("");
|
||||
@@ -383,54 +209,6 @@ describe("studio manual edits", () => {
|
||||
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
||||
});
|
||||
|
||||
it("does not recapture a studio rotation draft as the authored base", () => {
|
||||
const document = createDocument(`<div id="card" style="rotate: 8deg"></div>`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "rotation",
|
||||
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
|
||||
"angle": 35
|
||||
}
|
||||
]
|
||||
}`);
|
||||
|
||||
applyStudioRotation(card, { angle: 12 });
|
||||
applyStudioRotationDraft(card, { angle: 35 });
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + 35deg)");
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
|
||||
|
||||
expect(card.style.getPropertyValue("rotate")).toBe(
|
||||
`calc(8deg + var(${STUDIO_ROTATION_PROP}, 0deg))`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a base-free studio rotation draft as authored rotation", () => {
|
||||
const document = createDocument(`<div id="card"></div>`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "rotation",
|
||||
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
|
||||
"angle": 35
|
||||
}
|
||||
]
|
||||
}`);
|
||||
|
||||
applyStudioRotation(card, { angle: 12 });
|
||||
applyStudioRotationDraft(card, { angle: 35 });
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("35deg");
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
|
||||
|
||||
expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
|
||||
});
|
||||
|
||||
it("uses height for flex-basis inside column flex containers", () => {
|
||||
const document = createDocument(`
|
||||
<div style="display: flex; flex-direction: column">
|
||||
@@ -523,9 +301,7 @@ describe("studio manual edits", () => {
|
||||
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
|
||||
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
||||
|
||||
expect(
|
||||
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
|
||||
).toBe(0);
|
||||
clearStudioPathOffset(card);
|
||||
expect(tween.vars).toMatchObject({
|
||||
x: 0,
|
||||
y: 10,
|
||||
@@ -537,197 +313,48 @@ describe("studio manual edits", () => {
|
||||
expect(card.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("applies manifest offsets to matching preview elements", () => {
|
||||
it("clears path offsets and restores authored inline translate", () => {
|
||||
const document = createDocument(`<div id="card" style="translate: 10px 20px"></div>`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
|
||||
applyStudioPathOffset(card, { x: 24, y: 12 });
|
||||
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
||||
|
||||
clearStudioPathOffset(card);
|
||||
|
||||
expect(card.style.getPropertyValue("translate")).toBe("10px 20px");
|
||||
});
|
||||
|
||||
it("clears stale offsets applied directly to the DOM", () => {
|
||||
const document = createDocument(`<div id="card"></div>`);
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "path-offset",
|
||||
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
|
||||
"x": 32,
|
||||
"y": 18
|
||||
}
|
||||
]
|
||||
}`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
|
||||
expect(readStudioPathOffset(document.getElementById("card") as HTMLElement)).toEqual({
|
||||
x: 32,
|
||||
y: 18,
|
||||
});
|
||||
applyStudioPathOffset(card, { x: 24, y: 12 });
|
||||
expect(readStudioPathOffset(card)).toEqual({ x: 24, y: 12 });
|
||||
|
||||
clearStudioPathOffset(card);
|
||||
|
||||
expect(readStudioPathOffset(card)).toEqual({ x: 0, y: 0 });
|
||||
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
|
||||
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
|
||||
expect(card.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("resolves manifest targets within the matching source file", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="root">
|
||||
<div id="card" class="tile"></div>
|
||||
<div data-composition-id="nested" data-composition-file="scenes/nested.html">
|
||||
<div id="card" class="tile"></div>
|
||||
<div class="tile"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const htmlElement = document.defaultView?.HTMLElement;
|
||||
if (!htmlElement) throw new Error("HTMLElement fixture missing");
|
||||
const cards = Array.from(document.getElementsByTagName("*")).filter(
|
||||
(element): element is HTMLElement => element instanceof htmlElement && element.id === "card",
|
||||
);
|
||||
const rootCard = cards[0];
|
||||
const nestedCard = cards[1];
|
||||
const tiles = Array.from(document.getElementsByTagName("*")).filter(
|
||||
(element): element is HTMLElement =>
|
||||
element instanceof htmlElement && element.classList.contains("tile"),
|
||||
);
|
||||
const nestedSecondTile = tiles[2];
|
||||
if (!rootCard || !nestedCard || !nestedSecondTile) {
|
||||
throw new Error("source-scoped fixture missing");
|
||||
}
|
||||
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "path-offset",
|
||||
"target": {
|
||||
"sourceFile": "scenes/nested.html",
|
||||
"selector": "#card",
|
||||
"id": "card"
|
||||
},
|
||||
"x": 48,
|
||||
"y": 16
|
||||
},
|
||||
{
|
||||
"kind": "box-size",
|
||||
"target": {
|
||||
"sourceFile": "scenes/nested.html",
|
||||
"selector": ".tile",
|
||||
"selectorIndex": 1
|
||||
},
|
||||
"width": 220,
|
||||
"height": 80
|
||||
}
|
||||
]
|
||||
}`);
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(2);
|
||||
expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 });
|
||||
expect(readStudioPathOffset(nestedCard)).toEqual({ x: 48, y: 16 });
|
||||
expect(readStudioBoxSize(nestedSecondTile)).toEqual({ width: 220, height: 80 });
|
||||
});
|
||||
|
||||
it("resolves manifest targets inside composition-file hosts without composition ids", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="root">
|
||||
<div id="card"></div>
|
||||
<div data-composition-file="scenes/anonymous.html">
|
||||
<div id="card"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const htmlElement = document.defaultView?.HTMLElement;
|
||||
if (!htmlElement) throw new Error("HTMLElement fixture missing");
|
||||
const cards = Array.from(document.getElementsByTagName("*")).filter(
|
||||
(element): element is HTMLElement => element instanceof htmlElement && element.id === "card",
|
||||
);
|
||||
const rootCard = cards[0];
|
||||
const nestedCard = cards[1];
|
||||
if (!rootCard || !nestedCard) {
|
||||
throw new Error("anonymous composition fixture missing");
|
||||
}
|
||||
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "path-offset",
|
||||
"target": {
|
||||
"sourceFile": "scenes/anonymous.html",
|
||||
"selector": "#card",
|
||||
"id": "card"
|
||||
},
|
||||
"x": 24,
|
||||
"y": 12
|
||||
}
|
||||
]
|
||||
}`);
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
|
||||
expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 });
|
||||
expect(readStudioPathOffset(nestedCard)).toEqual({ x: 24, y: 12 });
|
||||
});
|
||||
|
||||
it("applies nested source edits while previewing a non-index parent composition", () => {
|
||||
const document = createDocument(`
|
||||
<div data-composition-id="parent">
|
||||
<div id="parent-card"></div>
|
||||
<div data-composition-file="scenes/child.html">
|
||||
<div id="child-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const parentCard = document.getElementById("parent-card") as HTMLElement;
|
||||
const childCard = document.getElementById("child-card") as HTMLElement;
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "path-offset",
|
||||
"target": {
|
||||
"sourceFile": "scenes/parent.html",
|
||||
"selector": "#parent-card",
|
||||
"id": "parent-card"
|
||||
},
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
{
|
||||
"kind": "path-offset",
|
||||
"target": {
|
||||
"sourceFile": "scenes/child.html",
|
||||
"selector": "#child-card",
|
||||
"id": "child-card"
|
||||
},
|
||||
"x": 36,
|
||||
"y": 18
|
||||
}
|
||||
]
|
||||
}`);
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "scenes/parent.html")).toBe(2);
|
||||
expect(readStudioPathOffset(parentCard)).toEqual({ x: 12, y: 8 });
|
||||
expect(readStudioPathOffset(childCard)).toEqual({ x: 36, y: 18 });
|
||||
});
|
||||
|
||||
it("applies and clears manifest box sizes while restoring authored inline size", () => {
|
||||
it("clears box sizes and restores authored inline size", () => {
|
||||
const document = createDocument(`
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<div id="card" style="width: 160px; height: 90px"></div>
|
||||
</div>
|
||||
`);
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "box-size",
|
||||
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
|
||||
"width": 320,
|
||||
"height": 180
|
||||
}
|
||||
]
|
||||
}`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
mockBoundingRect(card, 160, 90);
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
|
||||
applyStudioBoxSize(card, { width: 320, height: 180 });
|
||||
expect(readStudioBoxSize(card)).toEqual({ width: 320, height: 180 });
|
||||
expect(card.style.getPropertyValue("width")).toBe("320px");
|
||||
expect(card.style.getPropertyValue("height")).toBe("180px");
|
||||
expect(card.style.getPropertyValue("flex-basis")).toBe("320px");
|
||||
|
||||
expect(
|
||||
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
|
||||
).toBe(0);
|
||||
clearStudioBoxSize(card);
|
||||
expect(readStudioBoxSize(card)).toEqual({ width: 0, height: 0 });
|
||||
expect(card.style.getPropertyValue("width")).toBe("160px");
|
||||
expect(card.style.getPropertyValue("height")).toBe("90px");
|
||||
@@ -737,93 +364,39 @@ describe("studio manual edits", () => {
|
||||
expect(card.style.getPropertyValue("scale")).toBe("");
|
||||
});
|
||||
|
||||
it("applies and clears manifest rotations while restoring authored inline rotation", () => {
|
||||
it("clears rotations and restores authored inline rotation", () => {
|
||||
const document = createDocument(
|
||||
`<div id="card" style="rotate: 8deg; transform-origin: left top"></div>`,
|
||||
);
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "rotation",
|
||||
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
|
||||
"angle": 37.5
|
||||
}
|
||||
]
|
||||
}`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
|
||||
applyStudioRotation(card, { angle: 37.5 });
|
||||
expect(readStudioRotation(card)).toEqual({ angle: 37.5 });
|
||||
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
||||
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
|
||||
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
||||
|
||||
expect(
|
||||
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
|
||||
).toBe(0);
|
||||
clearStudioRotation(card);
|
||||
expect(readStudioRotation(card)).toEqual({ angle: 0 });
|
||||
expect(card.style.getPropertyValue("rotate")).toBe("8deg");
|
||||
expect(card.style.getPropertyValue("transform-origin")).toBe("left top");
|
||||
});
|
||||
|
||||
it("clears stale preview offsets that are no longer in the manifest", () => {
|
||||
it("does not replay a gesture-guarded offset during active gesture", () => {
|
||||
const document = createDocument(`<div id="card"></div>`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
|
||||
applyStudioPathOffset(card, { x: 24, y: 12 });
|
||||
expect(readStudioPathOffset(card)).toEqual({ x: 24, y: 12 });
|
||||
|
||||
expect(
|
||||
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
|
||||
).toBe(0);
|
||||
|
||||
expect(readStudioPathOffset(card)).toEqual({ x: 0, y: 0 });
|
||||
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
|
||||
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
|
||||
expect(card.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("restores authored inline translate when clearing offsets", () => {
|
||||
const document = createDocument(`<div id="card" style="translate: 10px 20px"></div>`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
|
||||
applyStudioPathOffset(card, { x: 24, y: 12 });
|
||||
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
||||
|
||||
expect(
|
||||
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
|
||||
).toBe(0);
|
||||
|
||||
expect(card.style.getPropertyValue("translate")).toBe("10px 20px");
|
||||
});
|
||||
|
||||
it("does not replay the manifest over an active manual edit gesture", () => {
|
||||
const document = createDocument(`<div id="card"></div>`);
|
||||
const card = document.getElementById("card") as HTMLElement;
|
||||
const manifest = parseStudioManualEditManifest(`{
|
||||
"version": 1,
|
||||
"edits": [
|
||||
{
|
||||
"kind": "path-offset",
|
||||
"target": { "sourceFile": "index.html", "selector": "#card", "id": "card" },
|
||||
"x": 8,
|
||||
"y": 4
|
||||
}
|
||||
]
|
||||
}`);
|
||||
|
||||
applyStudioPathOffset(card, { x: 40, y: 24 });
|
||||
const firstToken = beginStudioManualEditGesture(card);
|
||||
const secondToken = beginStudioManualEditGesture(card);
|
||||
endStudioManualEditGesture(card, firstToken);
|
||||
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(0);
|
||||
// Gesture still active — offset should remain
|
||||
expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 });
|
||||
|
||||
endStudioManualEditGesture(card, secondToken);
|
||||
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
|
||||
expect(readStudioPathOffset(card)).toEqual({ x: 8, y: 4 });
|
||||
// After gesture ends, offset remains (we don't auto-clear in this path)
|
||||
expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 });
|
||||
});
|
||||
|
||||
it("reapplies the latest preview manifest after wrapped seeks", () => {
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
// Public re-exports — consumers import from this file as before.
|
||||
export {
|
||||
STUDIO_MANUAL_EDITS_PATH,
|
||||
STUDIO_OFFSET_X_PROP,
|
||||
STUDIO_OFFSET_Y_PROP,
|
||||
STUDIO_WIDTH_PROP,
|
||||
STUDIO_HEIGHT_PROP,
|
||||
STUDIO_ROTATION_PROP,
|
||||
type StudioManualEditTarget,
|
||||
type StudioPathOffsetEdit,
|
||||
type StudioBoxSizeEdit,
|
||||
type StudioRotationEdit,
|
||||
type StudioManualEdit,
|
||||
type StudioManualEditManifest,
|
||||
type StudioManualEditSeekWindow,
|
||||
type StudioBoxSizeSnapshot,
|
||||
type StudioRotationSnapshot,
|
||||
type StudioPathOffsetSnapshot,
|
||||
} from "./manualEditsTypes";
|
||||
|
||||
export {
|
||||
emptyStudioManualEditManifest,
|
||||
parseStudioManualEditManifest,
|
||||
serializeStudioManualEditManifest,
|
||||
readStudioFileChangePath,
|
||||
isStudioManualEditManifestPath,
|
||||
upsertStudioPathOffsetEdit,
|
||||
upsertStudioBoxSizeEdit,
|
||||
upsertStudioRotationEdit,
|
||||
removeStudioManualEditsForSelection,
|
||||
} from "./manualEditsParsing";
|
||||
export { readStudioFileChangePath } from "./manualEditsParsing";
|
||||
|
||||
export {
|
||||
beginStudioManualEditGesture,
|
||||
@@ -46,6 +29,7 @@ export {
|
||||
clearStudioPathOffset,
|
||||
clearStudioRotation,
|
||||
clearStudioBoxSize,
|
||||
reapplyPositionEditsAfterSeek,
|
||||
} from "./manualEditsDom";
|
||||
|
||||
export {
|
||||
@@ -57,27 +41,14 @@ export {
|
||||
restoreStudioPathOffset,
|
||||
} from "./manualEditsSnapshot";
|
||||
|
||||
import type {
|
||||
StudioManualEdit,
|
||||
StudioManualEditManifest,
|
||||
StudioManualEditSeekWindow,
|
||||
} from "./manualEditsTypes";
|
||||
import type { StudioManualEditSeekWindow } from "./manualEditsTypes";
|
||||
import {
|
||||
STUDIO_MANUAL_EDITS_APPLY_PROP,
|
||||
STUDIO_MANUAL_EDITS_WRAPPED_PROP,
|
||||
STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP,
|
||||
} from "./manualEditsTypes";
|
||||
import { finiteNumber } from "./manualEditsParsing";
|
||||
import {
|
||||
applyStudioPathOffset,
|
||||
applyStudioBoxSize,
|
||||
applyStudioRotation,
|
||||
isStudioManualEditGestureActive,
|
||||
clearStudioPathOffset,
|
||||
clearStudioBoxSize,
|
||||
clearStudioRotation,
|
||||
} from "./manualEditsDom";
|
||||
import { collectStudioManualEditElements } from "./manualEditsSnapshot";
|
||||
import { isStudioManualEditGestureActive } from "./manualEditsDom";
|
||||
|
||||
/* ── Seek/play reapply wrappers ───────────────────────────────────── */
|
||||
function markWrapped(fn: (...args: unknown[]) => unknown): void {
|
||||
@@ -307,138 +278,5 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
/* ── DOM target resolution ────────────────────────────────────────── */
|
||||
function getManualEditSourceFileForElement(
|
||||
el: HTMLElement,
|
||||
activeCompositionPath: string | null,
|
||||
): string {
|
||||
let current: HTMLElement | null = el;
|
||||
while (current) {
|
||||
const sourceFile =
|
||||
current.getAttribute("data-composition-file") ?? current.getAttribute("data-composition-src");
|
||||
if (sourceFile) return sourceFile;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return activeCompositionPath ?? "index.html";
|
||||
}
|
||||
|
||||
function elementMatchesManualEditSourceFile(
|
||||
element: HTMLElement,
|
||||
sourceFile: string,
|
||||
activeCompositionPath: string | null,
|
||||
): boolean {
|
||||
return getManualEditSourceFileForElement(element, activeCompositionPath) === sourceFile;
|
||||
}
|
||||
|
||||
function queryManualEditSelectorCandidates(
|
||||
doc: Document,
|
||||
selector: string,
|
||||
htmlElement: typeof HTMLElement,
|
||||
): HTMLElement[] {
|
||||
const isCandidate = (element: Element): element is HTMLElement => element instanceof htmlElement;
|
||||
|
||||
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
|
||||
if (className) {
|
||||
return Array.from(doc.getElementsByTagName("*")).filter(
|
||||
(element): element is HTMLElement =>
|
||||
isCandidate(element) && element.classList.contains(className),
|
||||
);
|
||||
}
|
||||
|
||||
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
|
||||
return Array.from(doc.getElementsByTagName(selector)).filter(isCandidate);
|
||||
}
|
||||
|
||||
return Array.from(doc.querySelectorAll(selector)).filter(isCandidate);
|
||||
}
|
||||
|
||||
function resolveManualEditTarget(
|
||||
doc: Document,
|
||||
edit: StudioManualEdit,
|
||||
activeCompositionPath: string | null,
|
||||
): HTMLElement | null {
|
||||
const htmlElement = doc.defaultView?.HTMLElement;
|
||||
if (!htmlElement) return null;
|
||||
|
||||
if (edit.target.id) {
|
||||
const byId = doc.getElementById(edit.target.id);
|
||||
if (
|
||||
byId instanceof htmlElement &&
|
||||
elementMatchesManualEditSourceFile(byId, edit.target.sourceFile, activeCompositionPath)
|
||||
) {
|
||||
return byId;
|
||||
}
|
||||
|
||||
const matchesById = [doc.documentElement, ...Array.from(doc.getElementsByTagName("*"))].filter(
|
||||
(element): element is HTMLElement =>
|
||||
element instanceof htmlElement &&
|
||||
element.id === edit.target.id &&
|
||||
elementMatchesManualEditSourceFile(element, edit.target.sourceFile, activeCompositionPath),
|
||||
);
|
||||
if (matchesById[0]) return matchesById[0];
|
||||
}
|
||||
|
||||
if (!edit.target.selector) return null;
|
||||
try {
|
||||
const matches = queryManualEditSelectorCandidates(
|
||||
doc,
|
||||
edit.target.selector,
|
||||
htmlElement,
|
||||
).filter((element) =>
|
||||
elementMatchesManualEditSourceFile(element, edit.target.sourceFile, activeCompositionPath),
|
||||
);
|
||||
return matches[edit.target.selectorIndex ?? 0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Manifest application ─────────────────────────────────────────── */
|
||||
export function applyStudioManualEditManifest(
|
||||
doc: Document,
|
||||
manifest: StudioManualEditManifest,
|
||||
activeCompositionPath: string | null,
|
||||
): number {
|
||||
const resolvedEdits: Array<{ edit: StudioManualEdit; element: HTMLElement }> = [];
|
||||
const pathOffsetTargets = new Set<HTMLElement>();
|
||||
const boxSizeTargets = new Set<HTMLElement>();
|
||||
const rotationTargets = new Set<HTMLElement>();
|
||||
|
||||
for (const edit of manifest.edits) {
|
||||
const element = resolveManualEditTarget(doc, edit, activeCompositionPath);
|
||||
if (!element) continue;
|
||||
if (isStudioManualEditGestureActive(element)) {
|
||||
continue;
|
||||
}
|
||||
resolvedEdits.push({ edit, element });
|
||||
if (edit.kind === "path-offset") pathOffsetTargets.add(element);
|
||||
if (edit.kind === "box-size") boxSizeTargets.add(element);
|
||||
if (edit.kind === "rotation") rotationTargets.add(element);
|
||||
}
|
||||
|
||||
for (const element of collectStudioManualEditElements(doc)) {
|
||||
if (isStudioManualEditGestureActive(element)) continue;
|
||||
if (!pathOffsetTargets.has(element)) {
|
||||
clearStudioPathOffset(element);
|
||||
}
|
||||
if (!boxSizeTargets.has(element)) {
|
||||
clearStudioBoxSize(element);
|
||||
}
|
||||
if (!rotationTargets.has(element)) {
|
||||
clearStudioRotation(element);
|
||||
}
|
||||
}
|
||||
|
||||
let applied = 0;
|
||||
for (const { edit, element } of resolvedEdits) {
|
||||
if (edit.kind === "path-offset") {
|
||||
applyStudioPathOffset(element, { x: edit.x, y: edit.y });
|
||||
} else if (edit.kind === "box-size") {
|
||||
applyStudioBoxSize(element, { width: edit.width, height: edit.height });
|
||||
} else {
|
||||
applyStudioRotation(element, { angle: edit.angle });
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
// Re-export for internal use (seek hooks need this)
|
||||
export { isStudioManualEditGestureActive };
|
||||
|
||||
@@ -209,12 +209,39 @@ function writeStudioPathOffsetVars(
|
||||
}
|
||||
|
||||
/* ── Path offset apply ────────────────────────────────────────────── */
|
||||
|
||||
// GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it
|
||||
// into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also
|
||||
// writes `translate`, both properties compose additively, doubling the visual offset. This helper
|
||||
// zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted.
|
||||
function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
||||
const transform = element.style.getPropertyValue("transform");
|
||||
if (!transform || transform === "none") return;
|
||||
const win = element.ownerDocument.defaultView as (Window & typeof globalThis) | null;
|
||||
const DOMMatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix;
|
||||
if (!DOMMatrixCtor) return;
|
||||
try {
|
||||
const m = new DOMMatrixCtor(transform);
|
||||
if (m.m41 === 0 && m.m42 === 0) return;
|
||||
m.m41 = 0;
|
||||
m.m42 = 0;
|
||||
if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) {
|
||||
element.style.removeProperty("transform");
|
||||
} else {
|
||||
element.style.setProperty("transform", m.toString());
|
||||
}
|
||||
} catch {
|
||||
// non-parseable transform or DOMMatrix unavailable — leave as-is
|
||||
}
|
||||
}
|
||||
|
||||
export function applyStudioPathOffset(
|
||||
element: HTMLElement,
|
||||
offset: { x: number; y: number },
|
||||
options: { updateBase?: boolean } = {},
|
||||
): void {
|
||||
promoteInlineForTransform(element);
|
||||
writeStudioPathOffsetVars(element, offset);
|
||||
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
|
||||
element.style.setProperty(
|
||||
"translate",
|
||||
composeTranslateValue(
|
||||
@@ -223,6 +250,7 @@ export function applyStudioPathOffset(
|
||||
`var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
|
||||
),
|
||||
);
|
||||
stripGsapTranslateFromTransform(element);
|
||||
}
|
||||
|
||||
export function applyStudioPathOffsetDraft(
|
||||
@@ -235,6 +263,7 @@ export function applyStudioPathOffsetDraft(
|
||||
"translate",
|
||||
composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
|
||||
);
|
||||
stripGsapTranslateFromTransform(element);
|
||||
}
|
||||
|
||||
/* ── Box size apply ───────────────────────────────────────────────── */
|
||||
@@ -434,3 +463,334 @@ export {
|
||||
clearStudioRotation,
|
||||
clearStudioBoxSize,
|
||||
} from "./manualEditsSnapshot";
|
||||
|
||||
/* ── HTML patch builders ──────────────────────────────────────────── */
|
||||
import type { PatchOperation } from "../../utils/sourcePatcher";
|
||||
|
||||
export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
|
||||
const x = element.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
||||
const y = element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
||||
const translate = element.style.getPropertyValue("translate");
|
||||
const originalTranslate = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR);
|
||||
const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
|
||||
const displayVal = element.style.getPropertyValue("display");
|
||||
const transformDisplayAttr = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
||||
const ops: PatchOperation[] = [];
|
||||
if (x) ops.push({ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: x });
|
||||
if (y) ops.push({ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: y });
|
||||
if (translate) ops.push({ type: "inline-style", property: "translate", value: translate });
|
||||
ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
|
||||
if (originalTranslate !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_TRANSLATE_ATTR,
|
||||
value: originalTranslate,
|
||||
});
|
||||
if (originalInlineTranslate !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
|
||||
value: originalInlineTranslate,
|
||||
});
|
||||
if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
|
||||
if (transformDisplayAttr !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
||||
value: transformDisplayAttr,
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] {
|
||||
const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR);
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null },
|
||||
{ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null },
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "translate",
|
||||
value: originalInlineTranslate || null,
|
||||
},
|
||||
{ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null },
|
||||
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null },
|
||||
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null },
|
||||
];
|
||||
const origDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
||||
if (origDisplay !== null) {
|
||||
ops.push({ type: "inline-style", property: "display", value: origDisplay || null });
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
|
||||
const ops: PatchOperation[] = [];
|
||||
|
||||
const studioWidth = element.style.getPropertyValue(STUDIO_WIDTH_PROP);
|
||||
const studioHeight = element.style.getPropertyValue(STUDIO_HEIGHT_PROP);
|
||||
if (studioWidth)
|
||||
ops.push({ type: "inline-style", property: STUDIO_WIDTH_PROP, value: studioWidth });
|
||||
if (studioHeight)
|
||||
ops.push({ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: studioHeight });
|
||||
|
||||
const width = element.style.getPropertyValue("width");
|
||||
const height = element.style.getPropertyValue("height");
|
||||
const minWidth = element.style.getPropertyValue("min-width");
|
||||
const minHeight = element.style.getPropertyValue("min-height");
|
||||
const maxWidth = element.style.getPropertyValue("max-width");
|
||||
const maxHeight = element.style.getPropertyValue("max-height");
|
||||
const flexBasis = element.style.getPropertyValue("flex-basis");
|
||||
const flexGrow = element.style.getPropertyValue("flex-grow");
|
||||
const flexShrink = element.style.getPropertyValue("flex-shrink");
|
||||
const boxSizing = element.style.getPropertyValue("box-sizing");
|
||||
const scale = element.style.getPropertyValue("scale");
|
||||
const transformOrigin = element.style.getPropertyValue("transform-origin");
|
||||
const displayVal = element.style.getPropertyValue("display");
|
||||
|
||||
if (width) ops.push({ type: "inline-style", property: "width", value: width });
|
||||
if (height) ops.push({ type: "inline-style", property: "height", value: height });
|
||||
if (minWidth) ops.push({ type: "inline-style", property: "min-width", value: minWidth });
|
||||
if (minHeight) ops.push({ type: "inline-style", property: "min-height", value: minHeight });
|
||||
if (maxWidth) ops.push({ type: "inline-style", property: "max-width", value: maxWidth });
|
||||
if (maxHeight) ops.push({ type: "inline-style", property: "max-height", value: maxHeight });
|
||||
if (flexBasis) ops.push({ type: "inline-style", property: "flex-basis", value: flexBasis });
|
||||
if (flexGrow) ops.push({ type: "inline-style", property: "flex-grow", value: flexGrow });
|
||||
if (flexShrink) ops.push({ type: "inline-style", property: "flex-shrink", value: flexShrink });
|
||||
if (boxSizing) ops.push({ type: "inline-style", property: "box-sizing", value: boxSizing });
|
||||
if (scale) ops.push({ type: "inline-style", property: "scale", value: scale });
|
||||
if (transformOrigin)
|
||||
ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin });
|
||||
if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
|
||||
|
||||
ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
|
||||
|
||||
const origWidth = element.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR);
|
||||
const origHeight = element.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR);
|
||||
const origMinWidth = element.getAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR);
|
||||
const origMinHeight = element.getAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR);
|
||||
const origMaxWidth = element.getAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR);
|
||||
const origMaxHeight = element.getAttribute(STUDIO_ORIGINAL_MAX_HEIGHT_ATTR);
|
||||
const origFlexBasis = element.getAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR);
|
||||
const origFlexGrow = element.getAttribute(STUDIO_ORIGINAL_FLEX_GROW_ATTR);
|
||||
const origFlexShrink = element.getAttribute(STUDIO_ORIGINAL_FLEX_SHRINK_ATTR);
|
||||
const origBoxSizing = element.getAttribute(STUDIO_ORIGINAL_BOX_SIZING_ATTR);
|
||||
const origScale = element.getAttribute(STUDIO_ORIGINAL_SCALE_ATTR);
|
||||
const origTransformOrigin = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR);
|
||||
const origDisplay = element.getAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR);
|
||||
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
||||
|
||||
if (origWidth !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: origWidth });
|
||||
if (origHeight !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: origHeight });
|
||||
if (origMinWidth !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: origMinWidth });
|
||||
if (origMinHeight !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
|
||||
value: origMinHeight,
|
||||
});
|
||||
if (origMaxWidth !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: origMaxWidth });
|
||||
if (origMaxHeight !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
|
||||
value: origMaxHeight,
|
||||
});
|
||||
if (origFlexBasis !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
|
||||
value: origFlexBasis,
|
||||
});
|
||||
if (origFlexGrow !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: origFlexGrow });
|
||||
if (origFlexShrink !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
|
||||
value: origFlexShrink,
|
||||
});
|
||||
if (origBoxSizing !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_BOX_SIZING_ATTR,
|
||||
value: origBoxSizing,
|
||||
});
|
||||
if (origScale !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: origScale });
|
||||
if (origTransformOrigin !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
|
||||
value: origTransformOrigin,
|
||||
});
|
||||
if (origDisplay !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: origDisplay });
|
||||
if (origTransformDisplay !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
||||
value: origTransformDisplay,
|
||||
});
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[] {
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "inline-style", property: STUDIO_WIDTH_PROP, value: null },
|
||||
{ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null },
|
||||
{ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null },
|
||||
];
|
||||
|
||||
const origAttrs: Array<[string, string]> = [
|
||||
[STUDIO_ORIGINAL_WIDTH_ATTR, "width"],
|
||||
[STUDIO_ORIGINAL_HEIGHT_ATTR, "height"],
|
||||
[STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"],
|
||||
[STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"],
|
||||
[STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"],
|
||||
[STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "max-height"],
|
||||
[STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "flex-basis"],
|
||||
[STUDIO_ORIGINAL_FLEX_GROW_ATTR, "flex-grow"],
|
||||
[STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "flex-shrink"],
|
||||
[STUDIO_ORIGINAL_BOX_SIZING_ATTR, "box-sizing"],
|
||||
[STUDIO_ORIGINAL_SCALE_ATTR, "scale"],
|
||||
[STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "transform-origin"],
|
||||
[STUDIO_ORIGINAL_DISPLAY_ATTR, "display"],
|
||||
];
|
||||
|
||||
for (const [attrName, styleProp] of origAttrs) {
|
||||
const origVal = element.getAttribute(attrName);
|
||||
if (origVal !== null) {
|
||||
ops.push({ type: "inline-style", property: styleProp, value: origVal || null });
|
||||
}
|
||||
ops.push({ type: "attribute", property: attrName, value: null });
|
||||
}
|
||||
|
||||
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
||||
if (origTransformDisplay !== null) {
|
||||
ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null });
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
|
||||
const ops: PatchOperation[] = [];
|
||||
|
||||
const studioRotation = element.style.getPropertyValue(STUDIO_ROTATION_PROP);
|
||||
const rotate = element.style.getPropertyValue("rotate");
|
||||
const transformOrigin = element.style.getPropertyValue("transform-origin");
|
||||
const displayVal = element.style.getPropertyValue("display");
|
||||
|
||||
if (studioRotation)
|
||||
ops.push({ type: "inline-style", property: STUDIO_ROTATION_PROP, value: studioRotation });
|
||||
if (rotate) ops.push({ type: "inline-style", property: "rotate", value: rotate });
|
||||
if (transformOrigin)
|
||||
ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin });
|
||||
if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal });
|
||||
|
||||
ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
|
||||
|
||||
const origRotate = element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR);
|
||||
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
|
||||
const origRotationTransformOrigin = element.getAttribute(
|
||||
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
||||
);
|
||||
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
||||
|
||||
if (origRotate !== null)
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: origRotate });
|
||||
if (origInlineRotate !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
|
||||
value: origInlineRotate,
|
||||
});
|
||||
if (origRotationTransformOrigin !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
||||
value: origRotationTransformOrigin,
|
||||
});
|
||||
if (origTransformDisplay !== null)
|
||||
ops.push({
|
||||
type: "attribute",
|
||||
property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
||||
value: origTransformDisplay,
|
||||
});
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
|
||||
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
|
||||
const origRotationTransformOrigin = element.getAttribute(
|
||||
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
||||
);
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: null },
|
||||
{ type: "inline-style", property: "rotate", value: origInlineRotate || null },
|
||||
{
|
||||
type: "inline-style",
|
||||
property: "transform-origin",
|
||||
value: origRotationTransformOrigin !== null ? origRotationTransformOrigin || null : null,
|
||||
},
|
||||
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: null },
|
||||
{ type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null },
|
||||
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null },
|
||||
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null },
|
||||
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null },
|
||||
];
|
||||
const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
|
||||
if (origTransformDisplay !== null) {
|
||||
ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null });
|
||||
ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
export function reapplyPositionEditsAfterSeek(doc: Document): void {
|
||||
const htmlElement = doc.defaultView?.HTMLElement;
|
||||
if (!htmlElement) return;
|
||||
|
||||
const offsetEls = Array.from(doc.querySelectorAll(`[${STUDIO_PATH_OFFSET_ATTR}="true"]`)).filter(
|
||||
(el): el is HTMLElement => el instanceof htmlElement,
|
||||
);
|
||||
for (const el of offsetEls) {
|
||||
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
||||
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
||||
if (x || y) {
|
||||
applyStudioPathOffset(el, {
|
||||
x: Number.parseFloat(x) || 0,
|
||||
y: Number.parseFloat(y) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const boxSizeEls = Array.from(doc.querySelectorAll(`[${STUDIO_BOX_SIZE_ATTR}="true"]`)).filter(
|
||||
(el): el is HTMLElement => el instanceof htmlElement,
|
||||
);
|
||||
for (const el of boxSizeEls) {
|
||||
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
|
||||
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
|
||||
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
||||
applyStudioBoxSize(el, { width: w, height: h });
|
||||
}
|
||||
}
|
||||
|
||||
const rotationEls = Array.from(doc.querySelectorAll(`[${STUDIO_ROTATION_ATTR}="true"]`)).filter(
|
||||
(el): el is HTMLElement => el instanceof htmlElement,
|
||||
);
|
||||
for (const el of rotationEls) {
|
||||
const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
|
||||
if (Number.isFinite(angle)) {
|
||||
applyStudioRotation(el, { angle });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +1,10 @@
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type {
|
||||
StudioManualEdit,
|
||||
StudioManualEditManifest,
|
||||
StudioManualEditTarget,
|
||||
StudioPathOffsetEdit,
|
||||
StudioBoxSizeEdit,
|
||||
StudioRotationEdit,
|
||||
} from "./manualEditsTypes";
|
||||
import { STUDIO_MANUAL_EDITS_PATH } from "./manualEditsTypes";
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||
export function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/* ── Manifest factory ─────────────────────────────────────────────── */
|
||||
export function emptyStudioManualEditManifest(): StudioManualEditManifest {
|
||||
return { version: 1, edits: [] };
|
||||
}
|
||||
|
||||
/* ── Parsing ──────────────────────────────────────────────────────── */
|
||||
function parsePathOffsetEdit(value: unknown): StudioPathOffsetEdit | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.kind !== "path-offset") return null;
|
||||
const target = record.target;
|
||||
if (!target || typeof target !== "object") return null;
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
|
||||
if (!sourceFile) return null;
|
||||
|
||||
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined;
|
||||
const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined;
|
||||
if (!selector && !id) return null;
|
||||
|
||||
const x = finiteNumber(record.x);
|
||||
const y = finiteNumber(record.y);
|
||||
if (x == null || y == null) return null;
|
||||
|
||||
return {
|
||||
kind: "path-offset",
|
||||
target: {
|
||||
sourceFile,
|
||||
selector,
|
||||
selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined,
|
||||
id,
|
||||
},
|
||||
x,
|
||||
y,
|
||||
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseBoxSizeEdit(value: unknown): StudioBoxSizeEdit | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.kind !== "box-size") return null;
|
||||
const target = record.target;
|
||||
if (!target || typeof target !== "object") return null;
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
|
||||
if (!sourceFile) return null;
|
||||
|
||||
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined;
|
||||
const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined;
|
||||
if (!selector && !id) return null;
|
||||
|
||||
const width = finiteNumber(record.width);
|
||||
const height = finiteNumber(record.height);
|
||||
if (width == null || height == null || width <= 0 || height <= 0) return null;
|
||||
|
||||
return {
|
||||
kind: "box-size",
|
||||
target: {
|
||||
sourceFile,
|
||||
selector,
|
||||
selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined,
|
||||
id,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRotationEdit(value: unknown): StudioRotationEdit | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.kind !== "rotation") return null;
|
||||
const target = record.target;
|
||||
if (!target || typeof target !== "object") return null;
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
|
||||
if (!sourceFile) return null;
|
||||
|
||||
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined;
|
||||
const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined;
|
||||
if (!selector && !id) return null;
|
||||
|
||||
const angle = finiteNumber(record.angle);
|
||||
if (angle == null) return null;
|
||||
|
||||
return {
|
||||
kind: "rotation",
|
||||
target: {
|
||||
sourceFile,
|
||||
selector,
|
||||
selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined,
|
||||
id,
|
||||
},
|
||||
angle,
|
||||
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseManualEdit(value: unknown): StudioManualEdit | null {
|
||||
return parsePathOffsetEdit(value) ?? parseBoxSizeEdit(value) ?? parseRotationEdit(value);
|
||||
}
|
||||
|
||||
export function parseStudioManualEditManifest(content: string): StudioManualEditManifest {
|
||||
if (!content.trim()) return emptyStudioManualEditManifest();
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return emptyStudioManualEditManifest();
|
||||
const edits = (parsed as { edits?: unknown }).edits;
|
||||
if (!Array.isArray(edits)) return emptyStudioManualEditManifest();
|
||||
return {
|
||||
version: 1,
|
||||
edits: edits.map(parseManualEdit).filter((edit): edit is StudioManualEdit => edit !== null),
|
||||
};
|
||||
} catch {
|
||||
return emptyStudioManualEditManifest();
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeStudioManualEditManifest(manifest: StudioManualEditManifest): string {
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
export function roundRotationAngle(angle: number): number {
|
||||
return Math.round(angle * 10) / 10;
|
||||
}
|
||||
|
||||
/* ── File path utilities ──────────────────────────────────────────── */
|
||||
@@ -172,109 +40,3 @@ function readStudioFileChangePathFromValue(value: unknown): string | null {
|
||||
export function readStudioFileChangePath(payload: unknown): string | null {
|
||||
return readStudioFileChangePathFromValue(payload);
|
||||
}
|
||||
|
||||
export function isStudioManualEditManifestPath(path: string | null): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = normalizeStudioFileChangePath(path);
|
||||
return (
|
||||
normalized === STUDIO_MANUAL_EDITS_PATH || normalized.endsWith(`/${STUDIO_MANUAL_EDITS_PATH}`)
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Target / upsert helpers ──────────────────────────────────────── */
|
||||
function selectionTarget(selection: DomEditSelection): StudioManualEditTarget {
|
||||
return {
|
||||
sourceFile: selection.sourceFile || "index.html",
|
||||
selector: selection.selector,
|
||||
selectorIndex: selection.selectorIndex,
|
||||
id: selection.id ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function targetKey(target: StudioManualEditTarget): string {
|
||||
return [
|
||||
target.sourceFile,
|
||||
target.id ?? "",
|
||||
target.selector ?? "",
|
||||
target.selectorIndex ?? "",
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function roundRotationAngle(angle: number): number {
|
||||
return Math.round(angle * 10) / 10;
|
||||
}
|
||||
|
||||
export function upsertStudioPathOffsetEdit(
|
||||
manifest: StudioManualEditManifest,
|
||||
selection: DomEditSelection,
|
||||
offset: { x: number; y: number },
|
||||
): StudioManualEditManifest {
|
||||
const target = selectionTarget(selection);
|
||||
const key = targetKey(target);
|
||||
const nextEdit: StudioPathOffsetEdit = {
|
||||
kind: "path-offset",
|
||||
target,
|
||||
x: Math.round(offset.x),
|
||||
y: Math.round(offset.y),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const edits = manifest.edits.filter(
|
||||
(edit) => edit.kind !== "path-offset" || targetKey(edit.target) !== key,
|
||||
);
|
||||
edits.push(nextEdit);
|
||||
return { version: 1, edits };
|
||||
}
|
||||
|
||||
export function upsertStudioBoxSizeEdit(
|
||||
manifest: StudioManualEditManifest,
|
||||
selection: DomEditSelection,
|
||||
size: { width: number; height: number },
|
||||
): StudioManualEditManifest {
|
||||
const target = selectionTarget(selection);
|
||||
const key = targetKey(target);
|
||||
const nextEdit: StudioBoxSizeEdit = {
|
||||
kind: "box-size",
|
||||
target,
|
||||
width: Math.round(Math.max(1, size.width)),
|
||||
height: Math.round(Math.max(1, size.height)),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const edits = manifest.edits.filter(
|
||||
(edit) => edit.kind !== "box-size" || targetKey(edit.target) !== key,
|
||||
);
|
||||
edits.push(nextEdit);
|
||||
return { version: 1, edits };
|
||||
}
|
||||
|
||||
export function upsertStudioRotationEdit(
|
||||
manifest: StudioManualEditManifest,
|
||||
selection: DomEditSelection,
|
||||
rotation: { angle: number },
|
||||
): StudioManualEditManifest {
|
||||
const target = selectionTarget(selection);
|
||||
const key = targetKey(target);
|
||||
const nextEdit: StudioRotationEdit = {
|
||||
kind: "rotation",
|
||||
target,
|
||||
angle: roundRotationAngle(rotation.angle),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const edits = manifest.edits.filter(
|
||||
(edit) => edit.kind !== "rotation" || targetKey(edit.target) !== key,
|
||||
);
|
||||
edits.push(nextEdit);
|
||||
return { version: 1, edits };
|
||||
}
|
||||
|
||||
export function removeStudioManualEditsForSelection(
|
||||
manifest: StudioManualEditManifest,
|
||||
selection: DomEditSelection,
|
||||
): StudioManualEditManifest {
|
||||
const key = targetKey(selectionTarget(selection));
|
||||
const edits = manifest.edits.filter((edit) => targetKey(edit.target) !== key);
|
||||
if (edits.length === manifest.edits.length) return manifest;
|
||||
return { version: 1, edits };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* ── Public constants ──────────────────────────────────────────────── */
|
||||
export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
|
||||
export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
|
||||
export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
|
||||
export const STUDIO_WIDTH_PROP = "--hf-studio-width";
|
||||
@@ -40,44 +39,6 @@ export const STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP = "__hfStudioManualEditsPla
|
||||
|
||||
export const STUDIO_ROTATION_TRANSFORM_ORIGIN = "center center";
|
||||
|
||||
/* ── Edit types ───────────────────────────────────────────────────── */
|
||||
export interface StudioManualEditTarget {
|
||||
sourceFile: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface StudioPathOffsetEdit {
|
||||
kind: "path-offset";
|
||||
target: StudioManualEditTarget;
|
||||
x: number;
|
||||
y: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface StudioBoxSizeEdit {
|
||||
kind: "box-size";
|
||||
target: StudioManualEditTarget;
|
||||
width: number;
|
||||
height: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface StudioRotationEdit {
|
||||
kind: "rotation";
|
||||
target: StudioManualEditTarget;
|
||||
angle: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type StudioManualEdit = StudioPathOffsetEdit | StudioBoxSizeEdit | StudioRotationEdit;
|
||||
|
||||
export interface StudioManualEditManifest {
|
||||
version: 1;
|
||||
edits: StudioManualEdit[];
|
||||
}
|
||||
|
||||
export type StudioManualEditSeekWindow = Window & {
|
||||
__hf?: Record<string, unknown>;
|
||||
__player?: Record<string, unknown>;
|
||||
@@ -87,7 +48,7 @@ export type StudioManualEditSeekWindow = Window & {
|
||||
__hfStudioManualEditsPlaybackFrame?: number | null;
|
||||
};
|
||||
|
||||
/* ── Snapshot types ───────────────────────────────────────────────── */
|
||||
/* ── Snapshot types (used by drag/drop restore) ───────────────────── */
|
||||
export interface StudioBoxSizeSnapshot {
|
||||
width: string;
|
||||
height: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
restoreStudioPathOffset,
|
||||
restoreStudioRotation,
|
||||
} from "./manualEdits";
|
||||
import { type GroupOverlayItem, type OverlayRect } from "./domEditOverlayGeometry";
|
||||
import { type GroupOverlayItem, type OverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
|
||||
import {
|
||||
BLOCKED_MOVE_THRESHOLD_PX,
|
||||
type BlockedMoveState,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
// Refs are stable across renders; values are read via .current.
|
||||
export type UseDomEditOverlayGesturesOptions = {
|
||||
overlayRef: RefObject<HTMLDivElement | null>;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
boxRef: RefObject<HTMLDivElement | null>;
|
||||
selectionRef: RefObject<DomEditSelection | null>;
|
||||
overlayRectRef: RefObject<OverlayRect | null>;
|
||||
@@ -194,17 +195,31 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
dy,
|
||||
uniform: e.shiftKey,
|
||||
});
|
||||
applyStudioBoxSizeDraft(sel.element, nextSize);
|
||||
|
||||
// Re-read BCR after applying dimensions. For elements with a GSAP
|
||||
// scale transform and centered transform-origin the visual top-left
|
||||
// drifts and the visual size diverges from the raw CSS size, so BCR
|
||||
// is the only accurate source for both.
|
||||
const overlayEl = opts.overlayRef.current;
|
||||
const iframe = opts.iframeRef.current;
|
||||
const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
|
||||
const overlayLeft = refreshed ? refreshed.left : g.originLeft;
|
||||
const overlayTop = refreshed ? refreshed.top : g.originTop;
|
||||
const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth;
|
||||
const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight;
|
||||
box.style.left = `${overlayLeft}px`;
|
||||
box.style.top = `${overlayTop}px`;
|
||||
box.style.width = `${overlayWidth}px`;
|
||||
box.style.height = `${overlayHeight}px`;
|
||||
setDraftOverlayRect({
|
||||
left: g.originLeft,
|
||||
top: g.originTop,
|
||||
width: nextSize.overlayWidth,
|
||||
height: nextSize.overlayHeight,
|
||||
left: overlayLeft,
|
||||
top: overlayTop,
|
||||
width: overlayWidth,
|
||||
height: overlayHeight,
|
||||
editScaleX: g.editScaleX,
|
||||
editScaleY: g.editScaleY,
|
||||
});
|
||||
box.style.width = `${nextSize.overlayWidth}px`;
|
||||
box.style.height = `${nextSize.overlayHeight}px`;
|
||||
applyStudioBoxSizeDraft(sel.element, nextSize);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -281,6 +296,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
box.style.height = `${g.originHeight}px`;
|
||||
}
|
||||
restoreGestureOverlayRect(g);
|
||||
opts.suppressNextBoxClickRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,6 +357,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
|
||||
});
|
||||
} else {
|
||||
opts.suppressNextBoxClickRef.current = true;
|
||||
const finalSize = readStudioBoxSize(sel.element);
|
||||
applyStudioBoxSize(sel.element, finalSize);
|
||||
void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
|
||||
|
||||
@@ -3,7 +3,6 @@ import { usePlayerStore } from "../player";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
||||
import { STUDIO_MANUAL_EDITS_PATH } from "../components/editor/manualEdits";
|
||||
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
||||
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
|
||||
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
@@ -85,9 +84,7 @@ export function useAppHotkeys({
|
||||
|
||||
const readHistoryProjectFile = useCallback(
|
||||
async (path: string): Promise<string> => {
|
||||
return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
|
||||
? readOptionalProjectFile(path)
|
||||
: readProjectFile(path);
|
||||
return path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path);
|
||||
},
|
||||
[readOptionalProjectFile, readProjectFile],
|
||||
);
|
||||
|
||||
@@ -6,12 +6,21 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
||||
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
||||
import {
|
||||
removeStudioManualEditsForSelection,
|
||||
type StudioManualEditManifest,
|
||||
upsertStudioBoxSizeEdit,
|
||||
upsertStudioPathOffsetEdit,
|
||||
upsertStudioRotationEdit,
|
||||
applyStudioPathOffset,
|
||||
applyStudioBoxSize,
|
||||
applyStudioRotation,
|
||||
clearStudioPathOffset,
|
||||
clearStudioBoxSize,
|
||||
clearStudioRotation,
|
||||
} from "../components/editor/manualEdits";
|
||||
import {
|
||||
buildPathOffsetPatches,
|
||||
buildBoxSizePatches,
|
||||
buildRotationPatches,
|
||||
buildClearPathOffsetPatches,
|
||||
buildClearBoxSizePatches,
|
||||
buildClearRotationPatches,
|
||||
} from "../components/editor/manualEditsDom";
|
||||
import {
|
||||
removeStudioMotionForSelection,
|
||||
type StudioGsapMotion,
|
||||
@@ -48,15 +57,11 @@ export interface UseDomEditCommitsParams {
|
||||
activeCompPath: string | null;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
commitStudioManualEditManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||
commitStudioMotionManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
@@ -69,15 +74,12 @@ export interface UseDomEditCommitsParams {
|
||||
|
||||
// From useDomSelection
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
clearDomSelection: () => void;
|
||||
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
||||
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
|
||||
buildDomSelectionFromTarget: (
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
@@ -90,9 +92,8 @@ export function useDomEditCommits({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
queueDomEditSave,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
@@ -103,11 +104,9 @@ export function useDomEditCommits({
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
domEditSelection,
|
||||
domEditGroupSelectionsRef,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
}: UseDomEditCommitsParams) {
|
||||
const resolveImportedFontAsset = useCallback(
|
||||
@@ -211,97 +210,103 @@ export function useDomEditCommits({
|
||||
resolveImportedFontAsset,
|
||||
});
|
||||
|
||||
// ── Manifest commits ──
|
||||
// ── Position patch helper ──
|
||||
|
||||
const commitPositionPatchToHtml = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
patches: Parameters<typeof applyPatchByTarget>[2][],
|
||||
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
||||
) => {
|
||||
void queueDomEditSave(async () => {
|
||||
await persistDomEditOperations(selection, patches, {
|
||||
label: options.label,
|
||||
coalesceKey: options.coalesceKey,
|
||||
skipRefresh: options.skipRefresh ?? true,
|
||||
});
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to save position";
|
||||
showToast(message);
|
||||
});
|
||||
},
|
||||
[persistDomEditOperations, queueDomEditSave, showToast],
|
||||
);
|
||||
|
||||
// ── Position commits ──
|
||||
|
||||
const handleDomPathOffsetCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
|
||||
{
|
||||
applyStudioPathOffset(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
||||
label: "Move layer",
|
||||
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomGroupPathOffsetCommit = useCallback(
|
||||
(updates: DomEditGroupPathOffsetCommit[]) => {
|
||||
if (updates.length === 0) return;
|
||||
const coalesceKey = updates
|
||||
.map((update) => getDomEditTargetKey(update.selection))
|
||||
.map((u) => getDomEditTargetKey(u.selection))
|
||||
.sort()
|
||||
.join(":");
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) =>
|
||||
updates.reduce(
|
||||
(nextManifest, update) =>
|
||||
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
|
||||
manifest,
|
||||
),
|
||||
{
|
||||
for (const { selection, next } of updates) {
|
||||
applyStudioPathOffset(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
||||
label: `Move ${updates.length} layers`,
|
||||
coalesceKey: `group-path-offset:${coalesceKey}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
|
||||
},
|
||||
[
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
domEditGroupSelectionsRef,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomBoxSizeCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
|
||||
{
|
||||
applyStudioBoxSize(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
|
||||
label: "Resize layer box",
|
||||
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomRotationCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { angle: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
|
||||
{
|
||||
applyStudioRotation(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
||||
label: "Rotate layer",
|
||||
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomManualEditsReset = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
|
||||
{
|
||||
const element = selection.element;
|
||||
const clearPatches = [
|
||||
...buildClearPathOffsetPatches(element),
|
||||
...buildClearBoxSizePatches(element),
|
||||
...buildClearRotationPatches(element),
|
||||
];
|
||||
clearStudioPathOffset(element);
|
||||
clearStudioBoxSize(element);
|
||||
clearStudioRotation(element);
|
||||
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
|
||||
commitPositionPatchToHtml(selection, clearPatches, {
|
||||
label: "Reset layer edits",
|
||||
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
||||
skipRefresh: false,
|
||||
});
|
||||
},
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
},
|
||||
[
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Motion commits ──
|
||||
|
||||
const handleDomMotionCommit = useCallback(
|
||||
(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { findElementForSelection } from "../components/editor/domEditing";
|
||||
import type { StudioManualEditManifest } from "../components/editor/manualEdits";
|
||||
import type { StudioMotionManifest } from "../components/editor/studioMotion";
|
||||
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
@@ -36,15 +35,11 @@ export interface UseDomEditSessionParams {
|
||||
setRightPanelTab: (tab: RightPanelTab) => void;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
refreshPreviewDocumentVersion: () => void;
|
||||
commitStudioManualEditManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||
commitStudioMotionManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
@@ -85,9 +80,8 @@ export function useDomEditSession({
|
||||
setRightPanelTab,
|
||||
showToast,
|
||||
refreshPreviewDocumentVersion,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
queueDomEditSave,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
readProjectFile: _readProjectFile,
|
||||
writeProjectFile,
|
||||
@@ -114,7 +108,6 @@ export function useDomEditSession({
|
||||
domEditGroupSelections,
|
||||
domEditHoverSelection,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
buildDomSelectionFromTarget,
|
||||
@@ -123,7 +116,6 @@ export function useDomEditSession({
|
||||
buildDomSelectionForTimelineElement,
|
||||
handleTimelineElementSelect,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
} = useDomSelection({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -176,7 +168,6 @@ export function useDomEditSession({
|
||||
captionEditMode,
|
||||
compositionLoading,
|
||||
previewIframeRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
applyDomSelection,
|
||||
resolveDomSelectionFromPreviewPoint,
|
||||
@@ -208,9 +199,8 @@ export function useDomEditSession({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
queueDomEditSave,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
@@ -221,12 +211,9 @@ export function useDomEditSession({
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
domEditSelection,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import {
|
||||
STUDIO_MANUAL_EDITS_PATH,
|
||||
applyStudioManualEditManifest,
|
||||
emptyStudioManualEditManifest,
|
||||
installStudioManualEditSeekReapply,
|
||||
isStudioManualEditManifestPath,
|
||||
parseStudioManualEditManifest,
|
||||
reapplyPositionEditsAfterSeek,
|
||||
readStudioFileChangePath,
|
||||
serializeStudioManualEditManifest,
|
||||
type StudioManualEditManifest,
|
||||
} from "../components/editor/manualEdits";
|
||||
import {
|
||||
STUDIO_MOTION_PATH,
|
||||
@@ -41,6 +35,11 @@ interface UseManifestPersistenceParams {
|
||||
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
activeCompPathRef: React.MutableRefObject<string | null>;
|
||||
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
|
||||
* Used to suppress SSE echoes so we don't double-reload after our own saves. */
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
/** Called to reload the preview after undo/redo or external file changes. */
|
||||
reloadPreview: () => void;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
@@ -48,21 +47,19 @@ interface UseManifestPersistenceParams {
|
||||
export function useManifestPersistence({
|
||||
projectId,
|
||||
showToast,
|
||||
readOptionalProjectFile,
|
||||
readOptionalProjectFile: _readOptionalProjectFile,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
previewIframeRef,
|
||||
activeCompPathRef,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
}: UseManifestPersistenceParams) {
|
||||
const [, setStudioMotionRevision] = useState(0);
|
||||
void _readOptionalProjectFile;
|
||||
|
||||
const domEditSaveTimestampRef = useRef(0);
|
||||
const [, setStudioMotionRevision] = useState(0);
|
||||
const domTextCommitVersionRef = useRef(0);
|
||||
const domEditSaveQueueRef = useRef(Promise.resolve());
|
||||
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
|
||||
emptyStudioManualEditManifest(),
|
||||
);
|
||||
const studioManualEditRevisionRef = useRef(0);
|
||||
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
|
||||
const studioMotionRevisionRef = useRef(0);
|
||||
const applyStudioManualEditsToPreviewRef = useRef<
|
||||
@@ -77,9 +74,7 @@ export function useManifestPersistence({
|
||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
||||
) => Promise<void>
|
||||
>(async () => {});
|
||||
const manifestBootstrappedRef = useRef(false);
|
||||
const motionBootstrappedRef = useRef(false);
|
||||
const studioManualEditProjectRef = useRef<string | null>(projectId);
|
||||
|
||||
// Keep a ref to the latest projectId so async save callbacks always read the
|
||||
// current value, even when the callback was captured in a stale closure.
|
||||
@@ -101,7 +96,7 @@ export function useManifestPersistence({
|
||||
await domEditSaveQueueRef.current.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// ── Apply manual edits ──
|
||||
// ── Apply manual edits (HTML-baked — just install seek hooks) ──
|
||||
|
||||
const applyCurrentStudioManualEditsToPreview = useCallback(
|
||||
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
||||
@@ -113,68 +108,38 @@ export function useManifestPersistence({
|
||||
return;
|
||||
}
|
||||
if (!doc) return;
|
||||
const previewDoc = doc;
|
||||
|
||||
const applyManifest = () => {
|
||||
applyStudioManualEditManifest(
|
||||
previewDoc,
|
||||
studioManualEditManifestRef.current,
|
||||
activeCompPathRef.current,
|
||||
);
|
||||
};
|
||||
const applyAndInstallSeekHooks = () => {
|
||||
applyManifest();
|
||||
if (iframe.contentWindow) {
|
||||
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
||||
const reapply = () => {
|
||||
let d: Document | null = null;
|
||||
try {
|
||||
d = iframe.contentDocument;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (d) reapplyPositionEditsAfterSeek(d);
|
||||
};
|
||||
const install = () => {
|
||||
reapply();
|
||||
if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
|
||||
};
|
||||
|
||||
const win = iframe.contentWindow;
|
||||
applyAndInstallSeekHooks();
|
||||
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
|
||||
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
|
||||
install();
|
||||
win?.requestAnimationFrame?.(install);
|
||||
win?.setTimeout?.(install, 80);
|
||||
win?.setTimeout?.(install, 250);
|
||||
win?.setTimeout?.(install, 500);
|
||||
win?.setTimeout?.(install, 1000);
|
||||
win?.setTimeout?.(install, 2000);
|
||||
},
|
||||
[activeCompPathRef, previewIframeRef],
|
||||
[previewIframeRef],
|
||||
);
|
||||
|
||||
const applyStudioManualEditsToPreview = useCallback(
|
||||
async (
|
||||
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
||||
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
||||
) => {
|
||||
// Bootstrap from disk on first apply per session; explicit flag avoids
|
||||
// re-reading disk after the user deletes all edits (async write race).
|
||||
const needsBootstrap = !manifestBootstrappedRef.current;
|
||||
if (needsBootstrap) manifestBootstrappedRef.current = true;
|
||||
const readFromDiskFirst = Boolean(
|
||||
options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap,
|
||||
);
|
||||
if (!readFromDiskFirst) {
|
||||
applyCurrentStudioManualEditsToPreview(iframe);
|
||||
return;
|
||||
}
|
||||
const readRevision = studioManualEditRevisionRef.current;
|
||||
let content: string;
|
||||
try {
|
||||
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
||||
showToast(message);
|
||||
applyCurrentStudioManualEditsToPreview(iframe);
|
||||
return;
|
||||
}
|
||||
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
||||
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
||||
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
||||
}
|
||||
async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
||||
applyCurrentStudioManualEditsToPreview(iframe);
|
||||
},
|
||||
[applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast],
|
||||
[applyCurrentStudioManualEditsToPreview, previewIframeRef],
|
||||
);
|
||||
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
||||
|
||||
@@ -230,7 +195,7 @@ export function useManifestPersistence({
|
||||
const readRevision = studioMotionRevisionRef.current;
|
||||
let content: string;
|
||||
try {
|
||||
content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
||||
content = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
|
||||
showToast(message);
|
||||
@@ -244,80 +209,11 @@ export function useManifestPersistence({
|
||||
}
|
||||
applyCurrentStudioMotionToPreview(iframe);
|
||||
},
|
||||
[applyCurrentStudioMotionToPreview, previewIframeRef, readOptionalProjectFile, showToast],
|
||||
[applyCurrentStudioMotionToPreview, previewIframeRef, _readOptionalProjectFile, showToast],
|
||||
);
|
||||
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
|
||||
|
||||
// ── Optimistic commits ──
|
||||
|
||||
const commitStudioManualEditManifestOptimistically = useCallback(
|
||||
(
|
||||
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => {
|
||||
const previousManifest = studioManualEditManifestRef.current;
|
||||
const nextManifest = updateManifest(previousManifest);
|
||||
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
||||
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
||||
if (nextContent === previousContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = studioManualEditRevisionRef.current + 1;
|
||||
studioManualEditRevisionRef.current = revision;
|
||||
studioManualEditManifestRef.current = nextManifest;
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
|
||||
const save = async () => {
|
||||
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
||||
const diskManifest = parseStudioManualEditManifest(originalContent);
|
||||
const nextDiskManifest = updateManifest(diskManifest);
|
||||
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
||||
if (nextDiskContent === originalContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: options.label,
|
||||
kind: "manual",
|
||||
coalesceKey: options.coalesceKey,
|
||||
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
|
||||
if (studioManualEditRevisionRef.current === revision) {
|
||||
studioManualEditManifestRef.current = nextDiskManifest;
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
void queueDomEditSave(save).catch((error) => {
|
||||
if (studioManualEditRevisionRef.current === revision) {
|
||||
studioManualEditRevisionRef.current += 1;
|
||||
studioManualEditManifestRef.current = previousManifest;
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
||||
showToast(message);
|
||||
});
|
||||
},
|
||||
[
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
recordEdit,
|
||||
queueDomEditSave,
|
||||
readOptionalProjectFile,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
// ── Optimistic motion commit ──
|
||||
|
||||
const commitStudioMotionManifestOptimistically = useCallback(
|
||||
(
|
||||
@@ -339,7 +235,7 @@ export function useManifestPersistence({
|
||||
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
||||
|
||||
const save = async () => {
|
||||
const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
||||
const originalContent = await _readOptionalProjectFile(STUDIO_MOTION_PATH);
|
||||
const diskManifest = parseStudioMotionManifest(originalContent);
|
||||
const nextDiskManifest = updateManifest(diskManifest);
|
||||
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
|
||||
@@ -384,10 +280,11 @@ export function useManifestPersistence({
|
||||
applyCurrentStudioMotionToPreview,
|
||||
recordEdit,
|
||||
queueDomEditSave,
|
||||
readOptionalProjectFile,
|
||||
_readOptionalProjectFile,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
previewIframeRef,
|
||||
domEditSaveTimestampRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -396,69 +293,40 @@ export function useManifestPersistence({
|
||||
const syncHistoryPreviewAfterApply = useCallback(
|
||||
async (paths: string[] | undefined) => {
|
||||
const changedPaths = paths ?? [];
|
||||
const manualManifestOnly =
|
||||
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
||||
const motionManifestOnly =
|
||||
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
|
||||
|
||||
if (manualManifestOnly) {
|
||||
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
||||
return;
|
||||
}
|
||||
if (motionManifestOnly) {
|
||||
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload the iframe in-place rather than recreating the Player component.
|
||||
// This preserves the <hyperframes-player> web component and its shader
|
||||
// transition cache — only the iframe document reloads, so transitions that
|
||||
// weren't touched by the undo/redo don't need to rebuild from scratch.
|
||||
const iframe = previewIframeRef.current;
|
||||
if (iframe?.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.location.reload();
|
||||
return;
|
||||
} catch {
|
||||
// Cross-origin or detached — fall through to full refresh
|
||||
}
|
||||
}
|
||||
// Reload via refreshKey so NLELayout saves seek position before the iframe reloads.
|
||||
reloadPreview();
|
||||
},
|
||||
[applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef],
|
||||
[applyStudioMotionToPreview, previewIframeRef, reloadPreview],
|
||||
);
|
||||
|
||||
// ── Reset manifests when project changes ──
|
||||
|
||||
const projectTrackerRef = useRef<string | null>(projectId);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const previousProjectId = studioManualEditProjectRef.current;
|
||||
studioManualEditProjectRef.current = projectId;
|
||||
const previousProjectId = projectTrackerRef.current;
|
||||
projectTrackerRef.current = projectId;
|
||||
if (!previousProjectId || previousProjectId === projectId) return;
|
||||
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
|
||||
studioManualEditRevisionRef.current += 1;
|
||||
studioMotionManifestRef.current = emptyStudioMotionManifest();
|
||||
studioMotionRevisionRef.current += 1;
|
||||
setStudioMotionRevision((revision) => revision + 1);
|
||||
manifestBootstrappedRef.current = motionBootstrappedRef.current = false;
|
||||
motionBootstrappedRef.current = false;
|
||||
}, [projectId]);
|
||||
|
||||
// ── Listen for external file changes (HMR / SSE) ──
|
||||
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
||||
// Suppress file-change events that echo back from a recent DOM edit save —
|
||||
// those changes are already applied to the iframe DOM and a full reload
|
||||
// would flash the preview.
|
||||
useMountEffect(() => {
|
||||
const handler = (payload?: unknown) => {
|
||||
const changedPath = readStudioFileChangePath(payload);
|
||||
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
||||
if (isStudioManualEditManifestPath(changedPath)) {
|
||||
if (!recentDomEditSave) {
|
||||
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
|
||||
forceFromDisk: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStudioMotionManifestPath(changedPath)) {
|
||||
if (!recentDomEditSave) {
|
||||
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
|
||||
@@ -467,9 +335,10 @@ export function useManifestPersistence({
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Non-manifest file changes are not handled here — the caller is
|
||||
// responsible for triggering a preview refresh via onExternalFileChange
|
||||
// if needed. This hook only suppresses echoes and handles manifest reloads.
|
||||
// Non-motion external file change — reload unless it's an echo of our own save.
|
||||
if (!recentDomEditSave) {
|
||||
reloadPreview();
|
||||
}
|
||||
};
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("hf:file-change", handler);
|
||||
@@ -482,23 +351,18 @@ export function useManifestPersistence({
|
||||
});
|
||||
|
||||
return {
|
||||
domEditSaveTimestampRef,
|
||||
domTextCommitVersionRef,
|
||||
domEditSaveQueueRef,
|
||||
studioManualEditManifestRef,
|
||||
studioManualEditRevisionRef,
|
||||
studioMotionManifestRef,
|
||||
studioMotionRevisionRef,
|
||||
applyStudioManualEditsToPreviewRef,
|
||||
applyStudioMotionToPreviewRef,
|
||||
studioManualEditProjectRef,
|
||||
queueDomEditSave,
|
||||
waitForPendingDomEditSaves,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
applyStudioMotionToPreview,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
syncHistoryPreviewAfterApply,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface UsePreviewInteractionParams {
|
||||
captionEditMode: boolean;
|
||||
compositionLoading: boolean;
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
|
||||
// From useDomSelection
|
||||
|
||||
@@ -71,7 +71,7 @@ function splitInlineStyleDeclarations(style: string): string[] {
|
||||
export interface PatchOperation {
|
||||
type: "inline-style" | "attribute" | "text-content";
|
||||
property: string;
|
||||
value: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface PatchTarget {
|
||||
@@ -133,7 +133,12 @@ export function resolveSourceFile(
|
||||
/**
|
||||
* Apply a style property change to an element's inline style in the HTML source.
|
||||
*/
|
||||
function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string {
|
||||
function patchInlineStyle(
|
||||
html: string,
|
||||
elementId: string,
|
||||
prop: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
// Find the element tag with this id
|
||||
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
||||
const match = idPattern.exec(html);
|
||||
@@ -143,7 +148,12 @@ function patchInlineStyle(html: string, elementId: string, prop: string, value:
|
||||
return patchInlineStyleInTag(html, tag, prop, value);
|
||||
}
|
||||
|
||||
function patchInlineStyleInTag(html: string, tag: string, prop: string, value: string): string {
|
||||
function patchInlineStyleInTag(
|
||||
html: string,
|
||||
tag: string,
|
||||
prop: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
if (!tag) return html;
|
||||
|
||||
// Check if there's an existing style attribute
|
||||
@@ -160,16 +170,22 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
|
||||
const val = part.slice(colon + 1).trim();
|
||||
if (key) props.set(key, val);
|
||||
}
|
||||
// Update/add the property
|
||||
// Update/add or remove the property
|
||||
if (value === null) {
|
||||
props.delete(prop);
|
||||
} else {
|
||||
props.set(prop, value);
|
||||
// Rebuild style string
|
||||
}
|
||||
// Rebuild style string; keep style="" if empty (harmless)
|
||||
const newStyle = Array.from(props.entries())
|
||||
.map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
|
||||
.join("; ");
|
||||
const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
|
||||
return html.replace(tag, newTag);
|
||||
} else {
|
||||
// No existing style — add one
|
||||
// No existing style attribute
|
||||
if (value === null) return html; // nothing to remove
|
||||
// Add one
|
||||
const newTag =
|
||||
tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
|
||||
return html.replace(tag, newTag);
|
||||
@@ -180,7 +196,7 @@ function patchInlineStyleByTarget(
|
||||
html: string,
|
||||
target: PatchTarget,
|
||||
prop: string,
|
||||
value: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
@@ -277,15 +293,23 @@ function patchAttributeByTarget(
|
||||
html: string,
|
||||
target: PatchTarget,
|
||||
attr: string,
|
||||
value: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const match = findTagByTarget(html, target);
|
||||
if (!match) return html;
|
||||
|
||||
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||
const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
|
||||
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
||||
const tag = match.tag;
|
||||
|
||||
if (value === null) {
|
||||
// Remove the attribute if present
|
||||
if (!attrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
}
|
||||
|
||||
if (attrPattern.test(tag)) {
|
||||
const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
|
||||
return replaceTagAtMatch(html, match, newTag);
|
||||
@@ -298,14 +322,26 @@ function patchAttributeByTarget(
|
||||
/**
|
||||
* Apply an attribute change to an element in the HTML source.
|
||||
*/
|
||||
function patchAttribute(html: string, elementId: string, attr: string, value: string): string {
|
||||
function patchAttribute(
|
||||
html: string,
|
||||
elementId: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): string {
|
||||
const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
|
||||
const match = idPattern.exec(html);
|
||||
if (!match) return html;
|
||||
|
||||
const tag = match[1];
|
||||
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||
const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
|
||||
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
|
||||
|
||||
if (value === null) {
|
||||
if (!attrPattern.test(tag)) return html;
|
||||
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
|
||||
const newTag = tag.replace(removePattern, "");
|
||||
return html.replace(tag, newTag);
|
||||
}
|
||||
|
||||
if (attrPattern.test(tag)) {
|
||||
// Update existing attribute
|
||||
@@ -381,7 +417,7 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation):
|
||||
case "attribute":
|
||||
return patchAttribute(html, elementId, op.property, op.value);
|
||||
case "text-content":
|
||||
return patchTextContent(html, elementId, op.value);
|
||||
return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
|
||||
default:
|
||||
return html;
|
||||
}
|
||||
@@ -401,7 +437,7 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
|
||||
case "attribute":
|
||||
return patchAttributeByTarget(html, target, op.property, op.value);
|
||||
case "text-content":
|
||||
return patchTextContentByTarget(html, target, op.value);
|
||||
return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
|
||||
default:
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing";
|
||||
import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
|
||||
import {
|
||||
getDomLayerPatchTarget,
|
||||
isElementComputedVisible,
|
||||
} from "../components/editor/domEditingElement";
|
||||
import { usePlayerStore, liveTime } from "../player";
|
||||
import { getEventTargetElement } from "./studioHelpers";
|
||||
|
||||
@@ -56,6 +60,28 @@ export function getPreviewLocalPointer(
|
||||
return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
}
|
||||
|
||||
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
|
||||
|
||||
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
|
||||
try {
|
||||
const style = doc.createElement("style");
|
||||
style.id = POINTER_EVENTS_OVERRIDE_ID;
|
||||
style.textContent = "* { pointer-events: auto !important; }";
|
||||
doc.head.appendChild(style);
|
||||
return style;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePointerEventsOverride(style: HTMLStyleElement | null): void {
|
||||
try {
|
||||
style?.remove();
|
||||
} catch {
|
||||
// cross-origin or detached doc
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreviewTargetFromPointer(
|
||||
iframe: HTMLIFrameElement,
|
||||
clientX: number,
|
||||
@@ -75,6 +101,8 @@ export function getPreviewTargetFromPointer(
|
||||
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
||||
if (!localPointer) return null;
|
||||
|
||||
const overrideStyle = forcePointerEventsAuto(doc);
|
||||
try {
|
||||
if (typeof doc.elementsFromPoint === "function") {
|
||||
const visualTarget = resolveVisualDomEditSelectionTarget(
|
||||
doc.elementsFromPoint(localPointer.x, localPointer.y),
|
||||
@@ -85,7 +113,13 @@ export function getPreviewTargetFromPointer(
|
||||
if (visualTarget) return visualTarget;
|
||||
}
|
||||
|
||||
return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
||||
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
||||
if (!isElementComputedVisible(fallback)) return null;
|
||||
return fallback;
|
||||
} finally {
|
||||
removePointerEventsOverride(overrideStyle);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRasterClickSelectionContext(
|
||||
|
||||
@@ -106,7 +106,8 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi
|
||||
.filter(
|
||||
(d) =>
|
||||
(d.isDirectory() || d.isSymbolicLink()) &&
|
||||
existsSync(join(dataDir, d.name, "index.html")),
|
||||
(existsSync(join(dataDir, d.name, "index.html")) ||
|
||||
existsSync(join(dataDir, d.name, `${d.name}.html`))),
|
||||
)
|
||||
.map((d) => {
|
||||
const session = sessionMap.get(d.name);
|
||||
|
||||
Reference in New Issue
Block a user