diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a0130b782..c2d95aaf9 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -264,6 +264,10 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // sourcePatcher.ts: pre-existing internal clones between the inline-style + // and attribute tag-patchers; only the PatchOperation type gained two + // optional fields here, but the line shift makes fallow re-flag them. + "packages/studio/src/utils/sourcePatcher.ts", // gsapParser.ts: recast/babel GSAP writer — intentional duplication between // recast and acorn parallel implementations (pre-existing, moved from core). "packages/parsers/src/gsapParser.ts", @@ -364,6 +368,11 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / + // patch*InTag pre-date this PR; only the PatchOperation type gained two + // optional fields, but the line-shift fingerprint re-flags the inherited + // complexity. + "packages/studio/src/utils/sourcePatcher.ts", // timeline.ts: collectRuntimeTimelinePayload (CRITICAL) pre-dates this PR; // only an import line changed here (slideshow/sceneId → slideshow/index), // but the line-shift fingerprint makes fallow re-flag inherited complexity. diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts index 45927cfe7..1dd6a1ffa 100644 --- a/packages/studio/src/components/editor/domEditing.ts +++ b/packages/studio/src/components/editor/domEditing.ts @@ -31,6 +31,7 @@ export { buildDomEditTextPatchOperation, collectDomEditLayerItems, countDomEditChildLayers, + buildTextFieldChildLocator, getDomEditLayerKey, getDomEditNonEditableReason, getDomEditTargetKey, diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts index 04099ae91..b81728994 100644 --- a/packages/studio/src/components/editor/domEditingLayers.test.ts +++ b/packages/studio/src/components/editor/domEditingLayers.test.ts @@ -4,11 +4,27 @@ import { collectDomEditLayerItems, resolveDomEditSelection, buildDomEditPatchTarget, + buildTextFieldChildLocator, readHfId, } from "./domEditingLayers"; +import type { DomEditTextField } from "./domEditingTypes"; const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true }; +function textField(overrides: Partial = {}): DomEditTextField { + return { + key: "child:0:span", + label: "Text 1", + value: "Hello", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "child", + ...overrides, + }; +} + describe("buildDomEditPatchTarget", () => { it("includes hfId when selection has hfId", () => { const target = buildDomEditPatchTarget({ @@ -172,3 +188,39 @@ describe("resolveDomEditSelection — data-hf-group capture", () => { expect(selection?.id).toBe("outside"); }); }); + +describe("buildTextFieldChildLocator", () => { + it("locates a child field using its DOM-derived sourceChildIndex", () => { + const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })]; + + expect(buildTextFieldChildLocator(fields, "child:0:span")).toEqual({ + childSelector: ":scope > span", + childIndex: 0, + }); + }); + + it("fails closed for a synthetic child field with no sourceChildIndex", () => { + // A field built by buildDefaultDomEditTextField (e.g. "add text field") + // has never been read back from the live DOM, so its true position among + // same-tag siblings is unknown. Guessing it by counting same-tag "child" + // fields elsewhere in the array can silently point at the wrong element. + const fields = [ + textField({ key: "child:0:span", sourceChildIndex: 0 }), + textField({ key: "child:new:1", tagName: "span" }), + ]; + + expect(buildTextFieldChildLocator(fields, "child:new:1")).toBeNull(); + }); + + it("returns null for a self-sourced field", () => { + const fields = [textField({ key: "self:0:div", source: "self", sourceChildIndex: 0 })]; + + expect(buildTextFieldChildLocator(fields, "self:0:div")).toBeNull(); + }); + + it("returns null for an unknown field key", () => { + const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })]; + + expect(buildTextFieldChildLocator(fields, "missing")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 787444e8d..3102d030f 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -1,7 +1,3 @@ -/** - * Layer items, text fields, capabilities, selection resolution, and patch operations - * for dom editing. - */ import type { PatchOperation } from "../../utils/sourcePatcher"; import { resolveEditingAffordances, @@ -36,12 +32,20 @@ import { } from "./domEditingElement"; import { isCompositionRootLayer } from "./domEditingRootLayer"; -// ─── Text fields ──────────────────────────────────────────────────────────── - export function isEditableTextLeaf(el: HTMLElement): boolean { return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0; } +function sameTagChildIndex(el: HTMLElement): number { + let index = 0; + let sibling = el.previousElementSibling; + while (sibling) { + if (sibling.tagName === el.tagName) index += 1; + sibling = sibling.previousElementSibling; + } + return index; +} + function getTextFieldLabel( _tagName: string, index: number, @@ -57,6 +61,7 @@ function buildTextField( index: number, total: number, source: "self" | "child", + sourceChildIndex?: number, ): DomEditTextField { const tagName = el.tagName.toLowerCase(); const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`; @@ -74,6 +79,7 @@ function buildTextField( inlineStyles: getInlineStyles(el), computedStyles: getCuratedComputedStyles(el), source, + ...(sourceChildIndex == null ? {} : { sourceChildIndex }), }; } @@ -105,7 +111,9 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] { }); childIdx++; } else if (isHtmlElement(node) && isEditableTextLeaf(node)) { - fields.push(buildTextField(node, childIdx, childElements.length, "child")); + fields.push( + buildTextField(node, childIdx, childElements.length, "child", sameTagChildIndex(node)), + ); childIdx++; } } @@ -113,7 +121,7 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] { } return childElements.map((child, index) => - buildTextField(child, index, childElements.length, "child"), + buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)), ); } @@ -172,14 +180,30 @@ export function buildDefaultDomEditTextField(base?: Partial): }; } -// ─── Capabilities ──────────────────────────────────────────────────────────── +export interface DomEditChildLocator { + childSelector: string; + childIndex: number; +} + +export function buildTextFieldChildLocator( + fields: DomEditTextField[], + fieldKey: string, +): DomEditChildLocator | null { + const field = fields.find((candidate) => candidate.key === fieldKey); + if (!field || field.source !== "child") return null; + // sourceChildIndex is only absent for a synthetic field that was never read + // back from the live DOM (e.g. one built by buildDefaultDomEditTextField). + // Guessing its position by counting same-tag "child" fields elsewhere in + // the array is unreliable and can silently locate the wrong element — fail + // closed instead so the caller falls back to the unsupported-structure path. + if (field.sourceChildIndex == null) return null; + + return { + childSelector: `:scope > ${field.tagName}`, + childIndex: field.sourceChildIndex, + }; +} -/** - * Build the geometry/capability half of EditableElementFacts. Section inputs - * (text/timing/animation) are irrelevant to capability resolution, so they are - * zeroed here. Shared by the wrapper and the live-selection path so the two - * fact-construction sites can't disagree. - */ function capabilityFacts(geometry: { hasStableTarget: boolean; tag: string; @@ -276,8 +300,11 @@ async function probeSourceElement( }, ); if (!response.ok) return true; - const data = (await response.json()) as { exists?: boolean }; - return data.exists !== false; + const data = await response.json(); + if (data && typeof data === "object" && "exists" in data && data.exists === false) { + return false; + } + return true; } catch { return true; } @@ -475,19 +502,28 @@ export function collectDomEditLayerItems( // ─── Patch operations ──────────────────────────────────────────────────────── -export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation { +export function buildDomEditStylePatchOperation( + property: string, + value: string | null, + childLocator?: DomEditChildLocator, +): PatchOperation { return { type: "inline-style", property, value, + ...childLocator, }; } -export function buildDomEditTextPatchOperation(value: string): PatchOperation { +export function buildDomEditTextPatchOperation( + value: string, + childLocator?: DomEditChildLocator, +): PatchOperation { return { type: "text-content", property: "text", value, + ...childLocator, }; } diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index d6ec3cafd..c25ce1995 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -69,6 +69,7 @@ export interface DomEditTextField { inlineStyles: Record; computedStyles: Record; source: "self" | "child" | "text-node"; + sourceChildIndex?: number; } export interface DomEditSelection extends PatchTarget { diff --git a/packages/studio/src/components/editor/persistSeam.integration.test.ts b/packages/studio/src/components/editor/persistSeam.integration.test.ts new file mode 100644 index 000000000..e3ff60886 --- /dev/null +++ b/packages/studio/src/components/editor/persistSeam.integration.test.ts @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + patchElementInHtml, + type PatchOperation, + type SourceMutationTarget, +} from "@hyperframes/studio-server/source-mutation"; +import { describe, expect, it } from "vitest"; +import { + collectDomEditTextFields, + buildDomEditPatchTarget, + buildDomEditStylePatchOperation, + buildDomEditTextPatchOperation, +} from "./domEditingLayers"; +import { buildPathOffsetPatches } from "./manualEditsDomPatches"; +import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes"; +import { makeSelection } from "../../hooks/domSelectionTestHarness"; +import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const fixtureDir = join(testDir, "../../../tests/e2e/fixtures/design-panel-qa"); + +function readFixture(relativePath: string): string { + return readFileSync(join(fixtureDir, relativePath), "utf-8"); +} + +function createSelection(input: { + id: string; + hfId: string; + tagName: string; +}): ReturnType { + const element = document.createElement(input.tagName); + element.id = input.id; + element.setAttribute("data-hf-id", input.hfId); + return { + ...makeSelection(input.id, element), + hfId: input.hfId, + }; +} + +function clientTarget(input: { id: string; hfId: string; tagName: string }): SourceMutationTarget { + return buildDomEditPatchTarget(createSelection(input)); +} + +function patchAndExpectChange( + sourceHtml: string, + target: SourceMutationTarget, + operations: PatchOperation[], +): string { + const result = patchElementInHtml(sourceHtml, target, operations); + expect(result.matched).toBe(true); + expect(result.html).not.toBe(sourceHtml); + return result.html; +} + +function parseHtml(html: string): Document { + return new DOMParser().parseFromString(html, "text/html"); +} + +function findElementInHtml(html: string, selector: string): Element { + const document = parseHtml(html); + const directMatch = document.querySelector(selector); + if (directMatch) return directMatch; + + for (const template of Array.from(document.querySelectorAll("template"))) { + const templateMatch = template.content.querySelector(selector); + if (templateMatch) return templateMatch; + } + + throw new Error(`Expected selector ${selector} to match`); +} + +function findByHfId(html: string, hfId: string): Element { + return findElementInHtml(html, `[data-hf-id="${hfId}"]`); +} + +function countOccurrences(value: string, needle: string): number { + return value.split(needle).length - 1; +} + +describe("persist seam source mutation", () => { + const indexHtml = readFixture("index.html"); + const subHtml = readFixture("compositions/qa-sub.html"); + + it("persists qa-headline text font-size style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-headline", hfId: "qa-headline", tagName: "h1" }), + [buildDomEditStylePatchOperation("font-size", "64px")], + ); + + expect(findByHfId(html, "qa-headline").getAttribute("style")).toContain("font-size: 64px"); + }); + + it("persists qa-shape fill style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }), + [buildDomEditStylePatchOperation("background-color", "#ff0000")], + ); + + expect(findByHfId(html, "qa-shape").getAttribute("style")).toContain( + "background-color: #ff0000", + ); + }); + + it("persists qa-multi text color style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-multi", hfId: "qa-multi", tagName: "div" }), + [buildDomEditStylePatchOperation("color", "#00ff00")], + ); + + expect(findByHfId(html, "qa-multi").getAttribute("style")).toContain("color: #00ff00"); + }); + + it("persists qa-image opacity style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-image", hfId: "qa-image", tagName: "img" }), + [buildDomEditStylePatchOperation("opacity", "0.4")], + ); + + expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4"); + }); + + it("persists detached jsdom path offset operations", () => { + const element = document.createElement("div"); + element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px"); + + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }), + buildPathOffsetPatches(element), + ); + const shape = findByHfId(html, "qa-shape"); + + expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`); + expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)"); + expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true"); + }); + + it("persists timeline data-start attribute operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-zone-headline", hfId: "qa-zone-headline", tagName: "div" }), + [{ type: "attribute", property: "start", value: "2.5" }], + ); + + expect(findByHfId(html, "qa-zone-headline").getAttribute("data-start")).toBe("2.5"); + expect(countOccurrences(html, 'data-start="2.5"')).toBe(1); + }); + + it("persists media volume data attribute operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-video", hfId: "qa-video", tagName: "video" }), + [{ type: "attribute", property: "volume", value: "0.75" }], + ); + + expect(findByHfId(html, "qa-video").getAttribute("data-volume")).toBe("0.75"); + expect(html).not.toContain('data-volume="0.5"'); + }); + + it("returns matched false and unchanged html for a missing hfId target", () => { + const result = patchElementInHtml(indexHtml, { hfId: "qa-does-not-exist" }, [ + buildDomEditStylePatchOperation("font-size", "64px"), + ]); + + expect(result.matched).toBe(false); + expect(result.html).toBe(indexHtml); + }); + + it("persists sub-composition child style operation inside a template", () => { + const html = patchAndExpectChange( + subHtml, + clientTarget({ id: "qa-sub-title", hfId: "qa-sub-title", tagName: "h2" }), + [buildDomEditStylePatchOperation("font-size", "50px")], + ); + + expect(findByHfId(html, "qa-sub-title").getAttribute("style")).toContain("font-size: 50px"); + }); + + it("returns matched false for runtime-generated caption words absent from static source", () => { + const result = patchElementInHtml( + indexHtml, + { selector: "#qa-caption-host span", selectorIndex: 0 }, + [buildDomEditStylePatchOperation("color", "#ffffff")], + ); + + expect(result.matched).toBe(false); + expect(result.html).toBe(indexHtml); + }); + + it("fixes U4: child text-field style persists as an inline style on the correct child span", () => { + const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [ + buildDomEditStylePatchOperation("color", "#0000ff", { + childSelector: ":scope > span", + childIndex: 0, + }), + ]); + + const lineA = findElementInHtml(html, ".qa-line-a"); + const lineB = findElementInHtml(html, ".qa-line-b"); + expect(lineA.getAttribute("style")).toContain("color: #0000ff"); + expect(lineB.getAttribute("style")).toBeNull(); + expect(lineB.textContent).toBe("Second styled line"); + expect(html).not.toContain("<span"); + }); + + it("targets the second direct child when siblings share the same tag and class", () => { + const source = `
FirstSecond
`; + const html = patchAndExpectChange(source, { hfId: "dups" }, [ + buildDomEditStylePatchOperation("color", "#0000ff", { + childSelector: ":scope > span", + childIndex: 1, + }), + ]); + + const document = parseHtml(html); + const spans = Array.from(document.querySelectorAll(".dup")); + expect(spans[0]?.getAttribute("style")).toBeNull(); + expect(spans[1]?.getAttribute("style")).toContain("color: #0000ff"); + }); + + it("persists a child text-field content edit as plain text", () => { + const value = "A < B & C"; + const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [ + buildDomEditTextPatchOperation(value, { + childSelector: ":scope > span", + childIndex: 0, + }), + ]); + + expect(findElementInHtml(html, ".qa-line-a").textContent).toBe(value); + expect(findElementInHtml(html, ".qa-line-b").textContent).toBe("Second styled line"); + expect(html).not.toContain("<span"); + }); + + it("uses same-tag source child indexes when a non-leaf sibling sits between fields", () => { + const source = `
FirstWrapperSecond
`; + const previewHost = document.createElement("div"); + previewHost.innerHTML = source; + const previewTarget = previewHost.querySelector('[data-hf-id="mixed"]'); + if (!(previewTarget instanceof HTMLElement)) throw new Error("Expected preview target"); + + const originalFields = collectDomEditTextFields(previewTarget); + const secondField = originalFields.find((field) => field.value === "Second"); + if (!secondField) throw new Error("Expected second text field"); + const nextFields = originalFields.map((field) => + field.key === secondField.key ? { ...field, value: "Second updated" } : field, + ); + const operations = buildTextFieldChildOperations(originalFields, nextFields); + if (!operations) throw new Error("Expected child operations"); + + const html = patchAndExpectChange(source, { hfId: "mixed" }, operations); + + expect(findElementInHtml(html, ".leaf-a").textContent).toBe("First"); + expect(findElementInHtml(html, ".wrapper").textContent).toBe("Wrapper"); + expect(findElementInHtml(html, ".leaf-b").textContent).toBe("Second updated"); + }); +}); diff --git a/packages/studio/src/hooks/domEditTextFieldCommitOps.test.ts b/packages/studio/src/hooks/domEditTextFieldCommitOps.test.ts new file mode 100644 index 000000000..9ff82b619 --- /dev/null +++ b/packages/studio/src/hooks/domEditTextFieldCommitOps.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { DomEditTextField } from "../components/editor/domEditing"; +import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps"; + +function textField(input: { + key: string; + value: string; + tagName?: string; + inlineStyles?: Record; + source?: DomEditTextField["source"]; + sourceChildIndex?: number; +}): DomEditTextField { + return { + key: input.key, + label: input.key, + value: input.value, + tagName: input.tagName ?? "span", + attributes: [], + inlineStyles: input.inlineStyles ?? {}, + computedStyles: {}, + source: input.source ?? "child", + ...(input.sourceChildIndex == null ? {} : { sourceChildIndex: input.sourceChildIndex }), + }; +} + +describe("buildTextFieldChildOperations", () => { + it("builds child-scoped text and style operations for changed child fields", () => { + const originalFields = [ + textField({ + key: "first", + value: "First", + inlineStyles: { color: "red" }, + sourceChildIndex: 0, + }), + textField({ + key: "second", + value: "Second", + inlineStyles: { "font-size": "24px" }, + sourceChildIndex: 1, + }), + ]; + const nextFields = [ + originalFields[0], + textField({ + key: "second", + value: "Second < &", + inlineStyles: { "font-size": "24px", color: "#0000ff" }, + sourceChildIndex: 1, + }), + ]; + + expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([ + { + type: "text-content", + property: "text", + value: "Second < &", + childSelector: ":scope > span", + childIndex: 1, + }, + { + type: "inline-style", + property: "color", + value: "#0000ff", + childSelector: ":scope > span", + childIndex: 1, + }, + ]); + }); + + it("emits null for a removed inline style", () => { + const originalFields = [ + textField({ + key: "first", + value: "First", + inlineStyles: { color: "red" }, + sourceChildIndex: 0, + }), + ]; + const nextFields = [ + textField({ key: "first", value: "First", inlineStyles: {}, sourceChildIndex: 0 }), + ]; + + expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([ + { + type: "inline-style", + property: "color", + value: null, + childSelector: ":scope > span", + childIndex: 0, + }, + ]); + }); + + it("returns null for structural changes, reordered fields, and text nodes", () => { + const originalFields = [ + textField({ key: "first", value: "First" }), + textField({ key: "second", value: "Second" }), + ]; + + expect(buildTextFieldChildOperations(originalFields, [originalFields[0]])).toBeNull(); + expect( + buildTextFieldChildOperations(originalFields, [originalFields[1], originalFields[0]]), + ).toBeNull(); + expect( + buildTextFieldChildOperations(originalFields, [ + originalFields[0], + textField({ key: "second", value: "Second", tagName: "#text", source: "text-node" }), + ]), + ).toBeNull(); + }); +}); diff --git a/packages/studio/src/hooks/domEditTextFieldCommitOps.ts b/packages/studio/src/hooks/domEditTextFieldCommitOps.ts new file mode 100644 index 000000000..9c93766fe --- /dev/null +++ b/packages/studio/src/hooks/domEditTextFieldCommitOps.ts @@ -0,0 +1,63 @@ +import { + buildDomEditStylePatchOperation, + buildDomEditTextPatchOperation, + buildTextFieldChildLocator, + type DomEditTextField, +} from "../components/editor/domEditing"; +import type { PatchOperation } from "../utils/sourcePatcher"; + +function hasSameKeysInSamePositions( + originalFields: DomEditTextField[], + nextFields: DomEditTextField[], +): boolean { + return originalFields.every((field, index) => nextFields[index]?.key === field.key); +} + +function inlineStyleValue(styles: Record, property: string): string | null { + return Object.prototype.hasOwnProperty.call(styles, property) ? styles[property] : null; +} + +function inlineStyleProperties( + originalStyles: Record, + nextStyles: Record, +): string[] { + return Array.from(new Set([...Object.keys(originalStyles), ...Object.keys(nextStyles)])); +} + +// fallow-ignore-next-line complexity +export function buildTextFieldChildOperations( + originalFields: DomEditTextField[], + nextFields: DomEditTextField[], +): PatchOperation[] | null { + if (originalFields.length !== nextFields.length) return null; + if (!hasSameKeysInSamePositions(originalFields, nextFields)) return null; + if (nextFields.some((field) => field.source === "text-node")) return null; + if (nextFields.some((field) => field.source !== "child")) return null; + if (originalFields.some((field) => field.source !== "child")) return null; + + const originalByKey = new Map(originalFields.map((field) => [field.key, field])); + const operations: PatchOperation[] = []; + + for (const nextField of nextFields) { + const originalField = originalByKey.get(nextField.key); + const locator = buildTextFieldChildLocator(originalFields, nextField.key); + if (!originalField || !locator) return null; + + if (nextField.value !== originalField.value) { + operations.push(buildDomEditTextPatchOperation(nextField.value, locator)); + } + + for (const property of inlineStyleProperties( + originalField.inlineStyles, + nextField.inlineStyles, + )) { + const originalValue = inlineStyleValue(originalField.inlineStyles, property); + const nextValue = inlineStyleValue(nextField.inlineStyles, property); + if (nextValue !== originalValue) { + operations.push(buildDomEditStylePatchOperation(property, nextValue, locator)); + } + } + } + + return operations; +} diff --git a/packages/studio/src/utils/sdkCutoverEligibility.test.ts b/packages/studio/src/utils/sdkCutoverEligibility.test.ts new file mode 100644 index 000000000..fadd02cf1 --- /dev/null +++ b/packages/studio/src/utils/sdkCutoverEligibility.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import type { PatchOperation } from "./sourcePatcher"; +import { shouldUseSdkCutover } from "./sdkCutoverEligibility"; + +const childStyleOp: PatchOperation = { + type: "inline-style", + property: "color", + value: "blue", + childSelector: ":scope > span", + childIndex: 0, +}; + +describe("shouldUseSdkCutover child-scoped operations", () => { + it("declines child-scoped operations because SDK patch ops target only the parent hfId", () => { + expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false); + }); +}); diff --git a/packages/studio/src/utils/sdkCutoverEligibility.ts b/packages/studio/src/utils/sdkCutoverEligibility.ts index b16642b5d..4e779807b 100644 --- a/packages/studio/src/utils/sdkCutoverEligibility.ts +++ b/packages/studio/src/utils/sdkCutoverEligibility.ts @@ -61,19 +61,23 @@ function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean { ); } +function hasChildScopedOp(ops: PatchOperation[]): boolean { + return ops.some((op) => op.childSelector !== undefined); +} + function hasTextContentOp(ops: PatchOperation[]): boolean { return ops.some((op) => op.type === "text-content"); } function targetChildren(target: unknown): unknown[] | null { if (!target || typeof target !== "object" || !("children" in target)) return null; - const children = (target as { children?: unknown }).children; + const children = target.children; return Array.isArray(children) ? children : null; } function elementTag(element: unknown): string | null { if (!element || typeof element !== "object" || !("tag" in element)) return null; - const tag = (element as { tag?: unknown }).tag; + const tag = element.tag; return typeof tag === "string" ? tag.toLowerCase() : null; } @@ -110,6 +114,8 @@ export function shouldUseSdkCutover( !!hfId && ops.length > 0 && ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) && + // SDK edit ops target only the element hfId; child-scoped patch ops need the server path. + !hasChildScopedOp(ops) && !ops.some(mapsToReservedAttr) && !hasUnsafeHtmlAttributeOp(ops) ); diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index 002f376af..d12fe9d50 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -90,6 +90,8 @@ export interface PatchOperation { type: "inline-style" | "attribute" | "text-content" | "html-attribute"; property: string; value: string | null; + childSelector?: string; + childIndex?: number; } // Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 4e31c8183..f4099711f 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -174,6 +174,10 @@ export default defineConfig({ resolve: { alias: { "@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"), + "@hyperframes/studio-server/source-mutation": resolve( + __dirname, + "../studio-server/src/helpers/sourceMutation.ts", + ), }, }, build: {