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
@@ -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