mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
co-authored by
Claude Opus 4.8
parent
856bb0980f
commit
5915590b06
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user