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
@@ -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 && (
|
||||
<MediaSection
|
||||
projectDir={projectDir}
|
||||
element={element}
|
||||
@@ -360,7 +363,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{STUDIO_COLOR_GRADING_ENABLED && isColorGradingCapableElement(element) && (
|
||||
{STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && (
|
||||
<ColorGradingSection
|
||||
key={[
|
||||
element.id ?? "",
|
||||
|
||||
@@ -19,39 +19,10 @@ export function isHtmlElement(value: unknown): value is HTMLElement {
|
||||
|
||||
// ─── Style parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export 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) {
|
||||
const values = matrix[1].split(",").map((part) => 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);
|
||||
|
||||
@@ -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<DomEditTextField>):
|
||||
|
||||
// ─── 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<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
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<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
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
|
||||
|
||||
@@ -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]) ??
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user