Skip to content

Commit fc3ab76

Browse files
vanceingallsclaude
andauthored
fix(studio,core): persist manual position edits for GSAP-owned elements (#1346)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) * fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access - index.ts no longer exports document/session/history/persist-queue (those modules land in the next stacked PR); branch now typechecks standalone - setOwnText: optional-chain children[i] access (TS2532 under noUncheckedIndexedAccess) - fallow suppressions for buildPatchEvent + adapters/types.ts — consumers arrive in #1325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline - applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9 parser-backed ops instead of silently no-opping — callers must never believe an animation edit succeeded when nothing was mutated - validateOp returns false for Phase 3b ops so can() feature-detects - root package.json build filter now includes @hyperframes/sdk (package is dist-only; top-level build previously produced no SDK artifacts). publish.yml intentionally NOT updated — sdk stays unpublished until Phase 3 completes. Adversarial-review findings F3 + F4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs Round-2 review (Rames/Miguel) on the engine layer: - ORIGIN_APPLY_PATCHES: unique symbol → namespaced string ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't survive postMessage/structured-clone, which T3 embedded hosts may forward patch events across. Namespaced string keeps collision risk negligible. - setCompositionMetadata width/height: runtime treats data-width/data-height as a forced override of inline style (init.ts applyCompositionSizing). Style is always written; the data-* attr is updated when already present so the edit isn't clobbered on load. Absent attrs stay absent — inverses stay exact. Mirrored in the patch applier; 3 new tests. - JsonPatchOp documented as the emit-only RFC 6902 subset (add/remove/replace); applier header notes move/copy/test are ignored. - SdkDocument.html documented as a build-time snapshot (serialize() is the live state). - patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}. NOT changed (with reasons, see PR reply): moveElement left/top matches Studio's own inline-style commit convention (sourcePatcher); package version follows the repo-wide single-version policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): moveElement writes data-x/data-y, not left/top CSS HF elements use data-x/data-y for positioning (read by htmlParser.ts, emitted by hyperframes generator). CSS left/top is not the runtime convention. Adds inverse round-trip test for prior position restore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update bun.lock after sdk package registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete * fix(sdk): address review — live-DOM query cache, single parse, style parse dedup - getElements/getElement/find now walk the live linkedom DOM via buildRoots with a lazily-built cache invalidated on dispatch/applyPatches — no serialize→ensureHfIds→parseHTML round trip per query - openComposition parses once (parseMutable); dropped discarded _doc constructor param and the redundant buildDocument call - document.ts buildElement reuses model.ts getElementStyles — removes duplicated parseInlineStyles (also fixes custom-prop camelCase mangling) - JSDoc note: empty batch() still fires change handlers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): restore full public exports now session/document modules exist index.ts re-exports document/session/history/persist-queue (trimmed in the engine-layer PR to keep it self-contained); drops the temporary fallow suppressions whose consumers now exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): coalesce history by patch paths; replay override-set on open Adversarial-review findings F1 + F2: - history: coalescing now requires identical patch paths in addition to op types + origin + window. Previously two rapid setStyle calls on DIFFERENT elements merged into one entry carrying the second forward + first inverse — undo then reverted the wrong element and stranded the latest edit. Slider drags on one property still coalesce. - T3 init: openComposition({ overrides }) now replays the stored override-set onto the freshly-parsed base before exposing the session (new keyToPath inverse mapping + applyOverrideSet). Previously the overrides were copied into the map but never applied — reopening an embedded composition showed and serialized the base template. - examples: GSAP calls now feature-detect with can() (Phase 3b ops throw UnsupportedOpError as of the engine-layer fix); UnsupportedOpError re-exported from the package entry. - 8 new session tests: coalesce same-path / cross-element / cross-prop, override round-trip (style/text/attr/timing/removal/restore-base). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify Round-2 review (Rames/Miguel) on the session layer: - batch() is now transactional: on throw, accumulated inverse patches are replayed in reverse and the override-set snapshot restored — the model is exactly as it was at batch entry. Previously a throwing batch left the DOM partially mutated with no patch trail, no history entry, no recovery path. 2 new tests (model unchanged + undo is no-op after throwing batch). - history coalesce key sorts opTypes — same op-type set coalesces regardless of dispatch order within a batch. - applyPatches comment documents that emitted PatchEvents carry an empty inversePatches array (hosts keep their own inverse log). - document.ts extractDimensions/extractDuration now use the engine's findRoot — dimension extraction and mutations agree on the root element ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's data-width/data-height forced-override attrs, falling back to inline style. - ownText documented: snapshot .text is trimmed display text; setText writes verbatim. Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush error surfacing, debounce window, path default, history ring-buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting * fix(studio,core): persist manual position edits for GSAP-owned elements - sourceMutation: linkedom CSSStyleDeclaration silently drops CSS custom properties and transform longhands via setProperty; patch the style attribute string directly so --hf-studio-offset-* and translate survive the server round-trip (positions never reached disk before this) - gsapAnimatesTransform(): GSAP owns the full transform stack when it tweens ANY transform prop (scale, rotation, ...), not just x/y — it folds CSS translate into its cache once at init, zeroes the longhand once, and never re-reads it - applyStudioPathOffset: for GSAP-owned elements keep translate:none live and sync the offset into GSAP's cache via gsap.set; writing the longhand double-applied the offset (disappearing elements, scrub snap-back) - buildPathOffsetPatches: emit the var() translate expression explicitly so the persisted file re-folds on reload (live inline is none) - StudioPathOffsetSnapshot: capture/restore GSAP x/y — the drag-response probe mutates GSAP's cache, which inline-style restore cannot undo (click made elements jump by the probe distance) - reapplyPathOffsets: skip GSAP-owned elements (was x/y-only) to stop seek-time double-apply - STUDIO_GSAP_DRAG_INTERCEPT flag (default off): keyframe drag intercept is opt-in until its recording path is hardened; commits take the CSS persist path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio): remove duplicate flag declaration, trim useDomEditCommits to 600 lines Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 511665b commit fc3ab76

12 files changed

Lines changed: 182 additions & 23 deletions

packages/core/src/studio-api/helpers/manualEditsRenderScript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
export interface StudioManualEditsRenderScriptOptions {
23
activeCompositionPath?: string | null;
34
}

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,33 @@ function isSafeAttributeValue(name: string, value: string): boolean {
223223
return true;
224224
}
225225

226+
function patchStyleAttrString(style: string, property: string, value: string | null): string {
227+
const props = new Map<string, string>();
228+
const order: string[] = [];
229+
for (const decl of style.split(";")) {
230+
const colon = decl.indexOf(":");
231+
if (colon < 0) continue;
232+
const key = decl.slice(0, colon).trim();
233+
const val = decl.slice(colon + 1).trim();
234+
if (!key) continue;
235+
if (!props.has(key)) order.push(key);
236+
props.set(key, val);
237+
}
238+
if (value === null) {
239+
props.delete(property);
240+
const idx = order.indexOf(property);
241+
if (idx >= 0) order.splice(idx, 1);
242+
} else {
243+
if (!props.has(property)) order.push(property);
244+
props.set(property, value);
245+
}
246+
return order
247+
.map((k) => `${k}: ${props.get(k) ?? ""}`)
248+
.filter((d) => d.trim())
249+
.join("; ");
250+
}
251+
252+
// fallow-ignore-next-line complexity
226253
export function patchElementInHtml(
227254
source: string,
228255
target: SourceMutationTarget,
@@ -236,10 +263,14 @@ export function patchElementInHtml(
236263
for (const op of operations) {
237264
switch (op.type) {
238265
case "inline-style":
239-
if (op.value != null) {
240-
htmlEl.style.setProperty(op.property, op.value);
241-
} else {
242-
htmlEl.style.removeProperty(op.property);
266+
// linkedom's CSSStyleDeclaration does not support CSS custom properties
267+
// (--foo) or newer individual transform properties (translate, rotate,
268+
// scale) via style.setProperty(). Manipulate the style attribute string
269+
// directly so all property names survive the round-trip.
270+
{
271+
const raw = htmlEl.getAttribute("style") ?? "";
272+
const patched = patchStyleAttrString(raw, op.property, op.value);
273+
htmlEl.setAttribute("style", patched);
243274
}
244275
break;
245276
case "attribute":

packages/studio/src/components/editor/gsapAnimatesProperty.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,37 @@
1+
// GSAP's CSSPlugin takes ownership of the element's entire transform stack
2+
// when it tweens ANY of these — it bakes the CSS `translate` longhand into
3+
// style.transform at init and writes `translate: none` every tick. Position
4+
// reapply/strip logic must therefore stand down for all of them, not just x/y.
5+
const GSAP_TRANSFORM_PROPS = [
6+
"x",
7+
"y",
8+
"xPercent",
9+
"yPercent",
10+
"scale",
11+
"scaleX",
12+
"scaleY",
13+
"rotation",
14+
"rotate",
15+
"rotationX",
16+
"rotationY",
17+
"skewX",
18+
"skewY",
19+
"transform",
20+
];
21+
22+
/**
23+
* True when GSAP animates any transform-affecting property on the element,
24+
* meaning GSAP owns `style.transform` and has neutralized CSS `translate`.
25+
*/
26+
export function gsapAnimatesTransform(el: HTMLElement): boolean {
27+
return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS);
28+
}
29+
130
/**
231
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
332
* the given element by inspecting all registered `__timelines`.
433
*/
34+
// fallow-ignore-next-line complexity
535
export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
636
const win = el.ownerDocument.defaultView as
737
| (Window & {

packages/studio/src/components/editor/manualEditingAvailability.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,6 @@ export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag
4747
true,
4848
);
4949

50-
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
51-
env,
52-
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT"],
53-
false,
54-
);
55-
5650
export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
5751
env,
5852
[STUDIO_INSPECTOR_PANELS_ENV, "VITE_STUDIO_INSPECTOR_PANELS_ENABLED"],
@@ -89,6 +83,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
8983
false,
9084
);
9185

86+
// When disabled (the default), drag/resize/rotate commits always take the CSS
87+
// persist path instead of being intercepted into GSAP script keyframe
88+
// mutations. The keyframe intercept rewrites timeline tweens from drag
89+
// gestures and is opt-in until its recording path is hardened.
90+
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
91+
env,
92+
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
93+
false,
94+
);
95+
9296
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
9397

9498
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";

packages/studio/src/components/editor/manualEditsDom.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
} from "./manualEditsTypes";
3333
import { roundRotationAngle } from "./manualEditsParsing";
3434
import { applyStudioMotionFromDom } from "./studioMotion";
35-
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
35+
import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty";
3636

3737
/* ── Gesture tracking ─────────────────────────────────────────────── */
3838
let studioManualEditGestureId = 0;
@@ -223,6 +223,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
223223
return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
224224
}
225225

226+
// fallow-ignore-next-line complexity
226227
function stripGsapTranslateFromTransform(element: HTMLElement): void {
227228
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
228229
const transform = element.style.getPropertyValue("transform");
@@ -257,6 +258,18 @@ export function applyStudioPathOffset(
257258
): void {
258259
promoteInlineForTransform(element);
259260
writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true });
261+
if (gsapAnimatesTransform(element)) {
262+
// GSAP folded the CSS translate into its transform cache at init and owns
263+
// style.transform from then on — it zeroes the translate longhand exactly
264+
// once (at fold time) and never re-reads it. Writing translate here would
265+
// double-apply the offset on top of the baked transform. Keep translate
266+
// neutral in the live DOM and push the offset into GSAP's cache instead;
267+
// the var() expression is persisted to the source file by the patch
268+
// builder, where a reload re-folds it.
269+
element.style.setProperty("translate", "none");
270+
syncGsapOwnedTransformPosition(element);
271+
return;
272+
}
260273
element.style.setProperty(
261274
"translate",
262275
composeTranslateValue(
@@ -268,18 +281,36 @@ export function applyStudioPathOffset(
268281
stripGsapTranslateFromTransform(element);
269282
}
270283

284+
/**
285+
* After committing a new path offset on an element whose transform GSAP owns,
286+
* GSAP's internal cache still holds the pre-drag baked translate — the next
287+
* seek re-renders from that cache and snaps the element back. Push the new
288+
* offset into GSAP so live scrubbing matches what was persisted. (A page
289+
* reload re-initializes GSAP from the persisted CSS translate, so this is
290+
* only needed for the live session.)
291+
*/
292+
function syncGsapOwnedTransformPosition(element: HTMLElement): void {
293+
if (!gsapAnimatesTransform(element)) return;
294+
const win = element.ownerDocument.defaultView as
295+
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
296+
| null;
297+
if (!win?.gsap?.set) return;
298+
const { x, y } = readStudioPathOffset(element);
299+
win.gsap.set(element, { x, y });
300+
}
301+
271302
export function applyStudioPathOffsetDraft(
272303
element: HTMLElement,
273304
offset: { x: number; y: number },
274305
): void {
275306
promoteInlineForTransform(element);
276307
writeStudioPathOffsetVars(element, offset, { updateBase: false });
277308

278-
const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
309+
const isGsapAnimated = gsapAnimatesTransform(element);
279310
if (isGsapAnimated) {
280-
// For GSAP-animated elements: use gsap.set for positioning (the timeline
281-
// is paused during drag). Set translate:none explicitly to prevent
282-
// double-counting with the transform.
311+
// GSAP owns style.transform (see applyStudioPathOffset): position via
312+
// gsap.set while the timeline is paused. Set translate:none explicitly to
313+
// prevent double-counting with the baked transform.
283314
element.style.setProperty("translate", "none");
284315
const win = element.ownerDocument.defaultView as
285316
| (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
@@ -520,10 +551,14 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
520551

521552
function reapplyPathOffsets(doc: Document): void {
522553
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
523-
const gsapSkip = gsapAnimatesProperty(el, "x", "y");
554+
// Skip elements where GSAP owns the transform stack — GSAP bakes the
555+
// CSS translate into its transform and sets translate: none every tick
556+
// when it tweens ANY transform property (x/y, scale, rotation, ...).
557+
// Stripping/restoring would oscillate against GSAP's rendering and
558+
// double-apply the offset.
559+
if (gsapAnimatesTransform(el)) continue;
524560
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
525561
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
526-
if (gsapSkip) continue;
527562
if (x || y) {
528563
applyStudioPathOffset(
529564
el,

packages/studio/src/components/editor/manualEditsDomPatches.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
// @vitest-environment happy-dom
23

34
import { describe, it, expect } from "vitest";

packages/studio/src/components/editor/manualEditsDomPatches.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]):
7272

7373
export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
7474
const ops: PatchOperation[] = [];
75-
collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops);
75+
collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops);
76+
// When GSAP owns the element's transform, the live inline translate is kept
77+
// at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset).
78+
// Persist the var() expression in that case, so a reload re-folds the offset.
79+
const inlineTranslate = element.style.getPropertyValue("translate");
80+
const hasOffsetVars =
81+
element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) ||
82+
element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
83+
const translateValue =
84+
inlineTranslate && inlineTranslate !== "none"
85+
? inlineTranslate
86+
: hasOffsetVars
87+
? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`
88+
: null;
89+
if (translateValue) {
90+
ops.push({ type: "inline-style", property: "translate", value: translateValue });
91+
}
7692
ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
7793
collectAttributeOps(
7894
element,

packages/studio/src/components/editor/manualEditsSnapshot.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
styleUsesStudioRotation,
55
restoreInlineDisplay,
66
} from "./manualEditsDom";
7+
import { gsapAnimatesTransform } from "./gsapAnimatesProperty";
78
import {
89
STUDIO_OFFSET_X_PROP,
910
STUDIO_OFFSET_Y_PROP,
@@ -87,14 +88,32 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps
8788
};
8889
}
8990

91+
type GsapWindow = Window & {
92+
gsap?: {
93+
getProperty?: (el: Element, prop: string) => number | string;
94+
set?: (el: Element, vars: Record<string, unknown>) => void;
95+
};
96+
};
97+
9098
export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot {
99+
let gsapX: number | null = null;
100+
let gsapY: number | null = null;
101+
if (gsapAnimatesTransform(element)) {
102+
const win = element.ownerDocument.defaultView as GsapWindow | null;
103+
if (win?.gsap?.getProperty) {
104+
gsapX = Number(win.gsap.getProperty(element, "x")) || 0;
105+
gsapY = Number(win.gsap.getProperty(element, "y")) || 0;
106+
}
107+
}
91108
return {
92109
translate: element.style.getPropertyValue("translate"),
93110
x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP),
94111
y: element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP),
95112
marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR),
96113
originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR),
97114
originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR),
115+
gsapX,
116+
gsapY,
98117
};
99118
}
100119

@@ -183,6 +202,13 @@ export function restoreStudioPathOffset(
183202
STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
184203
previous.originalInlineTranslate,
185204
);
205+
206+
// Draft positioning on GSAP-owned elements goes through gsap.set, which
207+
// mutates GSAP's transform cache — restore it alongside the inline styles.
208+
if (previous.gsapX != null || previous.gsapY != null) {
209+
const win = element.ownerDocument.defaultView as GsapWindow | null;
210+
win?.gsap?.set?.(element, { x: previous.gsapX ?? 0, y: previous.gsapY ?? 0 });
211+
}
186212
}
187213

188214
/* ── Clear functions ──────────────────────────────────────────────── */

packages/studio/src/components/editor/manualEditsTypes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,12 @@ export interface StudioPathOffsetSnapshot {
101101
marker: string | null;
102102
originalTranslate: string | null;
103103
originalInlineTranslate: string | null;
104+
/**
105+
* GSAP's cached x/y at capture time, for elements whose transform GSAP
106+
* owns. Draft positioning mutates GSAP's cache (gsap.set), which inline
107+
* style restoration alone cannot undo. Null when GSAP does not own the
108+
* element's transform.
109+
*/
110+
gsapX: number | null;
111+
gsapY: number | null;
104112
}

packages/studio/src/contexts/DomEditContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// fallow-ignore-file code-duplication
12
import { createContext, useContext, useMemo, type ReactNode } from "react";
23
import type { useDomEditSession } from "../hooks/useDomEditSession";
34

0 commit comments

Comments
 (0)