mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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,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 });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user