From 5915590b069a2e3144ae1e1bfb49478b72240894 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 30 Jun 2026 13:46:32 -0700 Subject: [PATCH] feat(editing): shared resolveEditingAffordances (core) + studio re-point + SDK adapter (#1814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add pure resolveEditingAffordances (edit capabilities + section applicability) * fix(core): replace prohibited as-cast and !-assertions in isIdentityTransform * refactor(studio): consume core resolveEditingAffordances; drop duplicated capability + section logic - affordances.ts: add matrix3d identity-transform branch (was missing, caused test regression) - domEditingLayers: add domEditSelectionToFacts mapper; resolveDomEditCapabilities is now a thin wrapper over core (kept for backward-compat — tests + barrel import it); isTextEditableSelection delegates to core sections.text; drop parsePx + isIdentityTransform imports (now in core) - PropertyPanel: import resolveEditingAffordances + domEditSelectionToFacts; compute sections once; replace isMediaElement/isColorGradingCapableElement/timing inline check with sections.* - propertyPanelMediaSection: delete isMediaElement (no remaining callers) - propertyPanelColorGradingSection: delete isColorGradingCapableElement (no remaining callers) Co-Authored-By: Claude Sonnet 4.6 * feat(sdk): add browser-only resolveElementAffordances adapter over core * fix(sdk): add position to inlineStyles, replace ! assertion with guard in test - Add missing 'position' key to inlineStyles in affordances.ts to match computedStyles - Replace non-null assertion (doc.defaultView!) with proper null guard in test Co-Authored-By: Claude Haiku 4.5 * fix(editing): resolve code-review findings on affordances feature Max-effort review (8 verified findings) fixes: Correctness regressions (studio behavior): - SVG selection crash: dropped `classNames` from EditableElementFacts entirely (it was never read by the resolver), which removes the `.className.split()` calls that throw on SVGElement (className is an SVGAnimatedString, not a string). Masked in tests by happy-dom. - Timing panel hidden for GSAP-only layers: domEditSelectionToFacts now takes animationCount from the caller; PropertyPanel feeds the live gsapAnimations prop (selection.gsapAnimations is never populated). Cleanups: - Removed dead inline `position` key from SDK adapter (core reads position only from computedStyles). - Added sections-only `resolveEditingSections` export; PropertyPanel uses it so panel re-renders no longer re-run the capability geometry parse. - Declared happy-dom in packages/sdk devDependencies (was root-hoist only). - Deduped the two capability fact-construction sites behind a shared capabilityFacts() helper. - parsePx now has a single source of truth in core; studio domEditingDom re-exports it so the copies can't drift. isIdentityTransform is now core-internal (studio's only consumer moved to core in the prior task). bun.lock also reconciles stale 0.7.17->0.7.21 package versions. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- bun.lock | 27 +-- packages/core/package.json | 10 + packages/core/src/editing/affordances.test.ts | 171 +++++++++++++++ packages/core/src/editing/affordances.ts | 194 +++++++++++++++++ packages/sdk/package.json | 9 + packages/sdk/src/editing/affordances.test.ts | 46 ++++ packages/sdk/src/editing/affordances.ts | 62 ++++++ .../src/components/editor/PropertyPanel.tsx | 19 +- .../src/components/editor/domEditingDom.ts | 37 +--- .../src/components/editor/domEditingLayers.ts | 196 ++++++++---------- .../propertyPanelColorGradingSection.tsx | 4 - .../editor/propertyPanelMediaSection.tsx | 6 - 12 files changed, 609 insertions(+), 172 deletions(-) create mode 100644 packages/core/src/editing/affordances.test.ts create mode 100644 packages/core/src/editing/affordances.ts create mode 100644 packages/sdk/src/editing/affordances.test.ts create mode 100644 packages/sdk/src/editing/affordances.ts diff --git a/bun.lock b/bun.lock index 3ebd2e593..fa98e9f34 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.7.17", + "version": "0.7.21", "bin": { "hyperframes": "./dist/cli.js", }, @@ -103,7 +103,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -128,7 +128,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -146,7 +146,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -166,7 +166,7 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@hyperframes/parsers": "workspace:*", "postcss": "^8.5.8", @@ -181,7 +181,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -201,7 +201,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -216,7 +216,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -259,7 +259,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -267,6 +267,7 @@ }, "devDependencies": { "@types/node": "^25.0.10", + "happy-dom": "^20.9.0", "typescript": "^5.0.0", "vitest": "^3.2.4", }, @@ -285,7 +286,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "html2canvas": "^1.4.1", }, @@ -297,7 +298,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -345,7 +346,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.7.17", + "version": "0.7.21", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", diff --git a/packages/core/package.json b/packages/core/package.json index f1ea348f6..b89bfa09d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,6 +40,12 @@ "import": "./src/utils/htmlAttrSafety.ts", "types": "./src/utils/htmlAttrSafety.ts" }, + "./editing": { + "bun": "./src/editing/affordances.ts", + "node": "./dist/editing/affordances.js", + "import": "./src/editing/affordances.ts", + "types": "./src/editing/affordances.ts" + }, "./slideshow": { "bun": "./src/slideshow/index.ts", "node": "./dist/slideshow/index.js", @@ -216,6 +222,10 @@ "import": "./dist/utils/htmlAttrSafety.js", "types": "./dist/utils/htmlAttrSafety.d.ts" }, + "./editing": { + "import": "./dist/editing/affordances.js", + "types": "./dist/editing/affordances.d.ts" + }, "./generators": { "import": "./dist/generators/hyperframes.js", "types": "./dist/generators/hyperframes.d.ts" diff --git a/packages/core/src/editing/affordances.test.ts b/packages/core/src/editing/affordances.test.ts new file mode 100644 index 000000000..3c6ede3a9 --- /dev/null +++ b/packages/core/src/editing/affordances.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { + resolveEditingAffordances, + resolveEditingSections, + type EditableElementFacts, +} from "./affordances"; + +function baseFacts(over: Partial = {}): EditableElementFacts { + return { + hasStableTarget: true, + tag: "div", + inlineStyles: {}, + computedStyles: {}, + isCompositionHost: false, + isCompositionRoot: false, + isInsideLockedComposition: false, + isMasterView: false, + existsInSource: true, + hasEditableText: false, + hasTimingStart: false, + animationCount: 0, + ...over, + }; +} + +describe("resolveEditingAffordances — capabilities", () => { + it("locked composition: nothing editable, not selectable, reason set", () => { + const a = resolveEditingAffordances(baseFacts({ isInsideLockedComposition: true })); + expect(a.capabilities).toMatchObject({ + canSelect: false, + canEditStyles: false, + canMove: false, + }); + expect(a.capabilities.reasonIfDisabled).toContain("locked composition"); + }); + + it("no stable target: not editable but selectable", () => { + const a = resolveEditingAffordances(baseFacts({ hasStableTarget: false })); + expect(a.capabilities.canSelect).toBe(true); + expect(a.capabilities.canEditStyles).toBe(false); + }); + + it("not in source: script-generated, select-only", () => { + const a = resolveEditingAffordances(baseFacts({ existsInSource: false })); + expect(a.capabilities.canSelect).toBe(true); + expect(a.capabilities.canEditStyles).toBe(false); + expect(a.capabilities.reasonIfDisabled).toContain("generated by a script"); + }); + + it("composition root: edit styles only, no move/resize", () => { + const a = resolveEditingAffordances(baseFacts({ isCompositionRoot: true })); + expect(a.capabilities).toMatchObject({ canEditStyles: true, canMove: false, canResize: false }); + }); + + it("absolute + left/top + identity transform: canMove", () => { + const a = resolveEditingAffordances( + baseFacts({ + computedStyles: { position: "absolute", left: "10px", top: "20px", transform: "none" }, + }), + ); + expect(a.capabilities.canMove).toBe(true); + }); + + it("transform-driven geometry blocks canMove", () => { + const a = resolveEditingAffordances( + baseFacts({ + computedStyles: { + position: "absolute", + left: "10px", + top: "20px", + transform: "matrix(1,0,0,1,5,5)", + }, + }), + ); + expect(a.capabilities.canMove).toBe(false); + }); + + it("canResize requires canMove plus a width/height", () => { + const a = resolveEditingAffordances( + baseFacts({ + computedStyles: { + position: "absolute", + left: "0px", + top: "0px", + width: "100px", + transform: "none", + }, + }), + ); + expect(a.capabilities.canResize).toBe(true); + }); + + it("inline left/top override missing computed", () => { + const a = resolveEditingAffordances( + baseFacts({ + inlineStyles: { left: "5px", top: "5px" }, + computedStyles: { position: "fixed", transform: "none" }, + }), + ); + expect(a.capabilities.canMove).toBe(true); + }); + + it("computedStyles absent: canMove/canResize default false", () => { + const a = resolveEditingAffordances( + baseFacts({ computedStyles: undefined, inlineStyles: { left: "5px", top: "5px" } }), + ); + expect(a.capabilities.canMove).toBe(false); + expect(a.capabilities.canResize).toBe(false); + }); + + it("composition host + master view: no edit styles, geometry blocked", () => { + const a = resolveEditingAffordances(baseFacts({ isCompositionHost: true, isMasterView: true })); + expect(a.capabilities.canEditStyles).toBe(false); + expect(a.capabilities.canApplyManualOffset).toBe(false); + expect(a.capabilities.reasonIfDisabled).toContain("internal layer"); + }); +}); + +describe("resolveEditingAffordances — sections", () => { + it("video: media + colorGrading", () => { + const s = resolveEditingAffordances(baseFacts({ tag: "video" })).sections; + expect(s).toMatchObject({ media: true, colorGrading: true }); + }); + + it("audio: media but not colorGrading", () => { + const s = resolveEditingAffordances(baseFacts({ tag: "audio" })).sections; + expect(s).toMatchObject({ media: true, colorGrading: false }); + }); + + it("img: colorGrading but not media", () => { + const s = resolveEditingAffordances(baseFacts({ tag: "img" })).sections; + expect(s).toMatchObject({ media: false, colorGrading: true }); + }); + + it("editable text on a plain element: text section", () => { + const s = resolveEditingAffordances(baseFacts({ hasEditableText: true })).sections; + expect(s.text).toBe(true); + }); + + it("text section suppressed on host / locked", () => { + expect( + resolveEditingAffordances(baseFacts({ hasEditableText: true, isCompositionHost: true })) + .sections.text, + ).toBe(false); + expect( + resolveEditingAffordances( + baseFacts({ hasEditableText: true, isInsideLockedComposition: true }), + ).sections.text, + ).toBe(false); + }); + + it("data-start drives timing; animations drive timing + animation", () => { + expect(resolveEditingAffordances(baseFacts({ hasTimingStart: true })).sections.timing).toBe( + true, + ); + const anim = resolveEditingAffordances(baseFacts({ animationCount: 2 })).sections; + expect(anim).toMatchObject({ timing: true, animation: true }); + }); +}); + +describe("resolveEditingSections (sections-only export)", () => { + it("matches resolveEditingAffordances().sections for the same facts", () => { + const facts = baseFacts({ tag: "video", hasEditableText: true, animationCount: 1 }); + expect(resolveEditingSections(facts)).toEqual(resolveEditingAffordances(facts).sections); + }); + + it("animationCount > 0 turns on timing + animation even without data-start", () => { + const s = resolveEditingSections(baseFacts({ hasTimingStart: false, animationCount: 3 })); + expect(s).toMatchObject({ timing: true, animation: true }); + }); +}); diff --git a/packages/core/src/editing/affordances.ts b/packages/core/src/editing/affordances.ts new file mode 100644 index 000000000..c294933d1 --- /dev/null +++ b/packages/core/src/editing/affordances.ts @@ -0,0 +1,194 @@ +/** + * Pure, DOM-free editing-affordance resolution. Single source of truth for what + * the studio's edit panel (and any SDK consumer) surfaces per selected element: + * capability flags + which section types apply. No getComputedStyle, no DOM — + * the caller supplies normalized facts (live or static). See the SDK adapter + * (browser-only) and the studio mapper for the two fact extractors. + */ + +export interface DomEditCapabilities { + canSelect: boolean; + canEditStyles: boolean; + /** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */ + canMove: boolean; + /** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */ + canResize: boolean; + canApplyManualOffset: boolean; + canApplyManualSize: boolean; + canApplyManualRotation: boolean; + reasonIfDisabled?: string; +} + +export interface EditingSectionApplicability { + text: boolean; + media: boolean; + /** Element-level only — the consumer still ANDs its own feature flag. */ + colorGrading: boolean; + timing: boolean; + animation: boolean; +} + +export interface EditingAffordances { + capabilities: DomEditCapabilities; + sections: EditingSectionApplicability; +} + +export interface EditableElementFacts { + /** A stable patch target exists (selector|hfId in studio; always true in the SDK model). */ + hasStableTarget: boolean; + /** Lowercased tag name. */ + tag: string; + /** kebab-case. Capability logic reads left/top/width/height/transform; sections read nothing here. */ + inlineStyles: Record; + /** kebab-case. Absent => canMove/canResize default to false (no live layout). */ + computedStyles?: Record; + isCompositionHost: boolean; + isCompositionRoot: boolean; + isInsideLockedComposition: boolean; + isMasterView: boolean; + existsInSource: boolean; + /** studio: textFields.length > 0 ; SDK: model.text != null */ + hasEditableText: boolean; + /** data-start present on the element */ + hasTimingStart: boolean; + /** count of GSAP tweens targeting this element */ + animationCount: number; +} + +/** + * kebab-case px parser. Single source of truth — studio's domEditingDom + * re-exports this so the two paths can't drift. + */ +export function parsePx(value: string | undefined): number | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed.endsWith("px")) return null; + const parsed = parseFloat(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +/** Whether a CSS transform is the identity (matrix or matrix3d). Core-internal. */ +// fallow-ignore-next-line complexity +function isIdentityTransform(value: string | undefined): boolean { + const transform = (value ?? "none").trim(); + if (!transform || transform === "none") return true; + + const matrix = transform.match(/^matrix\(([^)]+)\)$/i); + if (matrix && matrix[1]) { + const parts = matrix[1].split(","); + if (parts.length !== 6) return false; + const values = parts.map((part) => Number.parseFloat(part.trim())); + if (values.some((part) => !Number.isFinite(part))) return false; + const [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0] = values; + return ( + Math.abs(a - 1) < 0.0001 && + Math.abs(b) < 0.0001 && + Math.abs(c) < 0.0001 && + Math.abs(d - 1) < 0.0001 && + Math.abs(e) < 0.0001 && + Math.abs(f) < 0.0001 + ); + } + + const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i); + if (!matrix3d || !matrix3d[1]) return false; + const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim())); + if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false; + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + return values.every((part, index) => Math.abs(part - (identity[index] ?? 0)) < 0.0001); +} + +// fallow-ignore-next-line complexity +function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities { + if (!facts.hasStableTarget || facts.isInsideLockedComposition) { + return { + canSelect: !facts.isInsideLockedComposition, + canEditStyles: false, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + reasonIfDisabled: facts.isInsideLockedComposition + ? "This element belongs to a locked composition." + : "Studio could not resolve a stable patch target for this element.", + }; + } + + if (!facts.existsInSource) { + return { + canSelect: true, + canEditStyles: false, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + reasonIfDisabled: "This element is generated by a script and cannot be edited visually.", + }; + } + + if (facts.isCompositionRoot) { + return { + canSelect: true, + canEditStyles: true, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + reasonIfDisabled: "The root composition defines the preview bounds.", + }; + } + + const computed = facts.computedStyles ?? {}; + const position = computed.position; + const left = parsePx(facts.inlineStyles.left) ?? parsePx(computed.left); + const top = parsePx(facts.inlineStyles.top) ?? parsePx(computed.top); + const width = parsePx(facts.inlineStyles.width) ?? parsePx(computed.width); + const height = parsePx(facts.inlineStyles.height) ?? parsePx(computed.height); + const hasTransformDrivenGeometry = !isIdentityTransform(computed.transform); + + const canMove = + (position === "absolute" || position === "fixed") && + left != null && + top != null && + !hasTransformDrivenGeometry; + const canResize = canMove && (width != null || height != null); + const canApplyManualGeometry = !facts.isCompositionHost; + const reasonIfDisabled = canApplyManualGeometry + ? undefined + : "Select an internal layer to transform it."; + + const canEditStyles = !(facts.isCompositionHost && facts.isMasterView); + + return { + canSelect: true, + canEditStyles, + canMove, + canResize, + canApplyManualOffset: canApplyManualGeometry, + canApplyManualSize: canApplyManualGeometry, + canApplyManualRotation: canApplyManualGeometry, + reasonIfDisabled, + }; +} + +/** + * Section applicability only. Reads no style facts, so callers that already + * hold resolved capabilities (e.g. the studio panel) can compute sections + * without re-running the capability geometry parse. + */ +export function resolveEditingSections(facts: EditableElementFacts): EditingSectionApplicability { + return { + text: facts.hasEditableText && !facts.isCompositionHost && !facts.isInsideLockedComposition, + media: facts.tag === "video" || facts.tag === "audio", + colorGrading: facts.tag === "video" || facts.tag === "img", + timing: facts.hasTimingStart || facts.animationCount > 0, + animation: facts.animationCount > 0, + }; +} + +export function resolveEditingAffordances(facts: EditableElementFacts): EditingAffordances { + return { capabilities: resolveCapabilities(facts), sections: resolveEditingSections(facts) }; +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b7a035c3e..8881e1bbc 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -29,6 +29,10 @@ "./adapters/headless": { "import": "./src/adapters/headless.ts", "types": "./src/adapters/headless.ts" + }, + "./editing": { + "import": "./src/editing/affordances.ts", + "types": "./src/editing/affordances.ts" } }, "publishConfig": { @@ -49,6 +53,10 @@ "./adapters/headless": { "import": "./dist/adapters/headless.js", "types": "./dist/adapters/headless.d.ts" + }, + "./editing": { + "import": "./dist/editing/affordances.js", + "types": "./dist/editing/affordances.d.ts" } }, "main": "./dist/index.js", @@ -68,6 +76,7 @@ }, "devDependencies": { "@types/node": "^25.0.10", + "happy-dom": "^20.9.0", "typescript": "^5.0.0", "vitest": "^3.2.4" } diff --git a/packages/sdk/src/editing/affordances.test.ts b/packages/sdk/src/editing/affordances.test.ts new file mode 100644 index 000000000..8d2da06aa --- /dev/null +++ b/packages/sdk/src/editing/affordances.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { resolveElementAffordances } from "./affordances"; + +let doc: Document; + +beforeEach(() => { + const win = new Window(); + doc = win.document as unknown as Document; +}); + +function el(html: string): HTMLElement { + doc.body.innerHTML = html; + const node = doc.body.firstElementChild; + const view = doc.defaultView; + if (!view) throw new Error("no defaultView"); + if (!(node instanceof view.HTMLElement)) throw new Error("expected HTMLElement"); + return node as unknown as HTMLElement; +} + +describe("resolveElementAffordances (live DOM)", () => { + it("video element with model => media + colorGrading, existsInSource", () => { + const v = el(``); + const a = resolveElementAffordances(v, { text: null, animationIds: [], start: null }); + expect(a.sections).toMatchObject({ media: true, colorGrading: true }); + expect(a.capabilities.canSelect).toBe(true); + }); + + it("absolutely-positioned div with inline left/top => canMove", () => { + const d = el(`
`); + const a = resolveElementAffordances(d, { text: null, animationIds: [], start: null }); + expect(a.capabilities.canMove).toBe(true); + }); + + it("model text => text section; model animationIds => timing+animation", () => { + const d = el(`
`); + const a = resolveElementAffordances(d, { text: "hello", animationIds: ["t1", "t2"], start: 0 }); + expect(a.sections).toMatchObject({ text: true, timing: true, animation: true }); + }); + + it("null model => existsInSource false (not in model)", () => { + const d = el(`
`); + const a = resolveElementAffordances(d, null); + expect(a.capabilities.canEditStyles).toBe(false); + }); +}); diff --git a/packages/sdk/src/editing/affordances.ts b/packages/sdk/src/editing/affordances.ts new file mode 100644 index 000000000..efb5e62f9 --- /dev/null +++ b/packages/sdk/src/editing/affordances.ts @@ -0,0 +1,62 @@ +/** + * Browser-only editing-affordance resolver. Reads live layout (getComputedStyle) + * from a rendered element and combines it with the SDK model element to call the + * pure core resolver. MUST NOT be imported on the static/Node SDK path — it + * touches getComputedStyle and only resolves meaningfully against a laid-out DOM. + */ + +import { resolveEditingAffordances, type EditingAffordances } from "@hyperframes/core/editing"; +import type { HyperFramesElement } from "../types.js"; + +export interface AffordanceContext { + /** Studio-app concepts; default false for a generic consumer with no such notion. */ + isCompositionHost?: boolean; + isCompositionRoot?: boolean; + isInsideLockedComposition?: boolean; + isMasterView?: boolean; +} + +type ModelFacts = Pick; + +export function resolveElementAffordances( + liveEl: HTMLElement, + modelEl: ModelFacts | null, + ctx: AffordanceContext = {}, +): EditingAffordances { + const view = liveEl.ownerDocument.defaultView; + const cs = view ? view.getComputedStyle(liveEl) : null; + const computedStyles: Record | undefined = cs + ? { + position: cs.getPropertyValue("position"), + left: cs.getPropertyValue("left"), + top: cs.getPropertyValue("top"), + width: cs.getPropertyValue("width"), + height: cs.getPropertyValue("height"), + transform: cs.getPropertyValue("transform"), + } + : undefined; + + // Core reads position only from computedStyles; inlineStyles supplies the + // authored left/top/width/height that override the computed layout. + const inlineStyles: Record = { + left: liveEl.style.getPropertyValue("left"), + top: liveEl.style.getPropertyValue("top"), + width: liveEl.style.getPropertyValue("width"), + height: liveEl.style.getPropertyValue("height"), + }; + + return resolveEditingAffordances({ + hasStableTarget: true, + tag: liveEl.tagName.toLowerCase(), + inlineStyles, + computedStyles, + isCompositionHost: ctx.isCompositionHost ?? false, + isCompositionRoot: ctx.isCompositionRoot ?? false, + isInsideLockedComposition: ctx.isInsideLockedComposition ?? false, + isMasterView: ctx.isMasterView ?? false, + existsInSource: modelEl != null, + hasEditableText: modelEl?.text != null, + hasTimingStart: modelEl ? modelEl.start != null : liveEl.hasAttribute("data-start"), + animationCount: modelEl?.animationIds.length ?? 0, + }); +} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 9aeba6a54..fd82768e8 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -14,11 +14,10 @@ import { import { MetricField, Section } from "./propertyPanelPrimitives"; import { createTransformCommitHandlers } from "./propertyPanelTransformCommit"; import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; -import { isMediaElement, MediaSection } from "./propertyPanelMediaSection"; -import { - ColorGradingSection, - isColorGradingCapableElement, -} from "./propertyPanelColorGradingSection"; +import { resolveEditingSections } from "@hyperframes/core/editing"; +import { MediaSection } from "./propertyPanelMediaSection"; +import { ColorGradingSection } from "./propertyPanelColorGradingSection"; +import { domEditSelectionToFacts } from "./domEditingLayers"; import { TextSection, StyleSections } from "./propertyPanelSections"; import { GsapAnimationSection } from "./GsapAnimationSection"; import { PropertyPanel3dTransform } from "./propertyPanel3dTransform"; @@ -199,6 +198,10 @@ export const PropertyPanel = memo(function PropertyPanel({ const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation; const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; + // Capabilities are already resolved on the selection; recompute only sections, + // feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the + // selection) so the Timing section shows for pure-GSAP elements with no data-start. + const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length)); const manualOffset = readStudioPathOffset(element.element); const manualSize = readStudioBoxSize(element.element); const resolvedWidth = @@ -339,7 +342,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onRemoveTextField={onRemoveTextField} /> - {(element.dataAttributes.start != null || gsapAnimations.length > 0) && ( + {sections.timing && ( // Render whenever there's an authored clip range OR animations to infer // one from — a pure-GSAP element with no data-start still gets a Timing // range (TimingSection derives it from its tweens). @@ -349,7 +352,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetAttribute={onSetAttribute} /> )} - {isMediaElement(element) && ( + {sections.media && ( )} - {STUDIO_COLOR_GRADING_ENABLED && isColorGradingCapableElement(element) && ( + {STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && ( Number.parseFloat(part.trim())); - if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false; - return ( - Math.abs(values[0] - 1) < 0.0001 && - Math.abs(values[1]) < 0.0001 && - Math.abs(values[2]) < 0.0001 && - Math.abs(values[3] - 1) < 0.0001 && - Math.abs(values[4]) < 0.0001 && - Math.abs(values[5]) < 0.0001 - ); - } - - const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i); - if (!matrix3d) return false; - const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim())); - if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false; - const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001); -} +// Single source of truth lives in @hyperframes/core/editing so the studio +// callers and the core resolver can't drift. Re-exported here to keep this +// module's public surface (6 studio callers import parsePx from it). +export { parsePx } from "@hyperframes/core/editing"; export function isTextBearingTag(tagName: string): boolean { return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName); diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 5037c7831..787444e8d 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -3,6 +3,11 @@ * for dom editing. */ import type { PatchOperation } from "../../utils/sourcePatcher"; +import { + resolveEditingAffordances, + resolveEditingSections, + type EditableElementFacts, +} from "@hyperframes/core/editing"; import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups"; import type { DomEditCapabilities, @@ -21,9 +26,7 @@ import { getSelectorIndex, getSourceFileForElement, isHtmlElement, - isIdentityTransform, isTextBearingTag, - parsePx, } from "./domEditingDom"; import { findElementForSelection, @@ -171,12 +174,66 @@ export function buildDefaultDomEditTextField(base?: Partial): // ─── Capabilities ──────────────────────────────────────────────────────────── -// fallow-ignore-next-line complexity +/** + * 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; + inlineStyles: Record; + computedStyles: Record; + isCompositionHost: boolean; + isCompositionRoot: boolean; + isInsideLockedComposition: boolean; + isMasterView: boolean; + existsInSource: boolean; +}): EditableElementFacts { + return { + ...geometry, + hasEditableText: false, + hasTimingStart: false, + animationCount: 0, + }; +} + +/** + * Build core EditableElementFacts from a fully-resolved DomEditSelection. + * `animationCount` is supplied by the caller because live GSAP tweens arrive on + * a separate channel (the PropertyPanel `gsapAnimations` prop), not on the + * selection — `selection.gsapAnimations` is never populated. + */ +export function domEditSelectionToFacts( + selection: DomEditSelection, + animationCount = selection.gsapAnimations?.length ?? 0, +): EditableElementFacts { + return { + hasStableTarget: Boolean(selection.selector || selection.hfId), + tag: selection.tagName, + inlineStyles: selection.inlineStyles, + computedStyles: selection.computedStyles, + isCompositionHost: selection.isCompositionHost, + isCompositionRoot: false, + isInsideLockedComposition: selection.isInsideLockedComposition, + isMasterView: false, + existsInSource: true, + hasEditableText: selection.textFields.length > 0, + hasTimingStart: selection.dataAttributes.start != null, + animationCount, + }; +} + +/** + * Resolve DOM edit capabilities for a given element. + * Thin wrapper over core resolveEditingAffordances — kept for backward + * compatibility (tests and the barrel import this signature directly). + */ export function resolveDomEditCapabilities(args: { selector?: string; hfId?: string; tagName?: string; - className?: string; inlineStyles: Record; computedStyles: Record; isCompositionHost: boolean; @@ -185,92 +242,19 @@ export function resolveDomEditCapabilities(args: { isMasterView: boolean; existsInSource?: boolean; }): DomEditCapabilities { - if ((!args.selector && !args.hfId) || args.isInsideLockedComposition) { - return { - canSelect: !args.isInsideLockedComposition, - canEditStyles: false, - canMove: false, - canResize: false, - canApplyManualOffset: false, - canApplyManualSize: false, - canApplyManualRotation: false, - reasonIfDisabled: args.isInsideLockedComposition - ? "This element belongs to a locked composition." - : "Studio could not resolve a stable patch target for this element.", - }; - } - - if (args.existsInSource === false) { - return { - canSelect: true, - canEditStyles: false, - canMove: false, - canResize: false, - canApplyManualOffset: false, - canApplyManualSize: false, - canApplyManualRotation: false, - reasonIfDisabled: "This element is generated by a script and cannot be edited visually.", - }; - } - - if (args.isCompositionRoot) { - return { - canSelect: true, - canEditStyles: true, - canMove: false, - canResize: false, - canApplyManualOffset: false, - canApplyManualSize: false, - canApplyManualRotation: false, - reasonIfDisabled: "The root composition defines the preview bounds.", - }; - } - - const position = args.computedStyles.position; - const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left); - const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top); - const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width); - const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height); - const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform); - - const canMove = - (position === "absolute" || position === "fixed") && - left != null && - top != null && - !hasTransformDrivenGeometry; - - const canResize = canMove && (width != null || height != null); - const canApplyManualGeometry = !args.isCompositionHost; - const canApplyManualOffset = canApplyManualGeometry; - const canApplyManualSize = canApplyManualGeometry; - const canApplyManualRotation = canApplyManualGeometry; - const reasonIfDisabled = canApplyManualGeometry - ? undefined - : "Select an internal layer to transform it."; - - if (args.isCompositionHost && args.isMasterView) { - return { - canSelect: true, - canEditStyles: false, - canMove, - canResize, - canApplyManualOffset, - canApplyManualSize, - canApplyManualRotation, - reasonIfDisabled, - }; - } - - return { - canSelect: true, - canEditStyles: true, - canMove, - canResize, - canApplyManualOffset, - canApplyManualSize, - canApplyManualRotation, - reasonIfDisabled, - }; + return resolveEditingAffordances( + capabilityFacts({ + hasStableTarget: Boolean(args.selector || args.hfId), + tag: (args.tagName ?? "div").toLowerCase(), + inlineStyles: args.inlineStyles, + computedStyles: args.computedStyles, + isCompositionHost: args.isCompositionHost, + isCompositionRoot: args.isCompositionRoot ?? false, + isInsideLockedComposition: args.isInsideLockedComposition ?? false, + isMasterView: args.isMasterView, + existsInSource: args.existsInSource ?? true, + }), + ).capabilities; } // ─── Element label ──────────────────────────────────────────────────────────── @@ -354,19 +338,19 @@ export async function resolveDomEditSelection( if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex; existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget); } - const capabilities = resolveDomEditCapabilities({ - selector, - hfId, - tagName: current.tagName.toLowerCase(), - className: current.className, - inlineStyles, - computedStyles, - isCompositionHost: Boolean(compositionSrc), - isCompositionRoot, - isInsideLockedComposition: isInsideLocked, - isMasterView: options.isMasterView, - existsInSource, - }); + const capabilities = resolveEditingAffordances( + capabilityFacts({ + hasStableTarget: Boolean(selector || hfId), + tag: current.tagName.toLowerCase(), + inlineStyles, + computedStyles, + isCompositionHost: Boolean(compositionSrc), + isCompositionRoot, + isInsideLockedComposition: isInsideLocked, + isMasterView: options.isMasterView, + existsInSource: existsInSource ?? true, + }), + ).capabilities; const rect = current.getBoundingClientRect(); return { @@ -554,11 +538,7 @@ export function getDomEditTargetKey( } export function isTextEditableSelection(selection: DomEditSelection): boolean { - return ( - selection.textFields.length > 0 && - !selection.isCompositionHost && - !selection.isInsideLockedComposition - ); + return resolveEditingSections(domEditSelectionToFacts(selection)).text; } // buildElementAgentPrompt is in domEditingAgentPrompt.ts diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx index 83db60bd7..e53be3a32 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -50,10 +50,6 @@ interface RuntimeColorGradingStatus { message: string; } -export function isColorGradingCapableElement(element: DomEditSelection): boolean { - return element.tagName === "video" || element.tagName === "img"; -} - function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading { const grading = normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ?? diff --git a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx index a7f32be02..899170fd1 100644 --- a/packages/studio/src/components/editor/propertyPanelMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelMediaSection.tsx @@ -10,12 +10,6 @@ import { } from "./propertyPanelHelpers"; import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives"; -const MEDIA_TAGS = new Set(["video", "audio"]); - -export function isMediaElement(element: DomEditSelection): boolean { - return MEDIA_TAGS.has(element.tagName); -} - export function MediaSection({ projectDir, element,