Revert "feat: Persist Studio manual edits via manifest (#593)"

This reverts commit d0abe90a82.
This commit is contained in:
Miguel Ángel
2026-05-04 09:41:36 -07:00
parent 8d83d4f132
commit 26b8e2a985
82 changed files with 711 additions and 15345 deletions
@@ -1,241 +0,0 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import {
filterNestedDomEditGroupItems,
focusDomEditOverlayElement,
hasDomEditRotationChanged,
resolveDomEditCoordinateScale,
resolveDomEditGroupOverlayRect,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./DomEditOverlay";
describe("focusDomEditOverlayElement", () => {
it("focuses the canvas overlay without scrolling", () => {
const calls: Array<FocusOptions | undefined> = [];
focusDomEditOverlayElement({
focus: (options?: FocusOptions) => calls.push(options),
});
expect(calls).toEqual([{ preventScroll: true }]);
});
});
describe("resolveDomEditCoordinateScale", () => {
it("uses the top-level preview scale when no source boundary dimensions are available", () => {
expect(
resolveDomEditCoordinateScale({
rootScaleX: 0.5,
rootScaleY: 0.5,
}),
).toEqual({
scaleX: 0.5,
scaleY: 0.5,
});
});
it("converts source-local pixels through a scaled nested composition host", () => {
expect(
resolveDomEditCoordinateScale({
rootScaleX: 0.5,
rootScaleY: 0.5,
sourceRectWidth: 960,
sourceRectHeight: 540,
sourceWidth: 1920,
sourceHeight: 1080,
}),
).toEqual({
scaleX: 0.25,
scaleY: 0.25,
});
});
});
describe("resolveDomEditGroupOverlayRect", () => {
it("returns a bounding box that contains every selected element", () => {
expect(
resolveDomEditGroupOverlayRect([
{ left: 40, top: 30, width: 80, height: 50, editScaleX: 1, editScaleY: 1 },
{ left: 150, top: 10, width: 30, height: 120, editScaleX: 0.5, editScaleY: 0.5 },
{ left: 20, top: 90, width: 50, height: 20, editScaleX: 2, editScaleY: 2 },
]),
).toEqual({
left: 20,
top: 10,
width: 160,
height: 120,
editScaleX: 1,
editScaleY: 1,
});
});
it("returns null for an empty group", () => {
expect(resolveDomEditGroupOverlayRect([])).toBeNull();
});
});
describe("filterNestedDomEditGroupItems", () => {
it("keeps top-level selected elements so descendants are not moved twice", () => {
const window = new Window();
const parent = window.document.createElement("div");
const child = window.document.createElement("div");
const sibling = window.document.createElement("div");
parent.append(child);
expect(
filterNestedDomEditGroupItems([
{ key: "parent", element: parent },
{ key: "child", element: child },
{ key: "sibling", element: sibling },
]).map((item) => item.key),
).toEqual(["parent", "sibling"]);
});
});
describe("resolveDomEditResizeGesture", () => {
it("resizes width and height independently by default", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 240,
originHeight: 120,
actualWidth: 240,
actualHeight: 120,
scaleX: 1,
scaleY: 1,
dx: 30,
dy: 12,
uniform: false,
}),
).toEqual({
overlayWidth: 270,
overlayHeight: 132,
width: 270,
height: 132,
});
});
it("snaps width and height to the same value when Shift is held", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 240,
originHeight: 120,
actualWidth: 240,
actualHeight: 120,
scaleX: 1,
scaleY: 1,
dx: 30,
dy: 12,
uniform: true,
}),
).toEqual({
overlayWidth: 270,
overlayHeight: 270,
width: 270,
height: 270,
});
});
it("uses the dominant pointer delta for uniform shrink", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 300,
originHeight: 180,
actualWidth: 300,
actualHeight: 180,
scaleX: 1,
scaleY: 1,
dx: 8,
dy: -40,
uniform: true,
}),
).toMatchObject({
width: 260,
height: 260,
});
});
it("writes source-local dimensions when the edited source is scaled down in master view", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 100,
originHeight: 50,
actualWidth: 400,
actualHeight: 200,
scaleX: 0.25,
scaleY: 0.25,
dx: 25,
dy: 10,
uniform: false,
}),
).toEqual({
overlayWidth: 125,
overlayHeight: 60,
width: 500,
height: 240,
});
});
});
describe("resolveDomEditRotationGesture", () => {
it("rotates by the pointer angle around the element center", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 0,
startY: -10,
currentX: 10,
currentY: 0,
actualAngle: 5,
snap: false,
}),
).toEqual({ angle: 95 });
});
it("uses the shortest delta across the 180 degree boundary", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: -10,
startY: 1.76,
currentX: -10,
currentY: -1.76,
actualAngle: 0,
snap: false,
}).angle,
).toBeCloseTo(20, 1);
});
it("snaps to 15 degree increments when requested", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 10,
startY: 0,
currentX: 10,
currentY: 3.25,
actualAngle: 0,
snap: true,
}),
).toEqual({ angle: 15 });
});
it("allows small pointer movements when the rounded angle changes", () => {
const nextRotation = resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 0,
startY: -40,
currentX: 1,
currentY: -40,
actualAngle: 0,
snap: false,
});
expect(nextRotation.angle).toBe(1.4);
expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
expect(hasDomEditRotationChanged(0, 0)).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,82 +0,0 @@
import { describe, expect, it } from "vitest";
import {
formatCssColor,
hsvToRgb,
mergeColorWithExistingAlpha,
parseCssColor,
rgbToHsv,
toColorPickerValue,
toHexColor,
} from "./colorValue";
describe("parseCssColor", () => {
it("parses rgb values", () => {
expect(parseCssColor("rgb(12, 34, 56)")).toEqual({
red: 12,
green: 34,
blue: 56,
alpha: 1,
});
});
it("parses rgba values", () => {
expect(parseCssColor("rgba(15, 23, 42, 0.64)")).toEqual({
red: 15,
green: 23,
blue: 42,
alpha: 0.64,
});
});
it("parses transparent", () => {
expect(parseCssColor("transparent")).toEqual({
red: 0,
green: 0,
blue: 0,
alpha: 0,
});
});
});
describe("toColorPickerValue", () => {
it("converts css color to hex", () => {
expect(toColorPickerValue("rgba(15, 23, 42, 0.64)")).toBe("#0f172a");
});
});
describe("toHexColor", () => {
it("formats rgb channels as hex", () => {
expect(toHexColor({ red: 15, green: 23, blue: 42 })).toBe("#0f172a");
});
});
describe("formatCssColor", () => {
it("formats opaque colors as rgb", () => {
expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 1 })).toBe("rgb(18, 52, 86)");
});
it("formats translucent colors as rgba", () => {
expect(formatCssColor({ red: 18, green: 52, blue: 86, alpha: 0.64 })).toBe(
"rgba(18, 52, 86, 0.64)",
);
});
});
describe("rgb hsv conversion", () => {
it("round-trips primary color values", () => {
const hsv = rgbToHsv({ red: 47, green: 198, blue: 127 });
expect(hsvToRgb(hsv)).toEqual({ red: 47, green: 198, blue: 127 });
});
});
describe("mergeColorWithExistingAlpha", () => {
it("preserves alpha when the previous color was translucent", () => {
expect(mergeColorWithExistingAlpha("#123456", "rgba(15, 23, 42, 0.64)")).toBe(
"rgba(18, 52, 86, 0.64)",
);
});
it("returns rgb when the previous color was opaque", () => {
expect(mergeColorWithExistingAlpha("#123456", "rgb(15, 23, 42)")).toBe("rgb(18, 52, 86)");
});
});
@@ -1,175 +0,0 @@
export interface ParsedColor {
red: number;
green: number;
blue: number;
alpha: number;
}
export interface HsvColor {
hue: number;
saturation: number;
value: number;
}
function clampChannel(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}
function clampAlpha(value: number): number {
return Math.max(0, Math.min(1, value));
}
function toHex(value: number): string {
return clampChannel(value).toString(16).padStart(2, "0");
}
function formatAlpha(value: number): string {
return `${Math.round(clampAlpha(value) * 100) / 100}`;
}
export function parseCssColor(value: string): ParsedColor | null {
const trimmed = value.trim().toLowerCase();
if (!trimmed) return null;
if (trimmed === "transparent") {
return { red: 0, green: 0, blue: 0, alpha: 0 };
}
const shortHex = trimmed.match(/^#([0-9a-f]{3})$/i);
if (shortHex) {
const [r, g, b] = shortHex[1].split("");
return {
red: Number.parseInt(r + r, 16),
green: Number.parseInt(g + g, 16),
blue: Number.parseInt(b + b, 16),
alpha: 1,
};
}
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
if (hex) {
return {
red: Number.parseInt(hex[1].slice(0, 2), 16),
green: Number.parseInt(hex[1].slice(2, 4), 16),
blue: Number.parseInt(hex[1].slice(4, 6), 16),
alpha: 1,
};
}
const rgba = trimmed.match(
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
);
if (rgba) {
return {
red: clampChannel(Number.parseFloat(rgba[1])),
green: clampChannel(Number.parseFloat(rgba[2])),
blue: clampChannel(Number.parseFloat(rgba[3])),
alpha: clampAlpha(rgba[4] != null ? Number.parseFloat(rgba[4]) : 1),
};
}
return null;
}
export function toColorPickerValue(value: string): string {
const parsed = parseCssColor(value);
if (!parsed) return "#000000";
return toHexColor(parsed);
}
export function toHexColor(color: Pick<ParsedColor, "red" | "green" | "blue">): string {
return `#${toHex(color.red)}${toHex(color.green)}${toHex(color.blue)}`;
}
export function formatCssColor(color: ParsedColor): string {
const red = clampChannel(color.red);
const green = clampChannel(color.green);
const blue = clampChannel(color.blue);
const alpha = clampAlpha(color.alpha);
if (alpha >= 1) {
return `rgb(${red}, ${green}, ${blue})`;
}
return `rgba(${red}, ${green}, ${blue}, ${formatAlpha(alpha)})`;
}
export function rgbToHsv(color: Pick<ParsedColor, "red" | "green" | "blue">): HsvColor {
const red = clampChannel(color.red) / 255;
const green = clampChannel(color.green) / 255;
const blue = clampChannel(color.blue) / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
let hue = 0;
if (delta !== 0) {
if (max === red) {
hue = 60 * (((green - blue) / delta) % 6);
} else if (max === green) {
hue = 60 * ((blue - red) / delta + 2);
} else {
hue = 60 * ((red - green) / delta + 4);
}
}
if (hue < 0) hue += 360;
return {
hue,
saturation: max === 0 ? 0 : delta / max,
value: max,
};
}
export function hsvToRgb(color: HsvColor): Pick<ParsedColor, "red" | "green" | "blue"> {
const hue = (((color.hue % 360) + 360) % 360) / 60;
const saturation = Math.max(0, Math.min(1, color.saturation));
const value = Math.max(0, Math.min(1, color.value));
const chroma = value * saturation;
const x = chroma * (1 - Math.abs((hue % 2) - 1));
const m = value - chroma;
let red = 0;
let green = 0;
let blue = 0;
if (hue >= 0 && hue < 1) {
red = chroma;
green = x;
} else if (hue >= 1 && hue < 2) {
red = x;
green = chroma;
} else if (hue >= 2 && hue < 3) {
green = chroma;
blue = x;
} else if (hue >= 3 && hue < 4) {
green = x;
blue = chroma;
} else if (hue >= 4 && hue < 5) {
red = x;
blue = chroma;
} else {
red = chroma;
blue = x;
}
return {
red: clampChannel((red + m) * 255),
green: clampChannel((green + m) * 255),
blue: clampChannel((blue + m) * 255),
};
}
export function mergeColorWithExistingAlpha(nextHex: string, previousValue: string): string {
const hex = nextHex.trim();
const match = hex.match(/^#([0-9a-f]{6})$/i);
if (!match) return previousValue;
const previous = parseCssColor(previousValue);
const red = Number.parseInt(match[1].slice(0, 2), 16);
const green = Number.parseInt(match[1].slice(2, 4), 16);
const blue = Number.parseInt(match[1].slice(4, 6), 16);
const alpha = previous?.alpha ?? 1;
return formatCssColor({ red, green, blue, alpha });
}
@@ -1,669 +0,0 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import {
buildDomEditStylePatchOperation,
buildElementAgentPrompt,
findElementForSelection,
getDomEditNonEditableReason,
getDomEditTargetKey,
isTextEditableSelection,
serializeDomEditTextFields,
type DomEditSelection,
resolveDomEditCapabilities,
resolveDomEditSelection,
} from "./domEditing";
function createDocument(markup: string): Document {
const window = new Window();
Object.assign(window, { SyntaxError });
window.document.body.innerHTML = markup;
return window.document;
}
describe("resolveDomEditCapabilities", () => {
it("marks absolute px-positioned layers as movable and resizable", () => {
expect(
resolveDomEditCapabilities({
selector: "#card",
inlineStyles: {
left: "120px",
top: "80px",
width: "240px",
height: "140px",
},
computedStyles: {
position: "absolute",
left: "120px",
top: "80px",
width: "240px",
height: "140px",
transform: "none",
},
isCompositionHost: false,
isMasterView: false,
}),
).toEqual({
canSelect: true,
canEditStyles: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
reasonIfDisabled: undefined,
});
});
it("rejects flex/grid children for move and resize", () => {
expect(
resolveDomEditCapabilities({
selector: "#chip",
tagName: "div",
inlineStyles: {},
computedStyles: {
position: "static",
display: "block",
left: "auto",
top: "auto",
width: "180px",
height: "64px",
transform: "none",
},
isCompositionHost: false,
isMasterView: false,
}),
).toMatchObject({
canSelect: true,
canEditStyles: true,
canMove: false,
canResize: false,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
reasonIfDisabled: undefined,
});
});
it("rejects transform-driven geometry", () => {
expect(
resolveDomEditCapabilities({
selector: "#card",
inlineStyles: {
left: "120px",
top: "80px",
width: "240px",
height: "140px",
},
computedStyles: {
position: "absolute",
left: "120px",
top: "80px",
width: "240px",
height: "140px",
transform: "matrix(1, 0, 0, 1, 12, 0)",
},
isCompositionHost: false,
isMasterView: false,
}),
).toMatchObject({
canMove: false,
canResize: false,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
});
});
it("treats identity transforms left behind by animation libraries as movable", () => {
expect(
resolveDomEditCapabilities({
selector: "#card",
inlineStyles: {
left: "120px",
top: "80px",
width: "240px",
height: "140px",
},
computedStyles: {
position: "absolute",
left: "120px",
top: "80px",
width: "240px",
height: "140px",
transform: "matrix(1, 0, 0, 1, 0, 0)",
},
isCompositionHost: false,
isMasterView: false,
}),
).toMatchObject({
canMove: true,
canResize: true,
canApplyManualOffset: true,
});
});
it("treats identity matrix3d transforms as movable", () => {
expect(
resolveDomEditCapabilities({
selector: "#card",
inlineStyles: {
left: "120px",
top: "80px",
width: "240px",
height: "140px",
},
computedStyles: {
position: "absolute",
left: "120px",
top: "80px",
width: "240px",
height: "140px",
transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
},
isCompositionHost: false,
isMasterView: false,
}),
).toMatchObject({
canMove: true,
canResize: true,
});
});
it("allows imported absolute media to resize from computed px geometry", () => {
expect(
resolveDomEditCapabilities({
selector: "#photo",
inlineStyles: {
inset: "0",
width: "100%",
height: "100%",
},
computedStyles: {
position: "absolute",
left: "0px",
top: "0px",
width: "330px",
height: "228px",
transform: "none",
},
isCompositionHost: false,
isMasterView: false,
}),
).toMatchObject({
canMove: true,
canResize: true,
});
});
});
describe("resolveDomEditSelection", () => {
it("keeps composition host transforms disabled in master view", () => {
expect(
resolveDomEditCapabilities({
selector: "#detail-host",
inlineStyles: {
left: "80px",
top: "60px",
width: "320px",
height: "220px",
},
computedStyles: {
position: "absolute",
left: "80px",
top: "60px",
width: "320px",
height: "220px",
transform: "none",
},
isCompositionHost: true,
isMasterView: true,
}),
).toEqual({
canSelect: true,
canEditStyles: false,
canMove: true,
canResize: true,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "Select an internal layer to transform it.",
});
});
it("resolves child clicks inside a composition host to the child in master view", () => {
const document = createDocument(`
<div data-composition-id="main">
<div
id="detail-host"
class="clip"
data-composition-id="detail-card"
data-composition-file="compositions/detail-card.html"
>
<span id="inner-copy">Nested scene</span>
</div>
</div>
`);
const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: true,
});
expect(selection?.id).toBe("inner-copy");
expect(selection?.sourceFile).toBe("compositions/detail-card.html");
expect(selection?.isCompositionHost).toBe(false);
expect(selection?.capabilities.canApplyManualOffset).toBe(true);
expect(selection?.capabilities.canEditStyles).toBe(true);
});
it("does not prefer a scene host clip ancestor when selecting inside it", () => {
const document = createDocument(`
<div data-composition-id="main">
<div
id="detail-host"
class="clip"
data-composition-id="detail-card"
data-composition-file="compositions/detail-card.html"
>
<span id="inner-copy">Nested scene</span>
</div>
</div>
`);
const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: true,
preferClipAncestor: true,
});
expect(selection?.id).toBe("inner-copy");
expect(selection?.sourceFile).toBe("compositions/detail-card.html");
expect(selection?.isCompositionHost).toBe(false);
});
it("still prefers an internal clip ancestor inside a scene", () => {
const document = createDocument(`
<div data-composition-id="main">
<div
id="detail-host"
class="clip"
data-composition-id="detail-card"
data-composition-file="compositions/detail-card.html"
>
<section id="nested-card" class="clip">
<span id="inner-copy">Nested scene</span>
</section>
</div>
</div>
`);
const child = document.getElementById("inner-copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: true,
preferClipAncestor: true,
});
expect(selection?.id).toBe("nested-card");
expect(selection?.sourceFile).toBe("compositions/detail-card.html");
expect(selection?.isCompositionHost).toBe(false);
});
it("scopes class selector indexing to the same source file", () => {
const document = createDocument(`
<div data-composition-id="main">
<div class="chip">Root chip</div>
<div data-composition-id="nested" data-composition-file="compositions/nested.html">
<div class="chip">Nested chip</div>
</div>
</div>
`);
const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement;
const selection = resolveDomEditSelection(rootChip, {
activeCompositionPath: null,
isMasterView: true,
});
expect(selection?.sourceFile).toBe("index.html");
expect(selection?.selector).toBe(".chip");
expect(selection?.selectorIndex).toBe(0);
expect(findElementForSelection(document, selection!, null)).toBe(rootChip);
});
it("resolves nested duplicate ids from master view without treating root as the nested source", () => {
const document = createDocument(`
<div data-composition-id="main">
<div id="card">Root card</div>
<div data-composition-id="nested" data-composition-file="scenes/nested.html">
<div id="card">Nested card</div>
</div>
</div>
`);
const nestedCard = document.querySelector(
'[data-composition-file="scenes/nested.html"] #card',
) as HTMLElement;
const selection = resolveDomEditSelection(nestedCard, {
activeCompositionPath: null,
isMasterView: true,
});
expect(selection?.sourceFile).toBe("scenes/nested.html");
expect(findElementForSelection(document, selection!, null)).toBe(nestedCard);
});
it("prefers the nearest clip ancestor on single-click style selection", () => {
const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<p id="copy">Hello</p>
</section>
`);
const child = document.getElementById("copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: false,
preferClipAncestor: true,
});
expect(selection?.id).toBe("card");
expect(selection?.selector).toBe("#card");
});
it("can resolve the exact child when clip-ancestor preference is disabled", () => {
const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<p id="copy">Hello</p>
</section>
`);
const child = document.getElementById("copy") as HTMLElement;
const selection = resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: false,
preferClipAncestor: false,
});
expect(selection?.id).toBe("copy");
expect(selection?.selector).toBe("#copy");
});
it("collects simple child text blocks as separate editable fields", () => {
const document = createDocument(`
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
<strong>Headline</strong>
<span>Supporting copy</span>
</section>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]);
expect(selection?.textFields.map((field) => field.value)).toEqual([
"Headline",
"Supporting copy",
]);
});
it("preserves user-entered text spacing in editable text fields", () => {
const document = createDocument(`
<section id="card" class="clip" style="position: absolute;">
<strong>Headline with trailing space </strong>
</section>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
expect(selection?.textFields[0]?.value).toBe("Headline with trailing space ");
});
it("keeps an emptied text layer editable so users can type into it again", () => {
const document = createDocument(`
<div id="card" class="clip" style="position: absolute;"></div>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
expect(selection?.textFields).toMatchObject([
{
key: "self:0:div",
label: "Content",
value: "",
source: "self",
},
]);
expect(selection ? isTextEditableSelection(selection) : false).toBe(true);
});
it("keeps emptied child text layers editable after their content is cleared", () => {
const document = createDocument(`
<div id="card" class="clip" style="position: absolute;">
<strong></strong>
<span></span>
</div>
`);
const selection = resolveDomEditSelection(document.getElementById("card") as HTMLElement, {
activeCompositionPath: null,
isMasterView: false,
});
expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]);
expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]);
});
it("explains anonymous child elements that resolve to an editable parent", () => {
const document = createDocument(`
<div data-composition-id="main">
<div id="card">
<strong>Headline</strong>
</div>
</div>
`);
const child = document.querySelector("strong") as HTMLElement;
const selection = resolveDomEditSelection(child, {
activeCompositionPath: null,
isMasterView: false,
preferClipAncestor: false,
});
expect(selection?.id).toBe("card");
expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card");
});
it("does not mark an element as non-editable when Studio can edit it directly", () => {
const document = createDocument(`
<div data-composition-id="main">
<div id="card">Editable</div>
</div>
`);
const element = document.getElementById("card") as HTMLElement;
const selection = resolveDomEditSelection(element, {
activeCompositionPath: null,
isMasterView: false,
});
expect(getDomEditNonEditableReason(element, selection)).toBeNull();
});
it("keeps duplicate class targets distinct for history keys", () => {
const first = getDomEditTargetKey({
sourceFile: "index.html",
selector: ".card",
selectorIndex: 0,
});
const second = getDomEditTargetKey({
sourceFile: "index.html",
selector: ".card",
selectorIndex: 1,
});
expect(first).not.toBe(second);
});
});
describe("patch builders and prompt builder", () => {
it("builds style patch operations", () => {
expect(buildDomEditStylePatchOperation("background-color", "rgb(15, 23, 42)")).toEqual({
type: "inline-style",
property: "background-color",
value: "rgb(15, 23, 42)",
});
});
it("builds an agent prompt with source and selector context", () => {
const selection = {
element: {} as HTMLElement,
id: "editable-card",
selector: "#editable-card",
selectorIndex: undefined,
sourceFile: "index.html",
compositionPath: "index.html",
compositionSrc: undefined,
isCompositionHost: false,
label: "Drag me first",
tagName: "div",
boundingBox: { x: 108, y: 112, width: 380, height: 196 },
textContent: "Drag me first",
dataAttributes: {},
inlineStyles: {
left: "108px",
top: "112px",
width: "380px",
height: "196px",
},
computedStyles: {
position: "absolute",
left: "108px",
top: "112px",
width: "380px",
height: "196px",
color: "rgb(248, 250, 252)",
},
textFields: [
{
key: "self:0:div",
label: "Content",
value: "Drag me first",
tagName: "div",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "self",
},
],
capabilities: {
canSelect: true,
canEditStyles: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
} satisfies DomEditSelection;
const prompt = buildElementAgentPrompt({
selection,
currentTime: 1.25,
tagSnippet: `<div id="editable-card" style="position:absolute; left: 108px; top: 112px; width: 380px; height: 196px; color: rgb(248, 250, 252)"`,
});
expect(prompt).toContain("## HyperFrames element edit request v1");
expect(prompt).toContain("Schema version: 1");
expect(prompt).toContain("Source file: index.html");
expect(prompt).toContain("Selector: #editable-card");
expect(prompt).toContain("Playback time:");
expect(prompt).toContain("Text fields:");
expect(prompt).toContain('key=self:0:div; tag=<div>; source=self; text="Drag me first"');
expect(prompt).toContain("Inline styles:");
expect(prompt).toContain("Computed styles (browser-resolved):");
expect(prompt).toContain("Target HTML:");
expect(prompt).toContain("Guardrails:");
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
});
it("uses an absolute source path in copied agent prompts when provided", () => {
const selection = {
element: {} as HTMLElement,
id: "editable-card",
selector: "#editable-card",
selectorIndex: undefined,
sourceFile: "index.html",
compositionPath: "index.html",
compositionSrc: undefined,
isCompositionHost: false,
label: "Drag me first",
tagName: "div",
boundingBox: { x: 108, y: 112, width: 380, height: 196 },
textContent: "Drag me first",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
} satisfies DomEditSelection;
const prompt = buildElementAgentPrompt({
selection,
currentTime: 1.25,
sourceFilePath: "/tmp/hf-studio-project/index.html",
});
expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
expect(prompt).not.toContain("Source file: index.html");
});
it("serializes child text fields back into HTML", () => {
expect(
serializeDomEditTextFields([
{
key: "child:0:strong",
label: "Text 1",
value: "Headline <1>",
tagName: "strong",
attributes: [],
inlineStyles: {
"font-size": "22px",
},
computedStyles: {},
source: "child",
},
{
key: "child:1:span",
label: "Text 2",
value: "Details & more",
tagName: "span",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "child",
},
]),
).toBe(
'<strong data-hf-text-key="child:0:strong" style="font-size: 22px">Headline &lt;1&gt;</strong><span data-hf-text-key="child:1:span">Details &amp; more</span>',
);
});
});
@@ -1,733 +0,0 @@
import { formatTime } from "../../player/lib/time";
import type { PatchOperation, PatchTarget } from "../../utils/sourcePatcher";
const CURATED_STYLE_PROPERTIES = [
"position",
"display",
"top",
"left",
"right",
"bottom",
"inset",
"width",
"height",
"gap",
"justify-content",
"align-items",
"flex-direction",
"font-size",
"font-weight",
"font-family",
"color",
"background-color",
"background-image",
"opacity",
"mix-blend-mode",
"border-radius",
"border-color",
"outline-color",
"overflow",
"box-shadow",
"z-index",
"transform",
] as const;
export interface DomEditCapabilities {
canSelect: boolean;
canEditStyles: boolean;
/** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */
canMove: boolean;
/** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */
canResize: boolean;
canApplyManualOffset: boolean;
canApplyManualSize: boolean;
canApplyManualRotation: boolean;
reasonIfDisabled?: string;
}
export interface DomEditTextField {
key: string;
label: string;
value: string;
tagName: string;
attributes: Array<{ name: string; value: string }>;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
source: "self" | "child";
}
export interface DomEditSelection extends PatchTarget {
element: HTMLElement;
label: string;
tagName: string;
sourceFile: string;
compositionPath: string;
compositionSrc?: string;
isCompositionHost: boolean;
boundingBox: { x: number; y: number; width: number; height: number };
textContent: string | null;
dataAttributes: Record<string, string>;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
textFields: DomEditTextField[];
capabilities: DomEditCapabilities;
}
export interface DomEditContextOptions {
activeCompositionPath: string | null;
isMasterView: boolean;
preferClipAncestor?: boolean;
}
function isHtmlElement(value: unknown): value is HTMLElement {
return (
typeof value === "object" &&
value !== null &&
"nodeType" in value &&
typeof (value as { nodeType?: unknown }).nodeType === "number" &&
(value as { nodeType: number }).nodeType === 1
);
}
function parsePx(value: string | undefined): number | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed.endsWith("px")) return null;
const parsed = parseFloat(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function isIdentityTransform(value: string | undefined): boolean {
const transform = (value ?? "none").trim();
if (!transform || transform === "none") return true;
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
if (matrix) {
const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
return (
Math.abs(values[0] - 1) < 0.0001 &&
Math.abs(values[1]) < 0.0001 &&
Math.abs(values[2]) < 0.0001 &&
Math.abs(values[3] - 1) < 0.0001 &&
Math.abs(values[4]) < 0.0001 &&
Math.abs(values[5]) < 0.0001
);
}
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
if (!matrix3d) return false;
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
}
function isTextBearingTag(tagName: string): boolean {
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
}
function getCuratedComputedStyles(el: HTMLElement): Record<string, string> {
const styles: Record<string, string> = {};
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
if (!computed) return styles;
for (const prop of CURATED_STYLE_PROPERTIES) {
const value = computed.getPropertyValue(prop);
if (value) styles[prop] = value;
}
return styles;
}
function findClosestByAttribute(el: HTMLElement, attributeNames: string[]): HTMLElement | null {
let current: HTMLElement | null = el;
while (current) {
const candidate = current;
if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) {
return candidate;
}
current = current.parentElement;
}
return null;
}
function getSourceFileForElement(
el: HTMLElement,
activeCompositionPath: string | null,
): { sourceFile: string; compositionPath: string } {
const sourceHost = findClosestByAttribute(el, ["data-composition-file", "data-composition-src"]);
const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]);
const sourceFile =
sourceHost?.getAttribute("data-composition-file") ??
sourceHost?.getAttribute("data-composition-src") ??
ownerRoot?.getAttribute("data-composition-file") ??
ownerRoot?.getAttribute("data-composition-src") ??
activeCompositionPath ??
"index.html";
return {
sourceFile,
compositionPath: sourceFile,
};
}
function getPreferredClipAncestor(startEl: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = startEl;
while (current) {
if (current.classList.contains("clip")) {
const isCompositionHost =
current.hasAttribute("data-composition-src") ||
current.hasAttribute("data-composition-file");
if (!isCompositionHost || current === startEl) return current;
}
current = current.parentElement;
}
return null;
}
function getSelectionCandidate(startEl: HTMLElement, options: DomEditContextOptions): HTMLElement {
if (options.preferClipAncestor) {
const clipAncestor = getPreferredClipAncestor(startEl);
if (clipAncestor) {
return clipAncestor;
}
}
return startEl;
}
function getPreferredClassSelector(el: HTMLElement): string | undefined {
const classes = el.className
.split(/\s+/)
.map((value) => value.trim())
.filter(Boolean);
if (classes.length === 0) return undefined;
const preferred =
classes.find((value) => value !== "clip" && !value.startsWith("__hf-")) ?? classes[0];
return preferred ? `.${preferred}` : undefined;
}
function humanizeIdentifier(value: string): string {
return (
value
.replace(/\.html$/i, "")
.replace(/^compositions\//i, "")
.split("/")
.at(-1)
?.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase()) ?? value
);
}
function buildStableSelector(el: HTMLElement): string | undefined {
if (el.id) return `#${el.id}`;
const compositionId = el.getAttribute("data-composition-id");
if (compositionId) return `[data-composition-id="${compositionId}"]`;
return getPreferredClassSelector(el);
}
function getSelectorIndex(
doc: Document,
el: HTMLElement,
selector: string | undefined,
sourceFile: string,
activeCompositionPath: string | null,
): number | undefined {
if (!selector?.startsWith(".")) return undefined;
const candidates = Array.from(doc.querySelectorAll(selector)).filter(
(candidate): candidate is HTMLElement =>
isHtmlElement(candidate) &&
getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
);
const index = candidates.indexOf(el);
return index >= 0 ? index : undefined;
}
function buildElementLabel(el: HTMLElement): string {
const compositionId = el.getAttribute("data-composition-id");
if (compositionId && compositionId !== "main") {
return humanizeIdentifier(compositionId);
}
const compositionSrc =
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
if (compositionSrc) {
return humanizeIdentifier(compositionSrc);
}
if (el.id) return humanizeIdentifier(el.id);
const preferredClass = getPreferredClassSelector(el);
if (preferredClass) {
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
}
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
if (text) return text.length > 40 ? `${text.slice(0, 39)}` : text;
return el.tagName.toLowerCase();
}
function getDataAttributes(el: HTMLElement): Record<string, string> {
const attrs: Record<string, string> = {};
for (const attr of el.attributes) {
if (attr.name.startsWith("data-")) {
attrs[attr.name.slice(5)] = attr.value;
}
}
return attrs;
}
function getInlineStyles(el: HTMLElement): Record<string, string> {
const styles: Record<string, string> = {};
for (const property of CURATED_STYLE_PROPERTIES) {
const value = el.style.getPropertyValue(property);
if (value) styles[property] = value;
}
return styles;
}
function isEditableTextLeaf(el: HTMLElement): boolean {
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
}
function getTextFieldLabel(
_tagName: string,
index: number,
total: number,
source: "self" | "child",
): string {
if (source === "self" || total === 1) return "Content";
return `Text ${index + 1}`;
}
function buildTextField(
el: HTMLElement,
index: number,
total: number,
source: "self" | "child",
): DomEditTextField {
const tagName = el.tagName.toLowerCase();
const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
return {
key,
label: getTextFieldLabel(tagName, index, total, source),
value: el.textContent ?? "",
tagName,
attributes: Array.from(el.attributes)
.filter((attribute) => attribute.name !== "style")
.map((attribute) => ({
name: attribute.name,
value: attribute.value,
})),
inlineStyles: getInlineStyles(el),
computedStyles: getCuratedComputedStyles(el),
source,
};
}
function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf);
if (childFields.length > 0) {
return childFields.map((child, index) =>
buildTextField(child, index, childFields.length, "child"),
);
}
if (isEditableTextLeaf(el)) {
return [buildTextField(el, 0, 1, "self")];
}
return [];
}
function escapeHtmlText(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function serializeTextFieldStyle(field: DomEditTextField): string {
const entries = Object.entries(field.inlineStyles).filter(([, value]) => Boolean(value));
if (entries.length === 0) return "";
return entries.map(([key, value]) => `${key}: ${value}`).join("; ");
}
export function serializeDomEditTextFields(fields: DomEditTextField[]): string {
return fields
.filter((field) => field.source === "child")
.map((field) => {
const attrs = [
...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"),
{ name: "data-hf-text-key", value: field.key },
]
.map((attribute) => ` ${attribute.name}="${attribute.value.replace(/"/g, "&quot;")}"`)
.join("");
const style = serializeTextFieldStyle(field);
const styleAttr = style ? ` style="${style.replace(/"/g, "&quot;")}"` : "";
return `<${field.tagName}${attrs}${styleAttr}>${escapeHtmlText(field.value)}</${field.tagName}>`;
})
.join("");
}
export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>): DomEditTextField {
return {
key: `child:new:${Date.now()}`,
label: "Text",
value: "New text",
tagName: "span",
attributes: [],
inlineStyles: {
"font-family": base?.computedStyles?.["font-family"] ?? "inherit",
"font-size": base?.computedStyles?.["font-size"] ?? "16px",
"font-weight": base?.computedStyles?.["font-weight"] ?? "400",
color: base?.computedStyles?.color ?? "inherit",
},
computedStyles: {},
source: "child",
};
}
export function resolveDomEditCapabilities(args: {
selector?: string;
tagName?: string;
className?: string;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
isCompositionHost: boolean;
isMasterView: boolean;
}): DomEditCapabilities {
if (!args.selector) {
return {
canSelect: false,
canEditStyles: false,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
reasonIfDisabled: "Studio could not resolve a stable patch target for this element.",
};
}
const position = args.computedStyles.position;
const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
const canMove =
(position === "absolute" || position === "fixed") &&
left != null &&
top != null &&
!hasTransformDrivenGeometry;
const canResize = canMove && (width != null || height != null);
const canApplyManualGeometry = !args.isCompositionHost;
const canApplyManualOffset = canApplyManualGeometry;
const canApplyManualSize = canApplyManualGeometry;
const canApplyManualRotation = canApplyManualGeometry;
const reasonIfDisabled = canApplyManualGeometry
? undefined
: "Select an internal layer to transform it.";
if (args.isCompositionHost && args.isMasterView) {
return {
canSelect: true,
canEditStyles: false,
canMove,
canResize,
canApplyManualOffset,
canApplyManualSize,
canApplyManualRotation,
reasonIfDisabled,
};
}
return {
canSelect: true,
canEditStyles: true,
canMove,
canResize,
canApplyManualOffset,
canApplyManualSize,
canApplyManualRotation,
reasonIfDisabled,
};
}
export function resolveDomEditSelection(
startEl: HTMLElement | null,
options: DomEditContextOptions,
): DomEditSelection | null {
if (!startEl) return null;
const doc = startEl.ownerDocument;
let current: HTMLElement | null = getSelectionCandidate(startEl, options);
while (current && current !== doc.body && current !== doc.documentElement) {
const selector = buildStableSelector(current);
if (!selector) {
current = current.parentElement;
continue;
}
const { sourceFile, compositionPath } = getSourceFileForElement(
current,
options.activeCompositionPath,
);
const selectorIndex = getSelectorIndex(
doc,
current,
selector,
sourceFile,
options.activeCompositionPath,
);
const compositionSrc =
current.getAttribute("data-composition-src") ??
current.getAttribute("data-composition-file") ??
undefined;
const inlineStyles = getInlineStyles(current);
const computedStyles = getCuratedComputedStyles(current);
const textFields = collectDomEditTextFields(current);
const capabilities = resolveDomEditCapabilities({
selector,
tagName: current.tagName.toLowerCase(),
className: current.className,
inlineStyles,
computedStyles,
isCompositionHost: Boolean(compositionSrc),
isMasterView: options.isMasterView,
});
const rect = current.getBoundingClientRect();
return {
element: current,
id: current.id || undefined,
selector,
selectorIndex,
sourceFile,
compositionPath,
compositionSrc,
isCompositionHost: Boolean(compositionSrc),
label: buildElementLabel(current),
tagName: current.tagName.toLowerCase(),
boundingBox: {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
},
textContent: current.textContent?.trim() || null,
dataAttributes: getDataAttributes(current),
inlineStyles,
computedStyles,
textFields,
capabilities,
};
}
return null;
}
export function refreshDomEditSelection(
selection: DomEditSelection,
activeCompositionPath: string | null,
): DomEditSelection | null {
const doc = selection.element.ownerDocument;
const nextElement = findElementForSelection(doc, selection, activeCompositionPath);
return nextElement
? resolveDomEditSelection(nextElement, {
activeCompositionPath,
isMasterView: !activeCompositionPath || activeCompositionPath === "index.html",
})
: null;
}
export function getDomEditTargetKey(
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
): string {
return [
selection.sourceFile || "index.html",
selection.id ?? "",
selection.selector ?? "",
selection.selectorIndex ?? "",
].join("|");
}
function hasSupportedDirectEdit(capabilities: DomEditCapabilities): boolean {
return (
capabilities.canEditStyles ||
capabilities.canMove ||
capabilities.canResize ||
capabilities.canApplyManualOffset ||
capabilities.canApplyManualSize ||
capabilities.canApplyManualRotation
);
}
export function getDomEditNonEditableReason(
element: HTMLElement,
selection: DomEditSelection | null,
): string | null {
if (!selection) {
return "No stable source target";
}
if (selection.element !== element) {
return selection.isCompositionHost
? "Nested composition boundary"
: `Selection resolves to ${selection.label}`;
}
if (!hasSupportedDirectEdit(selection.capabilities)) {
return selection.capabilities.reasonIfDisabled ?? "No supported direct edits";
}
return null;
}
export function findElementForSelection(
doc: Document,
selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
activeCompositionPath: string | null = null,
): HTMLElement | null {
if (selection.id) {
const byId = doc.getElementById(selection.id);
if (
isHtmlElement(byId) &&
(!selection.sourceFile ||
getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
) {
return byId;
}
}
if (!selection.selector) return null;
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
const matches = Array.from(doc.querySelectorAll(selection.selector)).filter(
(candidate): candidate is HTMLElement =>
isHtmlElement(candidate) &&
(!selection.sourceFile ||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
selection.sourceFile),
);
return matches[selection.selectorIndex] ?? null;
}
const matches = Array.from(doc.querySelectorAll(selection.selector)).filter(
(candidate): candidate is HTMLElement =>
isHtmlElement(candidate) &&
(!selection.sourceFile ||
getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
selection.sourceFile),
);
return matches[0] ?? null;
}
export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
return {
type: "inline-style",
property,
value,
};
}
export function buildDomEditTextPatchOperation(value: string): PatchOperation {
return {
type: "text-content",
property: "text",
value,
};
}
function formatBoundingBox(bounds: DomEditSelection["boundingBox"]): string {
return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`;
}
function formatStyleBlock(styles: Record<string, string>): string {
return Object.entries(styles)
.filter(([, value]) => value && value !== "initial")
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
}
function formatTextFields(fields: DomEditTextField[]): string {
return fields
.map(
(field) =>
`- key=${field.key}; tag=<${field.tagName}>; source=${field.source}; text=${JSON.stringify(field.value)}`,
)
.join("\n");
}
export function buildElementAgentPrompt({
selection,
currentTime,
tagSnippet,
userInstruction,
sourceFilePath,
}: {
selection: DomEditSelection;
currentTime: number;
tagSnippet?: string;
userInstruction?: string;
sourceFilePath?: string;
}): string {
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
const lines = [
"## HyperFrames element edit request v1",
"Schema version: 1",
"",
userInstruction?.trim() || "Edit this selected HyperFrames element.",
"",
`Composition: ${selection.compositionPath}`,
`Playback time: ${formatTime(currentTime)}`,
`Source file: ${displayedSourceFile}`,
`DOM id: ${selection.id ?? "(none)"}`,
`Selector: ${selection.selector ?? "(none)"}`,
`Selector index: ${selection.selectorIndex ?? 0}`,
`Tag: <${selection.tagName}>`,
`Bounds: ${formatBoundingBox(selection.boundingBox)}`,
];
if (selection.textContent) {
lines.push(`Text: ${selection.textContent}`);
}
const textFieldsBlock = formatTextFields(selection.textFields);
if (textFieldsBlock) {
lines.push("", "Text fields:", textFieldsBlock);
}
const inlineStyleBlock = formatStyleBlock(selection.inlineStyles);
if (inlineStyleBlock) {
lines.push("", "Inline styles:", inlineStyleBlock);
}
const computedStyleBlock = formatStyleBlock(selection.computedStyles);
if (computedStyleBlock) {
lines.push("", "Computed styles (browser-resolved):", computedStyleBlock);
}
if (tagSnippet) {
lines.push("", "Target HTML:", tagSnippet);
}
lines.push(
"",
"Guardrails:",
"- Make a targeted change to this element only.",
"- Preserve the rest of the composition and its timing.",
"- Do not modify other elements' data-* attributes or positioning.",
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
);
return lines.join("\n");
}
export function isTextEditableSelection(selection: DomEditSelection): boolean {
return selection.textFields.length > 0 && !selection.isCompositionHost;
}
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveFloatingPanelPosition } from "./floatingPanel";
describe("resolveFloatingPanelPosition", () => {
it("places the panel below the anchor when there is space", () => {
expect(
resolveFloatingPanelPosition(
{ left: 100, top: 100, right: 220, bottom: 140, width: 120, height: 40 },
{ width: 800, height: 600 },
{ width: 280, height: 220 },
),
).toMatchObject({ top: 148, placement: "bottom" });
});
it("places the panel above the anchor when the bottom would be clipped", () => {
expect(
resolveFloatingPanelPosition(
{ left: 100, top: 500, right: 220, bottom: 540, width: 120, height: 40 },
{ width: 800, height: 600 },
{ width: 280, height: 220 },
),
).toMatchObject({ top: 272, placement: "top" });
});
it("clamps the panel horizontally inside the viewport", () => {
expect(
resolveFloatingPanelPosition(
{ left: 760, top: 100, right: 800, bottom: 140, width: 40, height: 40 },
{ width: 800, height: 600 },
{ width: 280, height: 220 },
).left,
).toBe(508);
});
});
@@ -1,54 +0,0 @@
export interface FloatingRect {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
}
export interface FloatingSize {
width: number;
height: number;
}
export interface FloatingPosition {
left: number;
top: number;
placement: "top" | "bottom";
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function resolveFloatingPanelPosition(
anchor: FloatingRect,
viewport: FloatingSize,
panel: FloatingSize,
options?: { offset?: number; margin?: number },
): FloatingPosition {
const offset = options?.offset ?? 8;
const margin = options?.margin ?? 12;
const maxLeft = Math.max(margin, viewport.width - panel.width - margin);
const preferredLeft = anchor.left + anchor.width / 2 - panel.width / 2;
const left = clamp(preferredLeft, margin, maxLeft);
const belowTop = anchor.bottom + offset;
const aboveTop = anchor.top - panel.height - offset;
const fitsBelow = belowTop + panel.height <= viewport.height - margin;
const fitsAbove = aboveTop >= margin;
if (fitsBelow || !fitsAbove) {
return {
left,
top: clamp(belowTop, margin, Math.max(margin, viewport.height - panel.height - margin)),
placement: "bottom",
};
}
return {
left,
top: clamp(aboveTop, margin, Math.max(margin, viewport.height - panel.height - margin)),
placement: "top",
};
}
@@ -1,32 +0,0 @@
export interface ImportedFontAsset {
family: string;
path: string;
url: string;
}
const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i;
const FONT_STYLE_SUFFIX_RE =
/\s+(thin|extralight|extra light|light|regular|roman|medium|semibold|semi bold|bold|extrabold|extra bold|black|italic|oblique|variable)$/i;
export function cssString(value: string): string {
return JSON.stringify(value);
}
export function fontFamilyFromAssetPath(path: string): string {
const fileName = decodeURIComponent(path.split(/[\\/]/).pop() ?? path).replace(FONT_EXT_RE, "");
let family = fileName
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
while (FONT_STYLE_SUFFIX_RE.test(family)) {
family = family.replace(FONT_STYLE_SUFFIX_RE, "").trim();
}
return family || fileName;
}
export function importedFontFaceCss(asset: ImportedFontAsset, url: string = asset.url): string {
return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)}); font-display: swap; }`;
}
@@ -1,126 +0,0 @@
export const POPULAR_GOOGLE_FONT_FAMILIES = [
"ABeeZee",
"Abel",
"Abril Fatface",
"Alegreya",
"Alegreya Sans",
"Anton",
"Archivo",
"Archivo Black",
"Arimo",
"Assistant",
"Barlow",
"Barlow Condensed",
"Bebas Neue",
"Bitter",
"Bricolage Grotesque",
"Cabin",
"Cardo",
"Catamaran",
"Caveat",
"Chivo",
"Cormorant Garamond",
"Crimson Text",
"Dancing Script",
"DM Sans",
"DM Serif Display",
"Domine",
"EB Garamond",
"Exo 2",
"Figtree",
"Fira Code",
"Fira Sans",
"Fraunces",
"Fredoka",
"IBM Plex Mono",
"IBM Plex Sans",
"IBM Plex Serif",
"Inconsolata",
"Instrument Sans",
"Instrument Serif",
"Inter",
"JetBrains Mono",
"Josefin Sans",
"Jost",
"Kanit",
"Karla",
"Lato",
"League Gothic",
"Lexend",
"Libre Baskerville",
"Libre Franklin",
"Lora",
"Manrope",
"Merriweather",
"Montserrat",
"Mukta",
"Mulish",
"Newsreader",
"Noto Sans",
"Noto Sans JP",
"Noto Serif",
"Nunito",
"Nunito Sans",
"Open Sans",
"Oswald",
"Outfit",
"Overpass",
"Pacifico",
"Pathway Extreme",
"Permanent Marker",
"Playfair Display",
"Plus Jakarta Sans",
"Poppins",
"Prata",
"PT Sans",
"PT Serif",
"Public Sans",
"Quicksand",
"Raleway",
"Red Hat Display",
"Roboto",
"Roboto Condensed",
"Roboto Mono",
"Roboto Serif",
"Rubik",
"Schibsted Grotesk",
"Signika",
"Source Code Pro",
"Source Sans 3",
"Source Serif 4",
"Space Grotesk",
"Space Mono",
"Spectral",
"Sora",
"Syne",
"Teko",
"Titillium Web",
"Ubuntu",
"Ubuntu Mono",
"Unbounded",
"Urbanist",
"Varela Round",
"Work Sans",
"Young Serif",
"Zilla Slab",
] as const;
export const COMMON_LOCAL_FONT_FAMILIES = [
"TT Norms Pro",
"SF Pro Display",
"SF Pro Text",
"Avenir",
"Avenir Next",
"Helvetica Neue",
"Arial",
"Georgia",
"Times New Roman",
"Menlo",
"Monaco",
"Courier New",
] as const;
export function googleFontStylesheetUrl(family: string): string {
const encodedFamily = encodeURIComponent(family.trim()).replace(/%20/g, "+");
return `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@300;400;500;600;700;800;900&display=swap`;
}
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultGradientModel,
insertGradientStop,
parseGradient,
serializeGradient,
} from "./gradientValue";
describe("parseGradient", () => {
it("parses linear gradients", () => {
expect(
parseGradient("linear-gradient(135deg, rgba(15, 23, 42, 0.58), rgba(255, 255, 255, 0.04))"),
).toMatchObject({
kind: "linear",
repeating: false,
angle: 135,
stops: [
{ color: "rgba(15, 23, 42, 0.58)", position: 0 },
{ color: "rgba(255, 255, 255, 0.04)", position: 100 },
],
});
});
it("parses radial gradients", () => {
expect(
parseGradient("radial-gradient(circle closest-side at 20% 35%, #ff0000 10%, #0000ff 90%)"),
).toMatchObject({
kind: "radial",
shape: "circle",
radialSize: "closest-side",
centerX: 20,
centerY: 35,
});
});
it("parses conic gradients", () => {
expect(
parseGradient("conic-gradient(from 45deg at 40% 60%, #111111 0%, #ffffff 100%)"),
).toMatchObject({
kind: "conic",
angle: 45,
centerX: 40,
centerY: 60,
});
});
it("parses repeating gradients", () => {
expect(
parseGradient("repeating-linear-gradient(90deg, #000000 0%, #ffffff 50%)"),
).toMatchObject({
kind: "linear",
repeating: true,
angle: 90,
});
});
});
describe("serializeGradient", () => {
it("serializes default gradient models", () => {
expect(serializeGradient(buildDefaultGradientModel("rgba(60, 230, 172, 0.18)"))).toBe(
"linear-gradient(135deg, rgba(60, 230, 172, 0.18) 0%, rgba(255, 255, 255, 0.04) 100%)",
);
});
it("round-trips parsed gradients", () => {
const parsed = parseGradient(
"repeating-conic-gradient(from 90deg at 25% 75%, rgba(0, 0, 0, 0.5) 0%, rgba(255, 255, 255, 0.1) 100%)",
);
expect(parsed).not.toBeNull();
expect(serializeGradient(parsed!)).toBe(
"repeating-conic-gradient(from 90deg at 25% 75%, rgba(0, 0, 0, 0.5) 0%, rgba(255, 255, 255, 0.1) 100%)",
);
});
});
describe("insertGradientStop", () => {
it("inserts a stop at the clicked position with an interpolated color", () => {
const parsed = parseGradient("linear-gradient(90deg, #000000 0%, #ffffff 100%)");
expect(parsed).not.toBeNull();
expect(insertGradientStop(parsed!, 25)).toMatchObject({
stops: [
{ color: "#000000", position: 0 },
{ color: "#404040", position: 25 },
{ color: "#ffffff", position: 100 },
],
});
});
});
@@ -1,445 +0,0 @@
export type GradientKind = "linear" | "radial" | "conic";
export type RadialSizeKeyword =
| "closest-side"
| "closest-corner"
| "farthest-side"
| "farthest-corner";
export interface GradientStop {
color: string;
position: number;
}
export interface GradientModel {
kind: GradientKind;
repeating: boolean;
angle: number;
centerX: number;
centerY: number;
shape: "circle" | "ellipse";
radialSize: RadialSizeKeyword;
stops: GradientStop[];
}
const RADIAL_SIZE_KEYWORDS: RadialSizeKeyword[] = [
"closest-side",
"closest-corner",
"farthest-side",
"farthest-corner",
];
function isWhitespace(char: string | undefined): boolean {
return char === " " || char === "\n" || char === "\r" || char === "\t" || char === "\f";
}
function isDigit(char: string | undefined): boolean {
return char != null && char >= "0" && char <= "9";
}
function isSimpleNumber(value: string): boolean {
if (!value) return false;
let index = value[0] === "-" ? 1 : 0;
let digits = 0;
while (isDigit(value[index])) {
index += 1;
digits += 1;
}
if (value[index] === ".") {
index += 1;
while (isDigit(value[index])) {
index += 1;
digits += 1;
}
}
return digits > 0 && index === value.length;
}
function parseCssNumber(value: string | undefined): number | null {
if (!value) return null;
const trimmed = value.trim();
if (!isSimpleNumber(trimmed)) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function splitCssWhitespace(value: string): string[] {
const tokens: string[] = [];
let current = "";
for (const char of value) {
if (isWhitespace(char)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) tokens.push(current);
return tokens;
}
function hasCssWord(value: string, word: string): boolean {
return splitCssWhitespace(value.toLowerCase()).includes(word);
}
function parsePercentToken(value: string | undefined, fallback: number): number {
if (!value?.endsWith("%")) return fallback;
const parsed = parseCssNumber(value.slice(0, -1));
return parsed == null ? fallback : clamp(parsed, 0, 100);
}
function parseAngleToken(value: string | undefined): number | null {
const trimmed = value?.trim().toLowerCase();
if (!trimmed?.endsWith("deg")) return null;
return parseCssNumber(trimmed.slice(0, -3));
}
function trailingPercentStart(value: string): number | null {
if (!value.endsWith("%")) return null;
const withoutUnit = value.slice(0, -1).trimEnd();
let start = withoutUnit.length;
while (start > 0 && (isDigit(withoutUnit[start - 1]) || withoutUnit[start - 1] === ".")) {
start -= 1;
}
if (start > 0 && withoutUnit[start - 1] === "-") {
start -= 1;
}
const token = withoutUnit.slice(start);
if (!isSimpleNumber(token)) return null;
if (start === 0 || !isWhitespace(withoutUnit[start - 1])) return null;
return start;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
function parsePercent(value: string | undefined, fallback: number): number {
const parsed = parseCssNumber(value);
return parsed == null ? fallback : clamp(parsed, 0, 100);
}
function parseColorStop(raw: string): { color: string; position: number | null } {
const trimmed = raw.trim();
const percentStart = trailingPercentStart(trimmed);
if (percentStart == null) return { color: trimmed, position: null };
const withoutUnit = trimmed.slice(0, -1).trimEnd();
return {
color: withoutUnit.slice(0, percentStart).trim(),
position: parsePercent(withoutUnit.slice(percentStart), 0),
};
}
function normalizeStops(stops: Array<{ color: string; position: number | null }>): GradientStop[] {
if (stops.length === 0) {
return [
{ color: "rgba(60, 230, 172, 0.18)", position: 0 },
{ color: "rgba(255, 255, 255, 0.04)", position: 100 },
];
}
if (stops.length === 1) {
return [
{ color: stops[0].color, position: 0 },
{ color: stops[0].color, position: 100 },
];
}
const result = stops.map((stop, index) => ({
color: stop.color,
position: stop.position ?? (index / (stops.length - 1)) * 100,
}));
return result.map((stop) => ({
color: stop.color,
position: round(clamp(stop.position, 0, 100)),
}));
}
function splitGradientArgs(value: string): string[] {
const parts: string[] = [];
let current = "";
let depth = 0;
for (const char of value) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (char === "," && depth === 0) {
if (current.trim()) parts.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) parts.push(current.trim());
return parts;
}
function directionToAngle(value: string): number | null {
const normalized = value.trim().toLowerCase();
const map: Record<string, number> = {
"to top": 0,
"to top right": 45,
"to right top": 45,
"to right": 90,
"to bottom right": 135,
"to right bottom": 135,
"to bottom": 180,
"to bottom left": 225,
"to left bottom": 225,
"to left": 270,
"to top left": 315,
"to left top": 315,
};
return normalized in map ? map[normalized] : null;
}
function parseLinearArgs(parts: string[]): GradientModel {
const first = parts[0] ?? "";
const angleFromDirection = directionToAngle(first);
const parsedAngle = parseAngleToken(first);
const firstIsAngle = parsedAngle != null;
const angle = parsedAngle ?? angleFromDirection ?? 180;
const stopParts = firstIsAngle || angleFromDirection != null ? parts.slice(1) : parts;
return {
kind: "linear",
repeating: false,
angle,
centerX: 50,
centerY: 50,
shape: "ellipse",
radialSize: "farthest-corner",
stops: normalizeStops(stopParts.map(parseColorStop)),
};
}
function parseRadialArgs(parts: string[]): GradientModel {
const first = parts[0] ?? "";
const firstLower = first.toLowerCase();
const hasConfig =
hasCssWord(firstLower, "at") ||
hasCssWord(firstLower, "circle") ||
hasCssWord(firstLower, "ellipse") ||
firstLower.includes("closest-") ||
firstLower.includes("farthest-");
const config = hasConfig ? first : "";
const stopParts = hasConfig ? parts.slice(1) : parts;
const configLower = config.toLowerCase();
const configTokens = splitCssWhitespace(configLower);
const atIndex = configTokens.indexOf("at");
const shape = hasCssWord(configLower, "circle") ? "circle" : "ellipse";
const radialSize =
RADIAL_SIZE_KEYWORDS.find((keyword) => configTokens.includes(keyword)) ?? "farthest-corner";
return {
kind: "radial",
repeating: false,
angle: 180,
centerX: parsePercentToken(configTokens[atIndex + 1], 50),
centerY: parsePercentToken(configTokens[atIndex + 2], 50),
shape,
radialSize,
stops: normalizeStops(stopParts.map(parseColorStop)),
};
}
function parseConicArgs(parts: string[]): GradientModel {
const first = parts[0] ?? "";
const firstLower = first.toLowerCase();
const hasConfig = hasCssWord(firstLower, "from") || hasCssWord(firstLower, "at");
const config = hasConfig ? first : "";
const stopParts = hasConfig ? parts.slice(1) : parts;
const configTokens = splitCssWhitespace(config.toLowerCase());
const fromIndex = configTokens.indexOf("from");
const atIndex = configTokens.indexOf("at");
const angle = parseAngleToken(configTokens[fromIndex + 1]);
return {
kind: "conic",
repeating: false,
angle: angle ?? 0,
centerX: parsePercentToken(configTokens[atIndex + 1], 50),
centerY: parsePercentToken(configTokens[atIndex + 2], 50),
shape: "ellipse",
radialSize: "farthest-corner",
stops: normalizeStops(stopParts.map(parseColorStop)),
};
}
export function buildDefaultGradientModel(fallbackColor?: string): GradientModel {
return {
kind: "linear",
repeating: false,
angle: 135,
centerX: 50,
centerY: 50,
shape: "ellipse",
radialSize: "farthest-corner",
stops: normalizeStops([
{
color:
fallbackColor && fallbackColor !== "transparent"
? fallbackColor
: "rgba(60, 230, 172, 0.18)",
position: 0,
},
{ color: "rgba(255, 255, 255, 0.04)", position: 100 },
]),
};
}
export function parseGradient(value: string | undefined): GradientModel | null {
if (!value || value === "none") return null;
const trimmed = value.trim();
const openParenIndex = trimmed.indexOf("(");
if (openParenIndex <= 0 || !trimmed.endsWith(")")) return null;
const functionName = trimmed.slice(0, openParenIndex).toLowerCase();
const kindByFunctionName: Record<string, { kind: GradientKind; repeating: boolean }> = {
"linear-gradient": { kind: "linear", repeating: false },
"radial-gradient": { kind: "radial", repeating: false },
"conic-gradient": { kind: "conic", repeating: false },
"repeating-linear-gradient": { kind: "linear", repeating: true },
"repeating-radial-gradient": { kind: "radial", repeating: true },
"repeating-conic-gradient": { kind: "conic", repeating: true },
};
const parsedFunction = kindByFunctionName[functionName];
if (!parsedFunction) return null;
const { kind, repeating } = parsedFunction;
const parts = splitGradientArgs(trimmed.slice(openParenIndex + 1, -1));
const parsed =
kind === "linear"
? parseLinearArgs(parts)
: kind === "radial"
? parseRadialArgs(parts)
: parseConicArgs(parts);
return { ...parsed, repeating };
}
function formatStop(stop: GradientStop): string {
return `${stop.color} ${round(stop.position)}%`;
}
export function serializeGradient(model: GradientModel): string {
const fn = `${model.repeating ? "repeating-" : ""}${model.kind}-gradient`;
const stops = model.stops.map(formatStop).join(", ");
if (model.kind === "linear") {
return `${fn}(${round(model.angle)}deg, ${stops})`;
}
if (model.kind === "radial") {
return `${fn}(${model.shape} ${model.radialSize} at ${round(model.centerX)}% ${round(
model.centerY,
)}%, ${stops})`;
}
return `${fn}(from ${round(model.angle)}deg at ${round(model.centerX)}% ${round(
model.centerY,
)}%, ${stops})`;
}
function blendChannel(start: number, end: number, ratio: number): number {
return Math.round(start + (end - start) * ratio);
}
function formatHex(channel: number): string {
return channel.toString(16).padStart(2, "0");
}
export function interpolateGradientStopColor(model: GradientModel, position: number): string {
const clampedPosition = clamp(position, 0, 100);
const sortedStops = [...model.stops].sort((a, b) => a.position - b.position);
const exact = sortedStops.find((stop) => Math.abs(stop.position - clampedPosition) < 0.001);
if (exact) return exact.color;
const right = sortedStops.find((stop) => stop.position > clampedPosition) ?? sortedStops.at(-1);
const left =
[...sortedStops].reverse().find((stop) => stop.position < clampedPosition) ?? sortedStops[0];
if (!left || !right) return sortedStops[0]?.color ?? "rgba(255, 255, 255, 1)";
if (left === right) return left.color;
const leftColor = left.color;
const rightColor = right.color;
const leftParsed = leftColor ? parseColorString(leftColor) : null;
const rightParsed = rightColor ? parseColorString(rightColor) : null;
if (!leftParsed || !rightParsed) return left.color;
const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position);
const red = blendChannel(leftParsed.red, rightParsed.red, ratio);
const green = blendChannel(leftParsed.green, rightParsed.green, ratio);
const blue = blendChannel(leftParsed.blue, rightParsed.blue, ratio);
const alpha = round(leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio);
if (alpha >= 1) {
return `#${formatHex(red)}${formatHex(green)}${formatHex(blue)}`.toUpperCase();
}
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
export function insertGradientStop(model: GradientModel, position: number): GradientModel {
const clampedPosition = round(clamp(position, 0, 100));
const color = interpolateGradientStopColor(model, clampedPosition);
const nextStops = [...model.stops, { color, position: clampedPosition }].sort(
(a, b) => a.position - b.position,
);
return {
...model,
stops: nextStops,
};
}
function parseColorString(
value: string,
): { red: number; green: number; blue: number; alpha: number } | null {
const trimmed = value.trim().toLowerCase();
if (trimmed === "transparent") {
return { red: 0, green: 0, blue: 0, alpha: 0 };
}
const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
if (hex) {
return {
red: Number.parseInt(hex[1].slice(0, 2), 16),
green: Number.parseInt(hex[1].slice(2, 4), 16),
blue: Number.parseInt(hex[1].slice(4, 6), 16),
alpha: 1,
};
}
const rgba = trimmed.match(
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
);
if (!rgba) return null;
return {
red: Number.parseFloat(rgba[1]),
green: Number.parseFloat(rgba[2]),
blue: Number.parseFloat(rgba[3]),
alpha: rgba[4] != null ? Number.parseFloat(rgba[4]) : 1,
};
}
@@ -1,945 +0,0 @@
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,
STUDIO_ROTATION_PROP,
STUDIO_WIDTH_PROP,
applyStudioBoxSize,
applyStudioBoxSizeDraft,
applyStudioManualEditManifest,
applyStudioPathOffset,
applyStudioPathOffsetDraft,
applyStudioRotation,
applyStudioRotationDraft,
beginStudioManualEditGesture,
captureStudioBoxSize,
captureStudioRotation,
emptyStudioManualEditManifest,
endStudioManualEditGesture,
installStudioManualEditSeekReapply,
isStudioManualEditManifestPath,
parseStudioManualEditManifest,
readStudioFileChangePath,
readStudioBoxSize,
readStudioPathOffset,
readStudioRotation,
removeStudioManualEditsForSelection,
restoreStudioBoxSize,
restoreStudioRotation,
serializeStudioManualEditManifest,
upsertStudioBoxSizeEdit,
upsertStudioPathOffsetEdit,
upsertStudioRotationEdit,
} from "./manualEdits";
function createDocument(markup: string): Document {
const window = new Window();
window.document.body.innerHTML = markup;
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 = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
}) as DOMRect;
}
function mockComputedStyle(element: HTMLElement, values: Record<string, string>): void {
const win = element.ownerDocument.defaultView;
if (!win) throw new Error("defaultView fixture missing");
win.getComputedStyle = ((target: Element) =>
({
getPropertyValue: (property: string) => (target === element ? (values[property] ?? "") : ""),
}) as CSSStyleDeclaration) as typeof win.getComputedStyle;
}
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", () => {
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", () => {
const document = createDocument(`<div id="card"></div>`);
const card = document.getElementById("card") as HTMLElement;
applyStudioPathOffset(card, { x: 14, y: -8 });
expect(readStudioPathOffset(card)).toEqual({ x: 14, y: -8 });
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("14px");
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("-8px");
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
});
it("preserves authored inline translate as the additive path offset base", () => {
const document = createDocument(`<div id="card" style="translate: 10px 20px"></div>`);
const card = document.getElementById("card") as HTMLElement;
applyStudioPathOffset(card, { x: 14, y: -8 });
expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_Y_PROP);
});
it("preserves stylesheet-authored transform longhands as additive bases", () => {
const document = createDocument(`<div id="card"></div>`);
const card = document.getElementById("card") as HTMLElement;
mockComputedStyle(card, {
translate: "10px 20px",
rotate: "8deg",
});
applyStudioPathOffset(card, { x: 14, y: -8 });
applyStudioRotation(card, { angle: 12 });
expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
});
it("clears computed transform bases without freezing them inline", () => {
const document = createDocument(`<div id="card"></div>`);
const card = document.getElementById("card") as HTMLElement;
mockComputedStyle(card, {
translate: "10px 20px",
rotate: "8deg",
});
applyStudioPathOffset(card, { x: 14, y: -8 });
applyStudioRotation(card, { angle: 12 });
expect(
applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"),
).toBe(0);
expect(card.style.getPropertyValue("translate")).toBe("");
expect(card.style.getPropertyValue("rotate")).toBe("");
});
it("does not compound stale studio variables as authored transform bases", () => {
const document = createDocument(`<div id="card"></div>`);
const card = document.getElementById("card") as HTMLElement;
card.style.setProperty(
"translate",
`var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
);
card.style.setProperty("rotate", `var(${STUDIO_ROTATION_PROP}, 0deg)`);
applyStudioPathOffset(card, { x: 14, y: -8 });
applyStudioRotation(card, { angle: 12 });
expect(card.style.getPropertyValue("translate")).toBe(
`var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`,
);
expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
});
it("applies box sizes through CSS dimensions and flex sizing overrides", () => {
const document = createDocument(`
<div style="display: flex; flex-direction: row">
<div id="card" style="width: 160px; height: 90px"></div>
</div>
`);
const card = document.getElementById("card") as HTMLElement;
mockBoundingRect(card, 160, 90);
applyStudioBoxSize(card, { width: 240, height: 135 });
expect(readStudioBoxSize(card)).toEqual({ width: 240, height: 135 });
expect(card.style.getPropertyValue(STUDIO_WIDTH_PROP)).toBe("240px");
expect(card.style.getPropertyValue("width")).toBe("240px");
expect(card.style.getPropertyValue("height")).toBe("135px");
expect(card.style.getPropertyValue("flex-basis")).toBe("240px");
expect(card.style.getPropertyValue("flex-grow")).toBe("0");
expect(card.style.getPropertyValue("flex-shrink")).toBe("0");
expect(card.style.getPropertyValue("box-sizing")).toBe("border-box");
expect(card.style.getPropertyValue("scale")).toBe("");
applyStudioBoxSizeDraft(card, { width: 260, height: 150 });
expect(readStudioBoxSize(card)).toEqual({ width: 260, height: 150 });
expect(card.style.getPropertyValue("width")).toBe("260px");
expect(card.style.getPropertyValue("height")).toBe("150px");
expect(card.style.getPropertyValue("flex-basis")).toBe("260px");
const snapshot = captureStudioBoxSize(card);
applyStudioBoxSizeDraft(card, { width: 280, height: 160 });
restoreStudioBoxSize(card, snapshot);
expect(readStudioBoxSize(card)).toEqual({ width: 260, height: 150 });
expect(card.style.getPropertyValue("width")).toBe("260px");
expect(card.style.getPropertyValue("height")).toBe("150px");
expect(card.style.getPropertyValue("flex-basis")).toBe("260px");
});
it("applies rotations through CSS rotate longhand around the element center", () => {
const document = createDocument(
`<div id="card" style="rotate: 8deg; transform-origin: left top"></div>`,
);
const card = document.getElementById("card") as HTMLElement;
applyStudioRotation(card, { angle: 24.24 });
expect(readStudioRotation(card)).toEqual({ angle: 24.2 });
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.2deg");
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
applyStudioRotationDraft(card, { angle: -12.26 });
expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
const snapshot = captureStudioRotation(card);
applyStudioRotationDraft(card, { angle: 45 });
restoreStudioRotation(card, snapshot);
expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
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">
<div id="card" style="width: 160px; height: 90px"></div>
</div>
`);
const card = document.getElementById("card") as HTMLElement;
applyStudioBoxSize(card, { width: 240, height: 135 });
expect(card.style.getPropertyValue("width")).toBe("240px");
expect(card.style.getPropertyValue("height")).toBe("135px");
expect(card.style.getPropertyValue("flex-basis")).toBe("135px");
});
it("uses additive CSS translate without mutating GSAP tweens during path-offset moves", () => {
const document = createDocument(`<div id="card"></div>`);
const card = document.getElementById("card") as HTMLElement;
const getTweensOf = vi.fn();
const getProperty = vi.fn();
const set = vi.fn();
const tickerTick = vi.fn();
const tween = {
vars: { x: 0, y: 10, startAt: { x: -240, y: -20 } },
targets: () => [card],
invalidate: vi.fn(),
parent: {
time: () => 1.25,
totalTime: vi.fn(),
invalidate: vi.fn(),
},
_startAt: {
vars: { x: -240, y: -20 },
invalidate: vi.fn(),
},
};
(
document.defaultView as unknown as {
gsap: {
getTweensOf: () => Array<typeof tween>;
getProperty: (_target: Element, property: string) => unknown;
set: (_target: Element, vars: Record<string, unknown>) => void;
ticker: { tick: () => void };
};
}
).gsap = {
getTweensOf,
getProperty,
set,
ticker: { tick: tickerTick },
};
applyStudioPathOffset(card, { x: 30, y: -12 });
expect(tween.vars).toMatchObject({
x: 0,
y: 10,
startAt: { x: -240, y: -20 },
});
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
expect(readStudioPathOffset(card)).toEqual({ x: 30, y: -12 });
expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
expect(getTweensOf).not.toHaveBeenCalled();
expect(getProperty).not.toHaveBeenCalled();
expect(set).not.toHaveBeenCalled();
expect(tickerTick).not.toHaveBeenCalled();
beginStudioManualEditGesture(card);
applyStudioPathOffsetDraft(card, { x: 35, y: -6 });
expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
expect(card.style.getPropertyValue("translate")).toBe("35px -6px");
expect(tween.vars).toMatchObject({
x: 0,
y: 10,
startAt: { x: -240, y: -20 },
});
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
expect(tickerTick).not.toHaveBeenCalled();
applyStudioPathOffset(card, { x: 35, y: -6 });
endStudioManualEditGesture(card);
expect(tween.vars).toMatchObject({
x: 0,
y: 10,
startAt: { x: -240, y: -20 },
});
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);
expect(tween.vars).toMatchObject({
x: 0,
y: 10,
startAt: { x: -240, y: -20 },
});
expect(tween._startAt.vars).toEqual({ x: -240, y: -20 });
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("applies manifest offsets to matching preview elements", () => {
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
}
]
}`);
expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1);
expect(readStudioPathOffset(document.getElementById("card") as HTMLElement)).toEqual({
x: 32,
y: 18,
});
});
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", () => {
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);
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);
expect(readStudioBoxSize(card)).toEqual({ width: 0, height: 0 });
expect(card.style.getPropertyValue("width")).toBe("160px");
expect(card.style.getPropertyValue("height")).toBe("90px");
expect(card.style.getPropertyValue("flex-basis")).toBe("");
expect(card.style.getPropertyValue("flex-grow")).toBe("");
expect(card.style.getPropertyValue("flex-shrink")).toBe("");
expect(card.style.getPropertyValue("scale")).toBe("");
});
it("applies and clears manifest rotations while restoring 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);
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);
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", () => {
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);
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 });
});
it("reapplies the latest preview manifest after wrapped seeks", () => {
const window = new Window();
const seekArgs: unknown[][] = [];
const previewWindow = window as unknown as Parameters<
typeof installStudioManualEditSeekReapply
>[0] & {
__player: Record<string, unknown>;
};
previewWindow.__player = {
seek: (...args: unknown[]) => {
seekArgs.push(args);
},
};
let applied = 0;
expect(
installStudioManualEditSeekReapply(previewWindow, () => {
applied += 1;
}),
).toBe(true);
(previewWindow.__player.seek as (time: number, suppressEvents: boolean) => void)(1, false);
expect(applied).toBe(1);
expect(seekArgs).toEqual([[1, false]]);
expect(
installStudioManualEditSeekReapply(previewWindow, () => {
applied += 10;
}),
).toBe(true);
(previewWindow.__player.seek as (time: number) => void)(2);
expect(applied).toBe(11);
});
it("reapplies manual edits while fresh playback is active", () => {
const window = new Window();
const frames: FrameRequestCallback[] = [];
let playing = false;
const previewWindow = window as unknown as Parameters<
typeof installStudioManualEditSeekReapply
>[0] & {
__player: Record<string, unknown>;
requestAnimationFrame: (callback: FrameRequestCallback) => number;
};
previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
};
previewWindow.__player = {
play: () => {
playing = true;
},
isPlaying: () => playing,
};
let applied = 0;
expect(
installStudioManualEditSeekReapply(previewWindow, () => {
applied += 1;
}),
).toBe(true);
(previewWindow.__player.play as () => void)();
expect(applied).toBe(1);
expect(frames).toHaveLength(1);
frames.shift()?.(16);
expect(applied).toBe(2);
expect(frames).toHaveLength(1);
playing = false;
frames.shift()?.(32);
expect(applied).toBe(3);
expect(frames).toHaveLength(0);
});
it("stops playback reapply after an unpaused timeline has completed", () => {
const window = new Window();
const frames: FrameRequestCallback[] = [];
let currentTime = 0;
let paused = true;
const previewWindow = window as unknown as Parameters<
typeof installStudioManualEditSeekReapply
>[0] & {
__timeline: Record<string, unknown>;
requestAnimationFrame: (callback: FrameRequestCallback) => number;
};
previewWindow.requestAnimationFrame = (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
};
previewWindow.__timeline = {
play: () => {
paused = false;
},
paused: () => paused,
isActive: () => false,
time: () => currentTime,
duration: () => 2,
};
let applied = 0;
expect(
installStudioManualEditSeekReapply(previewWindow, () => {
applied += 1;
}),
).toBe(true);
(previewWindow.__timeline.play as () => void)();
expect(applied).toBe(1);
expect(frames).toHaveLength(1);
currentTime = 2;
frames.shift()?.(16);
expect(applied).toBe(2);
expect(frames).toHaveLength(0);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,140 +0,0 @@
import { Window } from "happy-dom";
import { describe, expect, it } from "vitest";
import {
applyManualOffsetDragMatrix,
invertManualOffsetDragMatrix,
measureManualOffsetDragScreenToOffsetMatrix,
resolveManualOffsetForPointerDelta,
type ManualOffsetDragMatrix,
} from "./manualOffsetDrag";
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
expect(actual.a).toBeCloseTo(expected.a, 6);
expect(actual.b).toBeCloseTo(expected.b, 6);
expect(actual.c).toBeCloseTo(expected.c, 6);
expect(actual.d).toBeCloseTo(expected.d, 6);
}
describe("manual offset drag matrix helpers", () => {
it("inverts identity movement", () => {
const inverse = invertManualOffsetDragMatrix({ a: 1, b: 0, c: 0, d: 1 });
if (!inverse) throw new Error("identity matrix should be invertible");
expectMatrixClose(inverse, { a: 1, b: 0, c: 0, d: 1 });
});
it("maps screen movement through a rotated coordinate system", () => {
const screenToOffset = invertManualOffsetDragMatrix({ a: 0, b: 1, c: -1, d: 0 });
if (!screenToOffset) throw new Error("rotation matrix should be invertible");
const offsetDelta = applyManualOffsetDragMatrix(screenToOffset, { x: 0, y: 10 });
expect(offsetDelta.x).toBeCloseTo(10, 6);
expect(offsetDelta.y).toBeCloseTo(0, 6);
});
it("rejects singular movement matrices", () => {
expect(invertManualOffsetDragMatrix({ a: 1, b: 1, c: 2, d: 2 })).toBeNull();
});
it("resolves final offsets from the measured inverse matrix", () => {
const offsetToScreen = { a: 2, b: 3, c: -1, d: 4 };
const screenToOffset = invertManualOffsetDragMatrix(offsetToScreen);
if (!screenToOffset) throw new Error("fixture matrix should be invertible");
const nextOffset = resolveManualOffsetForPointerDelta({
initialOffset: { x: 5, y: -2 },
screenToOffset,
dx: 7,
dy: 11,
});
const screenDelta = applyManualOffsetDragMatrix(offsetToScreen, {
x: nextOffset.x - 5,
y: nextOffset.y + 2,
});
expect(screenDelta.x).toBeCloseTo(7, 6);
expect(screenDelta.y).toBeCloseTo(11, 6);
});
});
describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
it("measures the element center response and restores probe styles", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
element.getBoundingClientRect = () => {
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
return new window.DOMRect(10 + 2 * offsetX - offsetY, 20 + 3 * offsetX + 4 * offsetY, 12, 8);
};
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
if (!measured.ok) throw new Error(measured.reason);
const expected = invertManualOffsetDragMatrix({ a: 2, b: 3, c: -1, d: 4 });
if (!expected) throw new Error("fixture matrix should be invertible");
expectMatrixClose(measured.matrix, expected);
expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
expect(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
expect(element.style.getPropertyValue("translate")).toBe("");
});
it("measures movement in parent viewport pixels when the element is inside a scaled iframe", () => {
const window = new Window();
const iframe = window.document.createElement("iframe");
window.document.body.append(iframe);
const iframeWindow = iframe.contentWindow;
const iframeDocument = iframe.contentDocument;
if (!iframeWindow || !iframeDocument) throw new Error("iframe fixture failed to initialize");
Object.defineProperty(iframeWindow, "frameElement", {
configurable: true,
value: iframe,
});
Object.defineProperty(iframeWindow, "innerWidth", {
configurable: true,
value: 200,
});
Object.defineProperty(iframeWindow, "innerHeight", {
configurable: true,
value: 100,
});
iframe.getBoundingClientRect = () => new window.DOMRect(50, 40, 100, 50);
const element = iframeDocument.createElement("div");
iframeDocument.body.append(element);
element.getBoundingClientRect = () => {
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
return new iframeWindow.DOMRect(20 + offsetX, 30 + offsetY, 40, 20);
};
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
if (!measured.ok) throw new Error(measured.reason);
expectMatrixClose(measured.matrix, { a: 2, b: -0, c: -0, d: 2 });
const nextOffset = resolveManualOffsetForPointerDelta({
initialOffset: { x: 0, y: 0 },
screenToOffset: measured.matrix,
dx: 50,
dy: 25,
});
expect(nextOffset).toEqual({ x: 100, y: 50 });
});
it("rejects elements whose movement response cannot be measured", () => {
const window = new Window();
const element = window.document.createElement("div");
window.document.body.append(element);
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 12, 8);
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
expect(measured.ok).toBe(false);
});
});
@@ -1,307 +0,0 @@
import type { DomEditSelection } from "./domEditing";
import {
applyStudioPathOffset,
applyStudioPathOffsetDraft,
beginStudioManualEditGesture,
captureStudioPathOffset,
endStudioManualEditGesture,
readStudioPathOffset,
restoreStudioPathOffset,
type StudioPathOffsetSnapshot,
} from "./manualEdits";
const DEFAULT_OFFSET_PROBE_PX = 100;
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
const MIN_MATRIX_DETERMINANT = 0.000001;
export interface ManualOffsetDragMatrix {
a: number;
b: number;
c: number;
d: number;
}
export interface ManualOffsetDragRect {
left: number;
top: number;
width: number;
height: number;
editScaleX: number;
editScaleY: number;
}
export interface ManualOffsetDragMember {
key: string;
selection: DomEditSelection;
element: HTMLElement;
initialOffset: { x: number; y: number };
initialPathOffset: StudioPathOffsetSnapshot;
gestureToken: string;
screenToOffset: ManualOffsetDragMatrix;
originRect: ManualOffsetDragRect;
}
export type ManualOffsetDragMemberResult =
| { ok: true; member: ManualOffsetDragMember }
| { ok: false; reason: string; selection: DomEditSelection };
type Point = { x: number; y: number };
function finitePoint(point: Point): boolean {
return Number.isFinite(point.x) && Number.isFinite(point.y);
}
function vectorLength(point: Point): number {
return Math.hypot(point.x, point.y);
}
function finiteRect(rect: DOMRect): boolean {
return (
Number.isFinite(rect.left) &&
Number.isFinite(rect.top) &&
Number.isFinite(rect.width) &&
Number.isFinite(rect.height)
);
}
function readViewportSize(win: Window): { width: number; height: number } {
const docEl = win.document.documentElement;
const width = win.innerWidth || docEl.clientWidth || 1;
const height = win.innerHeight || docEl.clientHeight || 1;
return {
width: width > 0 ? width : 1,
height: height > 0 ? height : 1,
};
}
function getFrameElement(win: Window): HTMLElement | null {
try {
const frameElement = win.frameElement;
if (!frameElement) return null;
const ownerWin = frameElement.ownerDocument.defaultView;
const htmlElement = ownerWin?.HTMLElement;
return htmlElement && frameElement instanceof htmlElement ? frameElement : null;
} catch {
return null;
}
}
function getRectCenter(element: HTMLElement): Point | null {
const rect = element.getBoundingClientRect();
if (!finiteRect(rect) || (rect.width <= 0 && rect.height <= 0)) {
return null;
}
let point = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
let win: Window | null = element.ownerDocument.defaultView;
while (win) {
const frameElement = getFrameElement(win);
if (!frameElement) break;
const frameRect = frameElement.getBoundingClientRect();
if (!finiteRect(frameRect) || frameRect.width <= 0 || frameRect.height <= 0) return null;
const viewport = readViewportSize(win);
point = {
x: frameRect.left + point.x * (frameRect.width / viewport.width),
y: frameRect.top + point.y * (frameRect.height / viewport.height),
};
win = frameElement.ownerDocument.defaultView;
}
return point;
}
export function invertManualOffsetDragMatrix(
matrix: ManualOffsetDragMatrix,
): ManualOffsetDragMatrix | null {
const determinant = matrix.a * matrix.d - matrix.b * matrix.c;
if (!Number.isFinite(determinant) || Math.abs(determinant) < MIN_MATRIX_DETERMINANT) {
return null;
}
return {
a: matrix.d / determinant,
b: -matrix.b / determinant,
c: -matrix.c / determinant,
d: matrix.a / determinant,
};
}
export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, point: Point): Point {
return {
x: matrix.a * point.x + matrix.c * point.y,
y: matrix.b * point.x + matrix.d * point.y,
};
}
export function measureManualOffsetDragScreenToOffsetMatrix(
element: HTMLElement,
initialOffset: { x: number; y: number },
options: { probeSize?: number } = {},
): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
if (!Number.isFinite(probeSize) || probeSize <= 0) {
return { ok: false, reason: "Invalid movement probe size." };
}
const snapshot = captureStudioPathOffset(element);
try {
applyStudioPathOffsetDraft(element, initialOffset);
const origin = getRectCenter(element);
if (!origin) {
return { ok: false, reason: "Element has no measurable box." };
}
applyStudioPathOffsetDraft(element, {
x: initialOffset.x + probeSize,
y: initialOffset.y,
});
const probeX = getRectCenter(element);
if (!probeX) {
return { ok: false, reason: "Element X movement could not be measured." };
}
applyStudioPathOffsetDraft(element, {
x: initialOffset.x,
y: initialOffset.y + probeSize,
});
const probeY = getRectCenter(element);
if (!probeY) {
return { ok: false, reason: "Element Y movement could not be measured." };
}
const xColumn = {
x: (probeX.x - origin.x) / probeSize,
y: (probeX.y - origin.y) / probeSize,
};
const yColumn = {
x: (probeY.x - origin.x) / probeSize,
y: (probeY.y - origin.y) / probeSize,
};
if (
!finitePoint(xColumn) ||
!finitePoint(yColumn) ||
vectorLength(xColumn) < MIN_PROBE_VECTOR_LENGTH_PX ||
vectorLength(yColumn) < MIN_PROBE_VECTOR_LENGTH_PX
) {
return { ok: false, reason: "Element movement response is too small to measure." };
}
const offsetToScreen = {
a: xColumn.x,
b: xColumn.y,
c: yColumn.x,
d: yColumn.y,
};
const screenToOffset = invertManualOffsetDragMatrix(offsetToScreen);
if (!screenToOffset) {
return { ok: false, reason: "Element movement response is not invertible." };
}
return { ok: true, matrix: screenToOffset };
} finally {
restoreStudioPathOffset(element, snapshot);
}
}
export function resolveManualOffsetForPointerDelta(input: {
initialOffset: { x: number; y: number };
screenToOffset: ManualOffsetDragMatrix;
dx: number;
dy: number;
}): { x: number; y: number } {
const offsetDelta = applyManualOffsetDragMatrix(input.screenToOffset, {
x: input.dx,
y: input.dy,
});
return {
x: input.initialOffset.x + offsetDelta.x,
y: input.initialOffset.y + offsetDelta.y,
};
}
export function createManualOffsetDragMember(input: {
key: string;
selection: DomEditSelection;
element: HTMLElement;
rect: ManualOffsetDragRect;
}): ManualOffsetDragMemberResult {
const initialOffset = readStudioPathOffset(input.element);
const initialPathOffset = captureStudioPathOffset(input.element);
const gestureToken = beginStudioManualEditGesture(input.element);
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
if (!measured.ok) {
restoreStudioPathOffset(input.element, initialPathOffset);
endStudioManualEditGesture(input.element, gestureToken);
return { ok: false, reason: measured.reason, selection: input.selection };
}
return {
ok: true,
member: {
key: input.key,
selection: input.selection,
element: input.element,
initialOffset,
initialPathOffset,
gestureToken,
screenToOffset: measured.matrix,
originRect: input.rect,
},
};
}
export function resolveManualOffsetDragMemberOffset(
member: ManualOffsetDragMember,
dx: number,
dy: number,
): { x: number; y: number } {
return resolveManualOffsetForPointerDelta({
initialOffset: member.initialOffset,
screenToOffset: member.screenToOffset,
dx,
dy,
});
}
export function applyManualOffsetDragDraft(
member: ManualOffsetDragMember,
dx: number,
dy: number,
): { x: number; y: number } {
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
applyStudioPathOffsetDraft(member.element, offset);
return offset;
}
export function applyManualOffsetDragCommit(
member: ManualOffsetDragMember,
dx: number,
dy: number,
): { x: number; y: number } {
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
applyStudioPathOffset(member.element, offset);
return offset;
}
export function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
restoreStudioPathOffset(member.element, member.initialPathOffset);
endStudioManualEditGesture(member.element, member.gestureToken);
}
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
restoreManualOffsetDragMember(member);
}
}
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
for (const member of members) {
endStudioManualEditGesture(member.element, member.gestureToken);
}
}