mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
* test(studio): add design-panel QA fixture and triage matrix Fixture project covering all panel-editable element archetypes, plus the QA findings matrix from the design-panel bug campaign. * fix(studio): make canvas selection hit intended elements - honor author pointer-events:none in hit-testing (was selecting invisible overlays) - pause playback before mousedown sampling; fall back to hover selection on null resolve - invalidate committed selection when the active composition changes - double-click keeps selection and defers to multi-candidate click cycling * fix(studio): close remaining selection-layer review findings - hoverSelection fallback now wired at all 3 mousedown call sites (box-click, blocked-drag, plain overlay click) instead of just the overlay path - pointer-events override detection reads computed style, not inline style, so a CSS-class opt-in (not just inline style=) on a descendant is honored - defensively remove the pointer-events override before the group-fallback check too, closing a theoretical gap in the no-elementsFromPoint branch - a click that resolves to nothing (dead-zone / deselect) no longer leaves playback paused if it was already playing * fix(studio-server): child-scoped patch operations with batch abort - PatchOperation gains optional childSelector/childIndex resolved under the matched parent - pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write - style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap - new ./source-mutation subpath export (mirrors ./finite-mutation) * fix(studio): per-child patch op builders and persist-seam harness - buildTextFieldChildLocator indexes over the parent's full same-tag child list - buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits - SDK cutover declines child-scoped batches (hfId mapping would hit the parent) - persist-seam integration harness drives real client ops through patchElementInHtml * fix(studio): fail closed on unresolved text-field child index buildTextFieldChildLocator guessed a synthetic field's position by counting same-tag "child" fields elsewhere in the array whenever sourceChildIndex was absent. That heuristic is unreachable today (the count-mismatch guard in buildTextFieldChildOperations already refuses add/remove edits before it's reached) but would silently locate the wrong element for a future caller that wires up synthetic-field support without also computing a real sourceChildIndex. Return null instead so the caller falls back to the unsupported-structure path.
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
/**
|
|
* Cutover eligibility checks: whether a batch of patch ops is safe to route
|
|
* through the SDK cutover path instead of the legacy server path. Split out of
|
|
* sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this
|
|
* block has no dependency on the persist/dispatch functions there.
|
|
*/
|
|
import type { PatchOperation } from "./sourcePatcher";
|
|
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
|
|
|
|
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
|
"inline-style",
|
|
"text-content",
|
|
"attribute",
|
|
"html-attribute",
|
|
]);
|
|
|
|
// Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is
|
|
// force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK
|
|
// rejects with a throw. Detect that up front and decline the whole batch so it
|
|
// takes the server path cleanly, instead of throwing inside the dispatch and
|
|
// silently falling back per op.
|
|
// ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new
|
|
// op for it just reverts to the (working) throw→fallback path until synced.
|
|
const RESERVED_CUTOVER_ATTRS = new Set<string>([
|
|
"data-hf-id",
|
|
"data-composition-id",
|
|
"data-width",
|
|
"data-height",
|
|
"data-start",
|
|
"data-end",
|
|
"data-track-index",
|
|
"data-hold-start",
|
|
"data-hold-end",
|
|
"data-hold-fill",
|
|
]);
|
|
|
|
function sdkAttrName(op: PatchOperation): string | null {
|
|
if (op.type === "attribute") {
|
|
return op.property.startsWith("data-") ? op.property : `data-${op.property}`;
|
|
}
|
|
if (op.type === "html-attribute") return op.property;
|
|
return null;
|
|
}
|
|
|
|
function mapsToReservedAttr(op: PatchOperation): boolean {
|
|
const name = sdkAttrName(op);
|
|
// Lowercase to match the SDK's validateSetAttribute (it lowercases before the
|
|
// reserved check), so "DATA-START" is declined up front too; covers both
|
|
// `attribute` (prefixed) and `html-attribute` (raw) ops.
|
|
return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
|
|
}
|
|
|
|
// ─── html-attribute safety ───────────────────────────────────────────────────
|
|
|
|
function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
|
|
return ops.some(
|
|
(op) =>
|
|
op.type === "html-attribute" &&
|
|
(!isAllowedHtmlAttribute(op.property) ||
|
|
(op.value !== null && !isSafeAttributeValue(op.property, op.value))),
|
|
);
|
|
}
|
|
|
|
function hasChildScopedOp(ops: PatchOperation[]): boolean {
|
|
return ops.some((op) => op.childSelector !== undefined);
|
|
}
|
|
|
|
function hasTextContentOp(ops: PatchOperation[]): boolean {
|
|
return ops.some((op) => op.type === "text-content");
|
|
}
|
|
|
|
function targetChildren(target: unknown): unknown[] | null {
|
|
if (!target || typeof target !== "object" || !("children" in target)) return null;
|
|
const children = target.children;
|
|
return Array.isArray(children) ? children : null;
|
|
}
|
|
|
|
function elementTag(element: unknown): string | null {
|
|
if (!element || typeof element !== "object" || !("tag" in element)) return null;
|
|
const tag = element.tag;
|
|
return typeof tag === "string" ? tag.toLowerCase() : null;
|
|
}
|
|
|
|
// Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
|
|
// Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
|
|
// HTMLElement` — that runtime check catches the same set, but we can't use it
|
|
// here because `target` is a plain SDK object, not a DOM Element. If linkedom
|
|
// (or a future parser) surfaces additional foreign-content elements as
|
|
// non-HTMLElement, add them here.
|
|
const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
|
|
|
|
export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
|
|
if (!hasTextContentOp(ops)) return false;
|
|
const children = targetChildren(target);
|
|
if (!children) return false;
|
|
// Legacy patch-element replaces the whole element for multi-child targets and
|
|
// for single non-HTML children. The SDK text patch stream stores a scalar
|
|
// inverse, so those shapes cannot be made both byte-identical and undo-safe
|
|
// here. Let the server path remain authoritative for them.
|
|
if (children.length > 1) return true;
|
|
const tag = elementTag(children[0]);
|
|
return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
|
|
}
|
|
|
|
export function shouldUseSdkCutover(
|
|
flagEnabled: boolean,
|
|
hasSession: boolean,
|
|
hfId: string | null | undefined,
|
|
ops: PatchOperation[],
|
|
): boolean {
|
|
return (
|
|
flagEnabled &&
|
|
hasSession &&
|
|
!!hfId &&
|
|
ops.length > 0 &&
|
|
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
|
|
// SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
|
|
!hasChildScopedOp(ops) &&
|
|
!ops.some(mapsToReservedAttr) &&
|
|
!hasUnsafeHtmlAttributeOp(ops)
|
|
);
|
|
}
|