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,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(`<video data-hf-id="hf-v" style="position:absolute;left:10px;top:20px"></video>`);
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(`<div style="position:absolute;left:5px;top:5px"></div>`);
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(`<div></div>`);
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(`<div></div>`);
const a = resolveElementAffordances(d, null);
expect(a.capabilities.canEditStyles).toBe(false);
});
});
+62
View File
@@ -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<HyperFramesElement, "text" | "animationIds" | "start">;
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<string, string> | 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<string, string> = {
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,
});
}