mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
Merge pull request #986 from heygen-com/fix/studio-edit-persistence-and-render-css
fix(studio): server-side DOM patching, render CSS scoping, and resilience
This commit is contained in:
@@ -228,6 +228,71 @@ jobs:
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci
|
||||
|
||||
studio-load-smoke:
|
||||
name: "Studio: load smoke"
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
lfs: true
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run --cwd packages/core build:hyperframes-runtime
|
||||
- name: Start studio and check for runtime errors
|
||||
run: |
|
||||
# Start the studio dev server in the background
|
||||
bun run --filter '@hyperframes/studio' dev -- --port 5199 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for the server to be ready (up to 20s)
|
||||
for i in $(seq 1 40); do
|
||||
if curl -sf http://localhost:5199/ >/dev/null 2>&1; then break; fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ! curl -sf http://localhost:5199/ >/dev/null 2>&1; then
|
||||
echo "FAIL: studio dev server did not start"
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load the studio in headless Chrome and capture console errors
|
||||
# puppeteer is a dependency of @hyperframes/producer; resolve from there
|
||||
cd packages/producer
|
||||
node --input-type=module <<'SMOKE_EOF'
|
||||
import puppeteer from "puppeteer";
|
||||
const browser = await puppeteer.launch({
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text());
|
||||
});
|
||||
await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
await browser.close();
|
||||
const fatal = errors.filter(
|
||||
(e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
|
||||
);
|
||||
if (fatal.length > 0) {
|
||||
console.error("FAIL: studio had runtime errors on load:");
|
||||
for (const e of fatal) console.error(" •", e);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("PASS: studio loaded without runtime errors");
|
||||
SMOKE_EOF
|
||||
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
|
||||
smoke-global-install:
|
||||
name: "Smoke: global install"
|
||||
needs: [changes, build]
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
"correctness": "error"
|
||||
},
|
||||
"plugins": ["react", "typescript"],
|
||||
"ignorePatterns": ["dist/", "coverage/", "node_modules/"]
|
||||
"ignorePatterns": ["dist/", "coverage/", "node_modules/", "playground/"]
|
||||
}
|
||||
|
||||
@@ -497,6 +497,55 @@ window.__afterTimeline = window.__timelines.scene;
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses compound selector when authored root is the scoped element itself", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
"#chrome-overlay-root { --primary: #FFDC8B; }",
|
||||
"chrome-overlay",
|
||||
undefined,
|
||||
"chrome-overlay-root",
|
||||
{ compoundAuthoredRoot: true },
|
||||
);
|
||||
|
||||
// Both attributes are on the same element after inlining, so the selector
|
||||
// must be compound (no space) to match.
|
||||
expect(scoped).toContain(
|
||||
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
|
||||
);
|
||||
expect(scoped).not.toContain(
|
||||
'[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
|
||||
);
|
||||
});
|
||||
|
||||
it("uses compound selector for authored root with descendant combinators", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
"#chrome-overlay-root .chrome { display: flex; }",
|
||||
"chrome-overlay",
|
||||
undefined,
|
||||
"chrome-overlay-root",
|
||||
{ compoundAuthoredRoot: true },
|
||||
);
|
||||
|
||||
// The authored root part is compound with scope, .chrome is a descendant
|
||||
expect(scoped).toContain(
|
||||
'[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
|
||||
);
|
||||
expect(scoped).not.toMatch(
|
||||
/\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
|
||||
);
|
||||
});
|
||||
|
||||
it("still uses descendant selector for non-root selectors with authoredRootId", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
".child-element { color: red; }",
|
||||
"chrome-overlay",
|
||||
undefined,
|
||||
"chrome-overlay-root",
|
||||
);
|
||||
|
||||
// Regular child selectors still get a descendant combinator (space)
|
||||
expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
|
||||
});
|
||||
|
||||
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
`#intro { background: #111; }
|
||||
|
||||
@@ -101,6 +101,7 @@ function scopeSelector(
|
||||
scope: string,
|
||||
compositionId: string,
|
||||
authoredRootId?: string | null,
|
||||
compoundAuthoredRoot?: boolean,
|
||||
): string {
|
||||
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
|
||||
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
|
||||
@@ -120,6 +121,15 @@ function scopeSelector(
|
||||
}
|
||||
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
|
||||
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
|
||||
if (compoundAuthoredRoot) {
|
||||
const authoredRootAttr = authoredRootId
|
||||
? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
|
||||
: null;
|
||||
if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
|
||||
const rest = trimmed.slice(authoredRootAttr.length);
|
||||
return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
|
||||
}
|
||||
}
|
||||
return `${leading}${scope} ${trimmed}${trailing}`;
|
||||
}
|
||||
|
||||
@@ -158,6 +168,7 @@ export function scopeCssToComposition(
|
||||
compositionId: string,
|
||||
scopeSelectorOverride?: string,
|
||||
authoredRootId?: string | null,
|
||||
options?: { compoundAuthoredRoot?: boolean },
|
||||
): string {
|
||||
const trimmedCompositionId = compositionId.trim();
|
||||
if (!css || !trimmedCompositionId) return css;
|
||||
@@ -169,7 +180,13 @@ export function scopeCssToComposition(
|
||||
root.walkRules((rule) => {
|
||||
if (isInsideGlobalAtRule(rule)) return;
|
||||
rule.selectors = rule.selectors.map((selector) =>
|
||||
scopeSelector(selector, scope, trimmedCompositionId, authoredRootId),
|
||||
scopeSelector(
|
||||
selector,
|
||||
scope,
|
||||
trimmedCompositionId,
|
||||
authoredRootId,
|
||||
options?.compoundAuthoredRoot,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -236,4 +236,35 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
|
||||
|
||||
expect(host.getAttribute("data-composition-id")).toBe("intro");
|
||||
});
|
||||
|
||||
it("producer path: scoped CSS matches host element when both attributes coexist", () => {
|
||||
const document = makeHostDocument("intro");
|
||||
const host = document.querySelector('[data-composition-src="intro.html"]')!;
|
||||
|
||||
const result = inlineSubCompositions(document, [host], {
|
||||
resolveHtml: () => SUB_COMP_HTML,
|
||||
parseHtml: (html) => parseHTML(html).document,
|
||||
compoundAuthoredRoot: true,
|
||||
});
|
||||
|
||||
// After inlining, the host has both data-composition-id and data-hf-authored-id.
|
||||
// CSS selectors targeting the root must be compound (no space) so they match
|
||||
// when both attributes are on the same element.
|
||||
expect(host.getAttribute("data-composition-id")).toBe("intro");
|
||||
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
|
||||
|
||||
const scopedCss = result.styles.join("\n");
|
||||
|
||||
// Root-only selector: must be compound
|
||||
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
|
||||
// Must NOT have a descendant combinator between the two attribute selectors
|
||||
expect(scopedCss).not.toMatch(
|
||||
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
|
||||
);
|
||||
|
||||
// Descendant selector: compound root + space + child
|
||||
expect(scopedCss).toMatch(
|
||||
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,15 @@ export interface InlineSubCompositionsOptions {
|
||||
*/
|
||||
flattenInnerRoot?: (innerRoot: Element) => Element;
|
||||
|
||||
/**
|
||||
* When true, CSS selectors targeting the authored root use a compound
|
||||
* selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
|
||||
* Enable this in the producer path where the inner root merges onto
|
||||
* the host element via innerHTML — both attributes end up on the same
|
||||
* element and a descendant selector won't match.
|
||||
*/
|
||||
compoundAuthoredRoot?: boolean;
|
||||
|
||||
/**
|
||||
* Read declared variable defaults from a sub-composition's `<html>` element.
|
||||
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
|
||||
@@ -140,6 +149,7 @@ export function inlineSubCompositions(
|
||||
hostIdentityMap,
|
||||
rewriteInlineStyles = false,
|
||||
flattenInnerRoot,
|
||||
compoundAuthoredRoot,
|
||||
readVariableDefaults,
|
||||
parseHostVariables,
|
||||
buildScopeSelector = defaultBuildScopeSelector,
|
||||
@@ -214,7 +224,9 @@ export function inlineSubCompositions(
|
||||
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
||||
styles.push(
|
||||
scopeCompId
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
|
||||
compoundAuthoredRoot: compoundAuthoredRoot === true,
|
||||
})
|
||||
: css,
|
||||
);
|
||||
}
|
||||
@@ -244,7 +256,9 @@ export function inlineSubCompositions(
|
||||
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
||||
styles.push(
|
||||
scopeCompId
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
|
||||
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
|
||||
compoundAuthoredRoot: compoundAuthoredRoot === true,
|
||||
})
|
||||
: css,
|
||||
);
|
||||
s.remove();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { removeElementFromHtml } from "./sourceMutation.js";
|
||||
import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js";
|
||||
|
||||
describe("removeElementFromHtml", () => {
|
||||
it("removes a self-closing element by id", () => {
|
||||
@@ -28,3 +28,223 @@ describe("removeElementFromHtml", () => {
|
||||
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchElementInHtml", () => {
|
||||
const FIXTURE = `<!doctype html><html><head></head><body>
|
||||
<div id="root" data-composition-id="main">
|
||||
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
|
||||
<div class="chrome">
|
||||
<span class="brand">HyperFrames</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
it("patches inline style by id", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "inline-style", property: "color", value: "red" },
|
||||
]);
|
||||
|
||||
expect(result).toMatch(/color:\s*red/);
|
||||
expect(result).toContain('id="hero"');
|
||||
});
|
||||
|
||||
it("patches inline style by class selector", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
|
||||
{ type: "inline-style", property: "font-size", value: "72px" },
|
||||
]);
|
||||
|
||||
expect(result).toMatch(/font-size:\s*72px/);
|
||||
});
|
||||
|
||||
it("patches data attribute", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
|
||||
]);
|
||||
|
||||
expect(result).toContain('data-hf-studio-path-offset="true"');
|
||||
});
|
||||
|
||||
it("patches html attribute", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "title", value: "greeting" },
|
||||
]);
|
||||
|
||||
expect(result).toContain('title="greeting"');
|
||||
});
|
||||
|
||||
it("patches text content", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "text-content", property: "", value: "New Title" },
|
||||
]);
|
||||
|
||||
expect(result).toContain("New Title");
|
||||
expect(result).not.toContain("Hello World");
|
||||
});
|
||||
|
||||
it("applies multiple operations in one call", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
{ type: "inline-style", property: "font-size", value: "96px" },
|
||||
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
|
||||
]);
|
||||
|
||||
expect(result).toMatch(/color:\s*blue/);
|
||||
expect(result).toMatch(/font-size:\s*96px/);
|
||||
expect(result).toContain('data-hf-studio-path-offset="true"');
|
||||
});
|
||||
|
||||
it("finds element by composition-id selector", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
|
||||
{ type: "inline-style", property: "opacity", value: "0.5" },
|
||||
]);
|
||||
|
||||
expect(result).toMatch(/opacity:\s*0\.5/);
|
||||
});
|
||||
|
||||
it("finds element by class with selectorIndex", () => {
|
||||
const html = `<div class="item">A</div><div class="item">B</div>`;
|
||||
const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
|
||||
{ type: "text-content", property: "", value: "Changed" },
|
||||
]);
|
||||
|
||||
expect(result).toContain("A");
|
||||
expect(result).toContain("Changed");
|
||||
expect(result).not.toContain(">B<");
|
||||
});
|
||||
|
||||
it("returns unchanged html when target not found", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
|
||||
{ type: "inline-style", property: "color", value: "red" },
|
||||
]);
|
||||
|
||||
expect(result).toBe(FIXTURE);
|
||||
});
|
||||
|
||||
it("removes inline style when value is null", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "inline-style", property: "font-size", value: null },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("font-size");
|
||||
});
|
||||
|
||||
it("removes attribute when value is null", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
|
||||
{ type: "html-attribute", property: "data-composition-src", value: null },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("data-composition-src");
|
||||
});
|
||||
|
||||
it("patches fragment html without doctype", () => {
|
||||
const fragment = `<div id="card" style="padding: 8px"><span>Title</span></div>`;
|
||||
const result = patchElementInHtml(fragment, { id: "card" }, [
|
||||
{ type: "inline-style", property: "padding", value: "16px" },
|
||||
]);
|
||||
|
||||
expect(result).toMatch(/padding:\s*16px/);
|
||||
});
|
||||
|
||||
it("rejects event handler attributes", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "onload", value: "fetch('/evil')" },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("onload");
|
||||
expect(result).not.toContain("fetch");
|
||||
});
|
||||
|
||||
it("rejects javascript: URLs in src", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "src", value: "javascript:alert(1)" },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("allows aria-* and data-* attributes", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "aria-label", value: "greeting" },
|
||||
{ type: "html-attribute", property: "data-custom", value: "test" },
|
||||
]);
|
||||
|
||||
expect(result).toContain('aria-label="greeting"');
|
||||
expect(result).toContain('data-custom="test"');
|
||||
});
|
||||
|
||||
it("rejects srcdoc and formaction attributes", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "srcdoc", value: "<script>alert(1)</script>" },
|
||||
{ type: "html-attribute", property: "formaction", value: "javascript:void(0)" },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("srcdoc");
|
||||
expect(result).not.toContain("formaction");
|
||||
});
|
||||
|
||||
it("rejects on* event handlers regardless of casing", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "onClick", value: "alert(1)" },
|
||||
{ type: "html-attribute", property: "ONERROR", value: "alert(2)" },
|
||||
{ type: "html-attribute", property: "onmouseover", value: "alert(3)" },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("rejects data:text/html URIs in src", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{
|
||||
type: "html-attribute",
|
||||
property: "src",
|
||||
value: "data:text/html,<script>alert(1)</script>",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("data:text/html");
|
||||
});
|
||||
|
||||
it("allows safe href values", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "href", value: "https://example.com" },
|
||||
]);
|
||||
|
||||
expect(result).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it("rejects javascript: in href", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "href", value: "javascript:alert(1)" },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("allows legitimate form and media attributes", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "placeholder", value: "Enter text" },
|
||||
{ type: "html-attribute", property: "target", value: "_blank" },
|
||||
{ type: "html-attribute", property: "rel", value: "noopener" },
|
||||
{ type: "html-attribute", property: "srcset", value: "img-2x.png 2x" },
|
||||
]);
|
||||
|
||||
expect(result).toContain('placeholder="Enter text"');
|
||||
expect(result).toContain('target="_blank"');
|
||||
expect(result).toContain('rel="noopener"');
|
||||
expect(result).toContain("srcset");
|
||||
});
|
||||
|
||||
it("rejects unknown/dangerous attributes", () => {
|
||||
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
|
||||
{ type: "html-attribute", property: "xmlns", value: "http://evil.com" },
|
||||
{ type: "html-attribute", property: "background", value: "http://evil.com/bg.js" },
|
||||
{ type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" },
|
||||
]);
|
||||
|
||||
expect(result).not.toContain("xmlns");
|
||||
expect(result).not.toContain("background=");
|
||||
expect(result).not.toContain("dynsrc");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,3 +54,148 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg
|
||||
element.remove();
|
||||
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
||||
}
|
||||
|
||||
function isHTMLElement(el: Element): boolean {
|
||||
const HTMLEl = el.ownerDocument.defaultView?.HTMLElement;
|
||||
return HTMLEl ? el instanceof HTMLEl : "style" in el;
|
||||
}
|
||||
|
||||
export interface PatchOperation {
|
||||
type: "inline-style" | "attribute" | "html-attribute" | "text-content";
|
||||
property: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const ALLOWED_HTML_ATTRS = new Set([
|
||||
// Identity & structure
|
||||
"id",
|
||||
"class",
|
||||
"style",
|
||||
"title",
|
||||
"name",
|
||||
"for",
|
||||
"type",
|
||||
// Internationalization
|
||||
"lang",
|
||||
"dir",
|
||||
"translate",
|
||||
// Interaction
|
||||
"hidden",
|
||||
"tabindex",
|
||||
"draggable",
|
||||
"contenteditable",
|
||||
// Accessibility
|
||||
"role",
|
||||
"slot",
|
||||
// Links & navigation
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
// Media
|
||||
"src",
|
||||
"srcset",
|
||||
"sizes",
|
||||
"alt",
|
||||
"poster",
|
||||
"loading",
|
||||
"decoding",
|
||||
"crossorigin",
|
||||
"preload",
|
||||
"autoplay",
|
||||
"loop",
|
||||
"muted",
|
||||
"controls",
|
||||
"playsinline",
|
||||
// Layout
|
||||
"width",
|
||||
"height",
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"scope",
|
||||
// Form
|
||||
"placeholder",
|
||||
"value",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"pattern",
|
||||
"required",
|
||||
"disabled",
|
||||
"readonly",
|
||||
"checked",
|
||||
"selected",
|
||||
"multiple",
|
||||
"accept",
|
||||
"maxlength",
|
||||
"minlength",
|
||||
"rows",
|
||||
"cols",
|
||||
"wrap",
|
||||
]);
|
||||
|
||||
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
|
||||
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
|
||||
|
||||
function isAllowedHtmlAttribute(name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.startsWith("on")) return false;
|
||||
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
|
||||
if (lower.startsWith("data-")) return true;
|
||||
if (lower.startsWith("aria-")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const URI_ATTRS = new Set(["src", "href", "action", "formaction", "poster", "srcset"]);
|
||||
|
||||
function isSafeAttributeValue(name: string, value: string): boolean {
|
||||
if (URI_ATTRS.has(name.toLowerCase())) {
|
||||
const trimmed = value.trim();
|
||||
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
|
||||
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function patchElementInHtml(
|
||||
source: string,
|
||||
target: SourceMutationTarget,
|
||||
operations: PatchOperation[],
|
||||
): string {
|
||||
const { document, wrappedFragment } = parseSourceDocument(source);
|
||||
const el = findTargetElement(document, target);
|
||||
if (!el || !isHTMLElement(el)) return source;
|
||||
const htmlEl = el as unknown as HTMLElement;
|
||||
|
||||
for (const op of operations) {
|
||||
switch (op.type) {
|
||||
case "inline-style":
|
||||
if (op.value != null) {
|
||||
htmlEl.style.setProperty(op.property, op.value);
|
||||
} else {
|
||||
htmlEl.style.removeProperty(op.property);
|
||||
}
|
||||
break;
|
||||
case "attribute":
|
||||
if (op.value != null) {
|
||||
htmlEl.setAttribute(`data-${op.property}`, op.value);
|
||||
} else {
|
||||
htmlEl.removeAttribute(`data-${op.property}`);
|
||||
}
|
||||
break;
|
||||
case "html-attribute":
|
||||
if (!isAllowedHtmlAttribute(op.property)) break;
|
||||
if (op.value != null) {
|
||||
if (!isSafeAttributeValue(op.property, op.value)) break;
|
||||
htmlEl.setAttribute(op.property, op.value);
|
||||
} else {
|
||||
htmlEl.removeAttribute(op.property);
|
||||
}
|
||||
break;
|
||||
case "text-content":
|
||||
if (op.value != null) htmlEl.textContent = op.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import { isAudioFile } from "../helpers/mime.js";
|
||||
import { generateWaveformCache } from "../helpers/waveform.js";
|
||||
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
import { removeElementFromHtml } from "../helpers/sourceMutation.js";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
type PatchOperation,
|
||||
} from "../helpers/sourceMutation.js";
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -236,6 +240,45 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
return c.json({ ok: true, changed: true, content: patchedContent });
|
||||
});
|
||||
|
||||
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const project = await adapter.resolveProject(id);
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const filePath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""),
|
||||
);
|
||||
if (filePath.includes("\0")) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
|
||||
const absPath = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, absPath)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
target?: { id?: string | null; selector?: string; selectorIndex?: number };
|
||||
operations?: PatchOperation[];
|
||||
} | null;
|
||||
if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) {
|
||||
return c.json({ error: "target and operations required" }, 400);
|
||||
}
|
||||
|
||||
let originalContent: string;
|
||||
try {
|
||||
originalContent = readFileSync(absPath, "utf-8");
|
||||
} catch {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
const patchedContent = patchElementInHtml(originalContent, body.target, body.operations);
|
||||
if (patchedContent === originalContent) {
|
||||
return c.json({ ok: true, changed: false, content: originalContent });
|
||||
}
|
||||
|
||||
writeFileSync(absPath, patchedContent, "utf-8");
|
||||
return c.json({ ok: true, changed: true, content: patchedContent });
|
||||
});
|
||||
|
||||
// ── Rename / Move ──
|
||||
|
||||
api.patch("/projects/:id/files/*", async (c) => {
|
||||
|
||||
@@ -111,6 +111,33 @@ function injectStudioMotionScript(
|
||||
);
|
||||
}
|
||||
|
||||
const GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
|
||||
(function(){
|
||||
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
|
||||
var loaded={};
|
||||
function loadFallback(file){
|
||||
if(loaded[file])return loaded[file];
|
||||
return loaded[file]=new Promise(function(ok,fail){
|
||||
var s=document.createElement("script");
|
||||
s.src=cdnBase+file;s.onload=ok;s.onerror=fail;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
document.addEventListener("error",function(e){
|
||||
var t=e.target;
|
||||
if(!t||t.tagName!=="SCRIPT"||!t.src)return;
|
||||
var m=t.src.match(/gsap[^/]*\\/dist\\/(.+\\.js)/);
|
||||
if(m)loadFallback(m[1]);
|
||||
},true);
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
function injectGsapCdnFallback(html: string): string {
|
||||
if (html.includes("data-hf-gsap-fallback")) return html;
|
||||
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
|
||||
return GSAP_CDN_FALLBACK_SCRIPT + html;
|
||||
}
|
||||
|
||||
function injectStudioPreviewAugmentations(
|
||||
html: string,
|
||||
adapter: StudioApiAdapter,
|
||||
@@ -118,7 +145,9 @@ function injectStudioPreviewAugmentations(
|
||||
activeCompositionPath: string,
|
||||
): string {
|
||||
return injectStudioMotionScript(
|
||||
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
|
||||
injectGsapCdnFallback(
|
||||
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
|
||||
),
|
||||
projectDir,
|
||||
activeCompositionPath,
|
||||
);
|
||||
|
||||
@@ -575,6 +575,7 @@ function inlineSubCompositions(
|
||||
},
|
||||
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
|
||||
scriptErrorLabel: "[Compiler] Composition script failed",
|
||||
compoundAuthoredRoot: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -871,6 +872,23 @@ export interface CompileForRenderOptions {
|
||||
failClosedFontFetch?: boolean;
|
||||
}
|
||||
|
||||
const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
|
||||
|
||||
function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string {
|
||||
return html.replace(
|
||||
/(<script\b[^>]*\bsrc=["'])([^"']*gsap[^"']*\/dist\/([^"']+))(["'][^>]*>)/gi,
|
||||
(full, prefix, src, file, suffix) => {
|
||||
if (/^https?:\/\//i.test(src)) return full;
|
||||
const absPath = resolve(projectDir, src);
|
||||
if (existsSync(absPath)) return full;
|
||||
console.log(
|
||||
`[Compiler] Rewriting missing gsap script to CDN: ${src} → ${GSAP_CDN_BASE}${file}`,
|
||||
);
|
||||
return `${prefix}${GSAP_CDN_BASE}${file}${suffix}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile an HTML composition project into a single self-contained HTML string
|
||||
* with all media metadata resolved.
|
||||
@@ -881,7 +899,7 @@ export async function compileForRender(
|
||||
downloadDir: string,
|
||||
options: CompileForRenderOptions = {},
|
||||
): Promise<CompiledComposition> {
|
||||
const rawHtml = readFileSync(htmlPath, "utf-8");
|
||||
const rawHtml = rewriteUnresolvableGsapToCdn(readFileSync(htmlPath, "utf-8"), projectDir);
|
||||
const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(
|
||||
rawHtml,
|
||||
projectDir,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d2ca0d92ccf9740ad67c4ea46f50f65c3e3cefac88a07a171bd0fdcaf865efe0
|
||||
size 13513666
|
||||
oid sha256:36ef88d84340f4ab2aabc17b702d09139fb6614475a4497f1995fd7be539dede
|
||||
size 12350905
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "wysiwyg-subcomp-css",
|
||||
"description": "Verifies sub-composition CSS is correctly scoped and applied in rendered output. Regression test for compound-selector bug where [data-composition-id] [data-hf-authored-id] used a descendant combinator (space) instead of compound (no space) when both attributes coexist on the same host element after inlining.",
|
||||
"tags": ["regression", "css-scoping", "sub-compositions", "wysiwyg"],
|
||||
"minPsnr": 25,
|
||||
"maxFrameFailures": 2,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 0,
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b2ee4eefee44ae1ec3e957c3a4a03b72edcd4e44b375965edf3334e3212de644
|
||||
size 9290
|
||||
@@ -0,0 +1,47 @@
|
||||
<template id="overlay-template">
|
||||
<div id="overlay-root" data-composition-id="overlay" data-start="0" data-width="1280" data-height="720" data-duration="2">
|
||||
<div class="chrome">
|
||||
<div class="brand">WYSIWYG Test</div>
|
||||
<div class="badge">CSS Loaded</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#overlay-root {
|
||||
--accent: #3cE6ac;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#overlay-root .chrome {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#overlay-root .brand {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
#overlay-root .badge {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: rgba(60, 230, 172, 0.15);
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0a; }
|
||||
#root {
|
||||
position: relative;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
#root > .layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="2" data-width="1280" data-height="720">
|
||||
<div id="overlay-layer" class="layer"
|
||||
data-composition-id="overlay"
|
||||
data-composition-src="compositions/overlay.html"
|
||||
data-start="0" data-duration="2" data-track-index="0">
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class StudioErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("[Studio] Uncaught error:", error, info.componentStack);
|
||||
trackStudioEvent("crash", {
|
||||
error_message: error.message,
|
||||
error_name: error.name,
|
||||
component_stack: info.componentStack?.slice(0, 500) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#0a0a0a",
|
||||
color: "#e5e5e5",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
|
||||
<div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => this.setState({ error: null })}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: "8px 20px",
|
||||
background: "#2563eb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getHistoryShortcutLabel } from "../utils/studioHelpers";
|
||||
import { useStudioContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useDomEditContext } from "../contexts/DomEditContext";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
export interface StudioHeaderProps {
|
||||
captureFrameHref: string;
|
||||
@@ -165,7 +166,10 @@ export function StudioHeader({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleUndo()}
|
||||
onClick={() => {
|
||||
trackStudioEvent("toolbar_action", { action: "undo" });
|
||||
void handleUndo();
|
||||
}}
|
||||
disabled={!editHistory.canUndo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
editHistory.canUndo
|
||||
@@ -183,7 +187,10 @@ export function StudioHeader({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRedo()}
|
||||
onClick={() => {
|
||||
trackStudioEvent("toolbar_action", { action: "redo" });
|
||||
void handleRedo();
|
||||
}}
|
||||
disabled={!editHistory.canRedo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
editHistory.canRedo
|
||||
@@ -202,7 +209,10 @@ export function StudioHeader({
|
||||
<a
|
||||
href={captureFrameHref}
|
||||
download={captureFrameFilename}
|
||||
onClick={handleCaptureFrameClick}
|
||||
onClick={(e) => {
|
||||
trackStudioEvent("toolbar_action", { action: "capture_frame" });
|
||||
handleCaptureFrameClick(e);
|
||||
}}
|
||||
onFocus={refreshCaptureFrameTime}
|
||||
onPointerDown={refreshCaptureFrameTime}
|
||||
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
||||
@@ -217,10 +227,12 @@ export function StudioHeader({
|
||||
onClick={() => {
|
||||
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
||||
if (rightCollapsed || !inspectorPanelActive) {
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
||||
setRightPanelTab("design");
|
||||
setRightCollapsed(false);
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
|
||||
clearDomSelection();
|
||||
setRightCollapsed(true);
|
||||
}}
|
||||
|
||||
@@ -74,7 +74,10 @@ function TimingSection({
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
}) {
|
||||
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
||||
const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0;
|
||||
const duration =
|
||||
Number.parseFloat(
|
||||
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
||||
) || 0;
|
||||
const end = start + duration;
|
||||
|
||||
const commitStart = (nextValue: string) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowLeft, CaretRight } from "@phosphor-icons/react";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
|
||||
export interface CompositionLevel {
|
||||
/** Unique id — "master" or composition file path */
|
||||
@@ -25,7 +26,13 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
||||
{/* Back button — always goes to parent */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(stack.length - 2)}
|
||||
onClick={() => {
|
||||
trackStudioEvent("navigation", {
|
||||
action: "back",
|
||||
target: stack[stack.length - 2]?.label,
|
||||
});
|
||||
onNavigate(stack.length - 2);
|
||||
}}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
|
||||
title="Back (Esc)"
|
||||
>
|
||||
@@ -43,7 +50,10 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(i)}
|
||||
onClick={() => {
|
||||
trackStudioEvent("navigation", { action: "breadcrumb", target: level.label });
|
||||
onNavigate(i);
|
||||
}}
|
||||
className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
{level.label}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useState, useRef, useEffect } from "react";
|
||||
import { RenderQueueItem } from "./RenderQueueItem";
|
||||
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
||||
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
|
||||
export interface CompositionDimensions {
|
||||
width: number;
|
||||
@@ -277,6 +278,7 @@ function FormatExportButton({
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
trackStudioEvent("render_start", { format, quality, resolution, fps });
|
||||
void onStartRender(format, quality, resolution, fps);
|
||||
}}
|
||||
disabled={isRendering}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
import { AssetsTab } from "./AssetsTab";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
import { BlocksTab } from "./BlocksTab";
|
||||
import { FileTree } from "../editor/FileTree";
|
||||
import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
|
||||
@@ -90,6 +91,7 @@ export const LeftSidebar = memo(
|
||||
const selectTab = useCallback((t: SidebarTab) => {
|
||||
setTab(t);
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function FileManagerProvider({
|
||||
setFileTree,
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveTimerRef,
|
||||
saveRafRef,
|
||||
importedFontAssetsRef,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
@@ -59,7 +59,7 @@ export function FileManagerProvider({
|
||||
setFileTree,
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveTimerRef,
|
||||
saveRafRef,
|
||||
importedFontAssetsRef,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
@@ -91,7 +91,7 @@ export function FileManagerProvider({
|
||||
setFileTree,
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveTimerRef,
|
||||
saveRafRef,
|
||||
importedFontAssetsRef,
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { FONT_EXT } from "../utils/mediaTypes";
|
||||
import { applyPatchByTarget } from "../utils/sourcePatcher";
|
||||
import type { PatchOperation } from "../utils/sourcePatcher";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
||||
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
|
||||
@@ -45,7 +46,7 @@ interface RecordEditInput {
|
||||
|
||||
export type PersistDomEditOperations = (
|
||||
selection: DomEditSelection,
|
||||
operations: Parameters<typeof applyPatchByTarget>[2][],
|
||||
operations: PatchOperation[],
|
||||
options?: {
|
||||
label?: string;
|
||||
coalesceKey?: string;
|
||||
@@ -134,39 +135,61 @@ export function useDomEditCommits({
|
||||
if (options?.shouldSave && !options.shouldSave()) return;
|
||||
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read ${targetPath}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { content?: string };
|
||||
const originalContent = data.content;
|
||||
const readResponse = await fetch(
|
||||
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||
);
|
||||
if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
|
||||
const readData = (await readResponse.json()) as { content?: string };
|
||||
const originalContent = readData.content;
|
||||
if (typeof originalContent !== "string") {
|
||||
throw new Error(`Missing file contents for ${targetPath}`);
|
||||
}
|
||||
|
||||
let patchedContent = originalContent;
|
||||
for (const operation of operations) {
|
||||
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
|
||||
}
|
||||
if (options?.prepareContent) {
|
||||
patchedContent = options.prepareContent(patchedContent, targetPath);
|
||||
}
|
||||
if (options?.shouldSave && !options.shouldSave()) return;
|
||||
|
||||
if (patchedContent === originalContent) {
|
||||
const patchTarget: { id?: string | null; selector?: string; selectorIndex?: number } = {
|
||||
id: selection.id,
|
||||
selector: selection.selector,
|
||||
selectorIndex: selection.selectorIndex,
|
||||
};
|
||||
|
||||
const patchResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget, operations }),
|
||||
},
|
||||
);
|
||||
if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
|
||||
|
||||
const patchData = (await patchResponse.json()) as {
|
||||
ok?: boolean;
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
if (!patchData.changed) {
|
||||
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
|
||||
}
|
||||
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
const patchedContent =
|
||||
typeof patchData.content === "string" ? patchData.content : originalContent;
|
||||
|
||||
let finalContent = patchedContent;
|
||||
if (options?.prepareContent) {
|
||||
finalContent = options.prepareContent(patchedContent, targetPath);
|
||||
if (finalContent !== patchedContent) {
|
||||
await writeProjectFile(targetPath, finalContent);
|
||||
}
|
||||
}
|
||||
|
||||
await editHistory.recordEdit({
|
||||
label: options?.label ?? "Edit layer",
|
||||
kind: "manual",
|
||||
coalesceKey: options?.coalesceKey,
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
||||
});
|
||||
|
||||
if (options?.skipRefresh) {
|
||||
@@ -177,7 +200,7 @@ export function useDomEditCommits({
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
editHistory.recordEdit,
|
||||
editHistory,
|
||||
writeProjectFile,
|
||||
projectIdRef,
|
||||
domEditSaveTimestampRef,
|
||||
@@ -212,7 +235,7 @@ export function useDomEditCommits({
|
||||
const commitPositionPatchToHtml = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
patches: Parameters<typeof applyPatchByTarget>[2][],
|
||||
patches: PatchOperation[],
|
||||
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
||||
) => {
|
||||
void queueDomEditSave(async () => {
|
||||
@@ -224,6 +247,11 @@ export function useDomEditCommits({
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to save position";
|
||||
showToast(message);
|
||||
trackStudioEvent("save_failure", {
|
||||
source: "dom_edit",
|
||||
label: options.label,
|
||||
error_message: message,
|
||||
});
|
||||
});
|
||||
},
|
||||
[persistDomEditOperations, queueDomEditSave, showToast],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/e
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -48,8 +49,8 @@ export function useFileManager({
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveRafRef = useRef<number | null>(null);
|
||||
const refreshRafRef = useRef<number | null>(null);
|
||||
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
||||
|
||||
// ── Load file tree when projectId changes ──
|
||||
@@ -145,12 +146,8 @@ export function useFileManager({
|
||||
const path = editingPathRef.current;
|
||||
if (!path) return;
|
||||
|
||||
// Debounce the server write (600ms)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
// Suppress the file-change watcher echo — the save callback triggers
|
||||
// its own refresh, so a second one from the watcher causes a double-reload
|
||||
// race that can leave the player in a non-playable state.
|
||||
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
||||
saveRafRef.current = requestAnimationFrame(() => {
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
@@ -163,11 +160,16 @@ export function useFileManager({
|
||||
recordEdit,
|
||||
})
|
||||
.then(() => {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
|
||||
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
||||
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 600);
|
||||
.catch((error) => {
|
||||
trackStudioEvent("save_failure", {
|
||||
source: "code_editor",
|
||||
error_message: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
|
||||
);
|
||||
@@ -449,7 +451,7 @@ export function useFileManager({
|
||||
// Refs
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveTimerRef,
|
||||
saveRafRef,
|
||||
importedFontAssetsRef,
|
||||
|
||||
// Core I/O
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
export interface InitialPanelLayoutState {
|
||||
rightCollapsed?: boolean | null;
|
||||
@@ -26,6 +27,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
const toggleLeftSidebar = useCallback(() => {
|
||||
setLeftCollapsed((collapsed) => {
|
||||
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
||||
trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
|
||||
return !collapsed;
|
||||
});
|
||||
}, []);
|
||||
@@ -63,6 +65,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
panelDragRef.current = null;
|
||||
}, []);
|
||||
|
||||
const trackedSetRightPanelTab = useCallback(
|
||||
(tab: RightPanelTab) => {
|
||||
setRightPanelTab(tab);
|
||||
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
||||
},
|
||||
[setRightPanelTab],
|
||||
);
|
||||
|
||||
return {
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
@@ -72,7 +82,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
rightCollapsed,
|
||||
setRightCollapsed,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
setRightPanelTab: trackedSetRightPanelTab,
|
||||
toggleLeftSidebar,
|
||||
handlePanelResizeStart,
|
||||
handlePanelResizeMove,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
|
||||
@@ -67,5 +67,15 @@ export function useServerConnection(): ServerConnectionState {
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const next = parseProjectIdFromHash(window.location.hash);
|
||||
if (next && next !== projectId) setProjectId(next);
|
||||
};
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, [projectId]);
|
||||
|
||||
return { projectId, resolving, waitingForServer };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { StudioApp } from "./App";
|
||||
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
||||
import { trackStudioEvent } from "./utils/studioTelemetry";
|
||||
import "./styles/studio.css";
|
||||
|
||||
trackStudioEvent("session_start");
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
trackStudioEvent("unhandled_error", {
|
||||
error_message: event.message,
|
||||
filename: event.filename ?? null,
|
||||
lineno: event.lineno ?? null,
|
||||
colno: event.colno ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
trackStudioEvent("unhandled_promise_rejection", {
|
||||
error_message: event.reason instanceof Error ? event.reason.message : String(event.reason),
|
||||
});
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<StudioApp />
|
||||
<StudioErrorBoundary>
|
||||
<StudioApp />
|
||||
</StudioErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
|
||||
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
||||
const SEEK_EDGE_SNAP_PX = 8;
|
||||
@@ -337,7 +338,10 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
onClick={onTogglePlay}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
||||
onTogglePlay();
|
||||
}}
|
||||
disabled={controlsDisabled}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
@@ -463,7 +467,10 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!audioAutoMuted) setAudioMuted(!audioMuted);
|
||||
if (!audioAutoMuted) {
|
||||
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
|
||||
setAudioMuted(!audioMuted);
|
||||
}
|
||||
}}
|
||||
disabled={controlsDisabled || audioAutoMuted}
|
||||
title={muteButtonLabel}
|
||||
@@ -530,6 +537,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "speed_change", rate });
|
||||
setPlaybackRate(rate);
|
||||
setShowSpeedMenu(false);
|
||||
}}
|
||||
@@ -555,7 +563,10 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoopEnabled(!loopEnabled)}
|
||||
onClick={() => {
|
||||
trackStudioEvent("playback", { action: "loop_toggle", enabled: !loopEnabled });
|
||||
setLoopEnabled(!loopEnabled);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
loopEnabled
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
|
||||
|
||||
export interface MediaProbeResult {
|
||||
duration: number;
|
||||
width?: number;
|
||||
@@ -11,6 +9,20 @@ export interface MediaProbeResult {
|
||||
const cache = new Map<string, MediaProbeResult>();
|
||||
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
||||
|
||||
let mediabunnyModule: typeof import("mediabunny") | null | false = null;
|
||||
|
||||
async function loadMediabunny() {
|
||||
if (mediabunnyModule === false) return null;
|
||||
if (mediabunnyModule) return mediabunnyModule;
|
||||
try {
|
||||
mediabunnyModule = await import("mediabunny");
|
||||
return mediabunnyModule;
|
||||
} catch {
|
||||
mediabunnyModule = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url, window.location.href).href;
|
||||
@@ -20,9 +32,12 @@ function normalizeUrl(url: string): string {
|
||||
}
|
||||
|
||||
async function probeOne(url: string): Promise<MediaProbeResult | null> {
|
||||
const input = new Input({
|
||||
source: new UrlSource(url),
|
||||
formats: ALL_FORMATS,
|
||||
const mb = await loadMediabunny();
|
||||
if (!mb) return null;
|
||||
|
||||
const input = new mb.Input({
|
||||
source: new mb.UrlSource(url),
|
||||
formats: mb.ALL_FORMATS,
|
||||
});
|
||||
try {
|
||||
const duration = await input.getDurationFromMetadata();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// PostHog public ingest key — write-only, safe to ship in the client bundle
|
||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
const FLUSH_INTERVAL_MS = 30_000;
|
||||
const FLUSH_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface EventProperties {
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
interface QueuedEvent {
|
||||
event: string;
|
||||
properties: EventProperties;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
let queue: QueuedEvent[] = [];
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let distinctId: string | null = null;
|
||||
|
||||
function getDistinctId(): string {
|
||||
if (distinctId) return distinctId;
|
||||
try {
|
||||
const stored = localStorage.getItem("hf-studio-anon-id");
|
||||
if (stored) {
|
||||
distinctId = stored;
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
distinctId = crypto.randomUUID();
|
||||
try {
|
||||
localStorage.setItem("hf-studio-anon-id", distinctId);
|
||||
} catch {
|
||||
// best-effort persistence
|
||||
}
|
||||
return distinctId;
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem("hf-studio-telemetry-opt-out") !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionProperties(): EventProperties {
|
||||
return {
|
||||
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
|
||||
screen_width: window.screen?.width,
|
||||
screen_height: window.screen?.height,
|
||||
viewport_width: window.innerWidth,
|
||||
viewport_height: window.innerHeight,
|
||||
user_agent: navigator.userAgent,
|
||||
url_hash: location.hash.replace(/#project\//, ""),
|
||||
};
|
||||
}
|
||||
|
||||
declare const __STUDIO_VERSION__: string;
|
||||
|
||||
export function trackStudioEvent(event: string, properties: EventProperties = {}): void {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
queue.push({
|
||||
event: `studio:${event}`,
|
||||
properties: { ...getSessionProperties(), ...properties },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (!flushTimer) {
|
||||
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function flushEvents(): Promise<void> {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const batch = queue.map((e) => ({
|
||||
event: e.event,
|
||||
properties: { ...e.properties, $ip: null },
|
||||
distinct_id: getDistinctId(),
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
queue = [];
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await fetch(`${POSTHOG_HOST}/batch/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry must never break the studio
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (queue.length === 0) return;
|
||||
const batch = queue.map((e) => ({
|
||||
event: e.event,
|
||||
properties: { ...e.properties, $ip: null },
|
||||
distinct_id: getDistinctId(),
|
||||
timestamp: e.timestamp,
|
||||
}));
|
||||
queue = [];
|
||||
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
|
||||
try {
|
||||
navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -166,6 +166,9 @@ function devProjectApi(): Plugin {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), devProjectApi()],
|
||||
define: {
|
||||
__STUDIO_VERSION__: JSON.stringify(process.env.npm_package_version ?? "dev"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),
|
||||
|
||||
Reference in New Issue
Block a user