Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
38768e1
feat(studio): edit and style text in the preview
miguel-heygen Aug 6, 2026
7c9bbd6
fix(studio): let a text-layer change save, and stop it losing what it…
miguel-heygen Aug 7, 2026
ea4f476
fix(studio): style the characters that are selected, not the ones aft…
miguel-heygen Aug 7, 2026
f93a6b8
fix(studio): save the text edit the caret was just allowed to make
miguel-heygen Aug 7, 2026
efdff97
fix(studio): give the spans a text edit adds their ids in the same write
miguel-heygen Aug 7, 2026
2df6f73
fix(studio): make a run's colour paint when something else is filling…
miguel-heygen Aug 7, 2026
29afdba
fix(studio): size the selection box by the transform the element actu…
miguel-heygen Aug 7, 2026
adc6ebf
refactor(studio): split the inline styling steps that failed the audi…
miguel-heygen Aug 8, 2026
d503dcf
fix(studio): stop a Studio edit from reloading the preview as if it w…
miguel-heygen Aug 8, 2026
95068c1
fix(studio): drag by the movement the element actually makes, not the…
miguel-heygen Aug 8, 2026
3d82168
fix(studio): claim the timeline and caption writes too, not just the …
miguel-heygen Aug 8, 2026
1b5f50d
fix(studio): shift-click adds the element under the pointer, not the …
miguel-heygen Aug 8, 2026
0bae902
fix(studio): keep every element a marquee caught, not just the first
miguel-heygen Aug 8, 2026
e74a836
fix(studio): stop a group selection from erasing itself on the timeline
miguel-heygen Aug 8, 2026
6a9d5be
chore(studio): trace what moves a dragged group and when
miguel-heygen Aug 8, 2026
eb47034
chore(studio): name the path that clears a selection after a group move
miguel-heygen Aug 8, 2026
3018f6d
fix(studio): losing one member of a group no longer deselects all of it
miguel-heygen Aug 8, 2026
d01a2a2
Merge remote-tracking branch 'origin/main' into feat-studio-inline-ri…
miguel-heygen Aug 8, 2026
bf2fe61
chore(studio): name whoever puts the pre-resize size back
miguel-heygen Aug 8, 2026
c25f29c
refactor(studio): bring three files back under the size cap
miguel-heygen Aug 8, 2026
dfd3a20
refactor(studio): keep the last two files this branch touched under t…
miguel-heygen Aug 8, 2026
36ec23a
fix(studio): hold a resized element's size while the timeline is rebuilt
miguel-heygen Aug 8, 2026
b149e37
feat(studio): carry a multi-selection in the URL, and name the member…
miguel-heygen Aug 8, 2026
a8f7cc1
fix(studio): stop snapping from moving a selection you have not dragg…
miguel-heygen Aug 8, 2026
1b3064c
fix(studio): a dropped group stays selected
miguel-heygen Aug 8, 2026
35aabcb
feat(studio): marquee from anywhere on the canvas, including outside …
miguel-heygen Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
"types": "./dist/utils/htmlAttrSafety.d.ts",
"environments": ["browser", "bun", "node"]
},
"./rich-text-sanitize": {
"source": "./src/utils/richTextSanitize.ts",
"runtime": "./dist/utils/richTextSanitize.js",
"types": "./dist/utils/richTextSanitize.d.ts",
"environments": ["browser", "bun", "node"]
},
"./composition-contract": {
"source": "./src/compositionContract.ts",
"runtime": "./dist/compositionContract.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
"import": "./src/utils/htmlAttrSafety.ts",
"types": "./src/utils/htmlAttrSafety.ts"
},
"./rich-text-sanitize": {
"bun": "./src/utils/richTextSanitize.ts",
"node": "./dist/utils/richTextSanitize.js",
"import": "./src/utils/richTextSanitize.ts",
"types": "./src/utils/richTextSanitize.ts"
},
"./composition-contract": {
"bun": "./src/compositionContract.ts",
"node": "./dist/compositionContract.js",
Expand Down Expand Up @@ -326,6 +332,10 @@
"import": "./dist/utils/htmlAttrSafety.js",
"types": "./dist/utils/htmlAttrSafety.d.ts"
},
"./rich-text-sanitize": {
"import": "./dist/utils/richTextSanitize.js",
"types": "./dist/utils/richTextSanitize.d.ts"
},
"./composition-contract": {
"import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts"
Expand Down
193 changes: 193 additions & 0 deletions packages/core/src/utils/richTextSanitize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { isRichTextFormattingTag, sanitizeRichTextChildren } from "./richTextSanitize";

// Both parsers, every case. The browser runs this against a live element and
// the server runs it against linkedom, and the whole point of one shared module
// is that the two cannot disagree about what may be written to a file.
const PARSERS: Array<[string, (html: string) => Element]> = [
[
"jsdom",
(html) => {
const host = document.createElement("div");
host.innerHTML = html;
return host;
},
],
[
"linkedom",
(html) => {
const { document: doc } = parseHTML(`<!DOCTYPE html><html><body></body></html>`);
const host = doc.createElement("div");
host.innerHTML = html;
return host as unknown as Element;
},
],
];

function clean(html: string, parse: (html: string) => Element): string {
const host = parse(html);
sanitizeRichTextChildren(host);
return host.innerHTML;
}

describe.each(PARSERS)("sanitizeRichTextChildren (%s)", (_name, parse) => {
it("keeps a styled span, which is the whole point", () => {
expect(clean('<span style="color: red">hi</span>', parse)).toBe(
'<span style="color: red">hi</span>',
);
});

it("keeps plain text untouched", () => {
expect(clean("just words", parse)).toBe("just words");
});

it("keeps nested formatting and its nesting", () => {
expect(clean('<b><span style="color: red">x</span></b>', parse)).toBe(
'<b><span style="color: red">x</span></b>',
);
});

it("keeps a line break", () => {
expect(clean("a<br>b", parse)).toContain("<br>");
});

it("removes a script and does not leave its source as visible text", () => {
const out = clean("<script>alert(1)</script>keep", parse);
expect(out).not.toContain("script");
expect(out).not.toContain("alert");
expect(out).toContain("keep");
});

it("strips an event handler from a tag it otherwise keeps", () => {
const out = clean('<span onclick="steal()" style="color: red">x</span>', parse);
expect(out).not.toContain("onclick");
expect(out).toContain("color: red");
});

it("strips every attribute that is neither style nor an identity", () => {
const out = clean('<span id="a" class="b" data-x="c" style="color: red">x</span>', parse);
expect(out).not.toContain("id=");
expect(out).not.toContain("class=");
expect(out).not.toContain("data-x");
expect(out).toContain("color: red");
});

// The design panel tracks each text layer by this. Stripping it left the
// panel unable to match a layer to its source after any inline style edit.
it("keeps the attributes a text layer is tracked by", () => {
const out = clean(
'<span data-hf-text-key="child:1" data-hf-id="hf-abc" style="color: red">x</span>',
parse,
);
expect(out).toContain('data-hf-text-key="child:1"');
expect(out).toContain('data-hf-id="hf-abc"');
});

it("drops an identity attribute whose value is not a bare token", () => {
const out = clean(`<span data-hf-text-key='a" onload="alert(1)'>x</span>`, parse);
expect(out).not.toContain("onload");
expect(out).not.toContain("data-hf-text-key");
});

// These are what the design panel writes onto those same spans. Sanitizing
// them away did not stop a text edit changing layout, it deleted the layout
// the user had already set: colouring one word dropped a sibling's size.
it("keeps the typography the design panel authors on a text layer", () => {
const out = clean(
'<span style="font-family: Inter; font-size: 48px; letter-spacing: -1px; line-height: 1.2">x</span>',
parse,
);
expect(out).toContain("font-family: Inter");
expect(out).toContain("font-size: 48px");
expect(out).toContain("letter-spacing: -1px");
expect(out).toContain("line-height: 1.2");
});

it("still refuses a value that reaches outside the stylesheet", () => {
const out = clean(`<span style="font-family: url(http://x/f.woff)">x</span>`, parse);
expect(out).not.toContain("url(");
});

it("unwraps a tag that is not formatting, keeping its words in place", () => {
expect(clean("before<div>middle</div>after", parse)).toBe("beforemiddleafter");
});

it("unwraps deeply and keeps the formatting found inside", () => {
const out = clean('<div><p><span style="color: red">deep</span></p></div>', parse);
expect(out).toBe('<span style="color: red">deep</span>');
});

it("keeps only the allowlisted style properties", () => {
const out = clean('<span style="color: red; position: fixed; z-index: 99">x</span>', parse);
expect(out).toContain("color: red");
expect(out).not.toContain("position");
expect(out).not.toContain("z-index");
});

it("keeps every property the allowlist names", () => {
const style =
"color: red; background-color: blue; font-weight: 700; font-style: italic; text-decoration-line: underline";
const out = clean(`<span style="${style}">x</span>`, parse);
for (const property of [
"color",
"background-color",
"font-weight",
"font-style",
"text-decoration-line",
]) {
expect(out).toContain(property);
}
});

it("rejects a value that smuggles a url or a script in", () => {
const out = clean(
'<span style="background-color: url(javascript:alert(1)); color: red">x</span>',
parse,
);
expect(out).not.toContain("javascript");
expect(out).not.toContain("url(");
expect(out).toContain("color: red");
});

it("drops the style attribute entirely when nothing in it survives", () => {
expect(clean('<span style="position: fixed">x</span>', parse)).toBe("<span>x</span>");
});

it("keeps a value carrying a function with its own separators", () => {
const out = clean('<span style="color: rgb(1, 2, 3); font-style: italic">x</span>', parse);
expect(out).toContain("rgb(1, 2, 3)");
expect(out).toContain("font-style: italic");
});

it("removes a comment, which is neither text nor formatting", () => {
expect(clean("a<!-- note -->b", parse)).toBe("ab");
});

it("leaves an empty element alone", () => {
expect(clean("", parse)).toBe("");
});

it("does not produce unbalanced markup from an unclosed tag", () => {
const out = clean('<span style="color: red">open', parse);
expect(out).toBe('<span style="color: red">open</span>');
});
});

describe("isRichTextFormattingTag", () => {
it("names the tags an inline edit may contain", () => {
for (const tag of ["SPAN", "B", "STRONG", "I", "EM", "U", "BR"]) {
expect(isRichTextFormattingTag(tag)).toBe(true);
}
});

it("is case-insensitive, since the two parsers disagree about case", () => {
expect(isRichTextFormattingTag("span")).toBe(true);
});

it("says no to anything structural", () => {
for (const tag of ["DIV", "P", "H1", "IMG", "SCRIPT", "A"]) {
expect(isRichTextFormattingTag(tag)).toBe(false);
}
});
});
Loading
Loading