feat(editing): shared resolveEditingAffordances (core) + studio re-point + SDK adapter (#1814)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-30 13:46:32 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 856bb0980f
commit 5915590b06
12 changed files with 609 additions and 172 deletions
@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import {
resolveEditingAffordances,
resolveEditingSections,
type EditableElementFacts,
} from "./affordances";
function baseFacts(over: Partial<EditableElementFacts> = {}): 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 });
});
});
+194
View File
@@ -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<string, string>;
/** kebab-case. Absent => canMove/canResize default to false (no live layout). */
computedStyles?: Record<string, string>;
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) };
}