mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 04:18:01 +00:00
Revert "feat: Persist Studio manual edits via manifest (#593)"
This reverts commit d0abe90a82.
This commit is contained in:
@@ -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 <1></strong><span data-hf-text-key="child:1:span">Details & 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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, """)}"`)
|
||||
.join("");
|
||||
const style = serializeTextFieldStyle(field);
|
||||
const styleAttr = style ? ` style="${style.replace(/"/g, """)}"` : "";
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user