@canvus/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/drop-zone.d.ts +48 -0
- package/dist/drop-zone.d.ts.map +1 -0
- package/dist/drop-zone.js +230 -0
- package/dist/drop-zone.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +143 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +278 -0
- package/dist/layout.js.map +1 -0
- package/dist/matrix.d.ts +168 -0
- package/dist/matrix.d.ts.map +1 -0
- package/dist/matrix.js +264 -0
- package/dist/matrix.js.map +1 -0
- package/dist/renderer.d.ts +286 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +892 -0
- package/dist/renderer.js.map +1 -0
- package/dist/shadow-mount.d.ts +367 -0
- package/dist/shadow-mount.d.ts.map +1 -0
- package/dist/shadow-mount.js +1120 -0
- package/dist/shadow-mount.js.map +1 -0
- package/dist/tree.d.ts +134 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +458 -0
- package/dist/tree.js.map +1 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +43 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace.d.ts +371 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +3922 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +30 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// canvus/src/shadow-mount.ts
|
|
3
|
+
// Shadow DOM Projection Layer — Lifecycle, Observer, and
|
|
4
|
+
// Geometry Extraction Engine.
|
|
5
|
+
//
|
|
6
|
+
// This module owns the open ShadowRoot that hosts all user HTML
|
|
7
|
+
// fragments. It keeps the shadow layer visually synchronized
|
|
8
|
+
// with the canvas viewport via CSS transforms, drives a
|
|
9
|
+
// ResizeObserver for reflow detection, and exposes the "flat
|
|
10
|
+
// string bridge" for clean HTML extraction.
|
|
11
|
+
// ─────────────────────────────────────────────────────────────
|
|
12
|
+
// ── Reset Stylesheet ────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Injected into the ShadowRoot to isolate user content from the
|
|
15
|
+
* host application's styles. Resets the `:host` display context
|
|
16
|
+
* and enforces `border-box` sizing on all user elements.
|
|
17
|
+
*/
|
|
18
|
+
const SHADOW_RESET_CSS = `
|
|
19
|
+
:host(.canvus-no-transitions) * {
|
|
20
|
+
transition: none !important;
|
|
21
|
+
animation: none !important;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:host {
|
|
25
|
+
all: initial;
|
|
26
|
+
display: block;
|
|
27
|
+
position: absolute;
|
|
28
|
+
top: 0;
|
|
29
|
+
left: 0;
|
|
30
|
+
width: 0;
|
|
31
|
+
height: 0;
|
|
32
|
+
overflow: visible;
|
|
33
|
+
transform-origin: 0 0;
|
|
34
|
+
pointer-events: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.canvus-node-wrapper {
|
|
38
|
+
position: absolute;
|
|
39
|
+
pointer-events: auto;
|
|
40
|
+
transform-origin: 0 0;
|
|
41
|
+
overflow: visible;
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
user-select: none;
|
|
45
|
+
-webkit-user-select: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.canvus-node-wrapper.canvus-editing {
|
|
49
|
+
user-select: text !important;
|
|
50
|
+
-webkit-user-select: text !important;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Flow-positioned children inherit their parent's layout mode. */
|
|
54
|
+
.canvus-node-wrapper.canvus-flow-child {
|
|
55
|
+
display: contents;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.canvus-node-wrapper > * {
|
|
59
|
+
flex: 1 0 auto;
|
|
60
|
+
min-width: 0;
|
|
61
|
+
min-height: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.canvus-node-wrapper * {
|
|
65
|
+
box-sizing: border-box;
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
// ── ShadowMount Class ───────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Manages the Shadow DOM projection layer lifecycle.
|
|
71
|
+
*
|
|
72
|
+
* ### Responsibilities
|
|
73
|
+
* 1. Creates a host `<div>` and attaches an open `ShadowRoot`.
|
|
74
|
+
* 2. Mounts user HTML fragments as isolated, absolutely-positioned
|
|
75
|
+
* wrapper nodes inside the shadow tree.
|
|
76
|
+
* 3. Applies viewport CSS transforms to keep the shadow layer
|
|
77
|
+
* visually synchronized with the canvas overlay.
|
|
78
|
+
* 4. Runs a `ResizeObserver` on every mounted wrapper to detect
|
|
79
|
+
* content reflow and fire `RectChangeCallback` notifications.
|
|
80
|
+
* 5. Provides geometry extraction (`.measureNode`, `.measureAll`)
|
|
81
|
+
* and the flat string bridge (`.extractHTML`).
|
|
82
|
+
*
|
|
83
|
+
* ### Coordinate Convention
|
|
84
|
+
* All positions stored and returned by this class are in
|
|
85
|
+
* **canvas-space** (world coordinates). The viewport CSS transform
|
|
86
|
+
* on the host element handles the canvas→screen projection.
|
|
87
|
+
*/
|
|
88
|
+
export class ShadowMount {
|
|
89
|
+
// ── Private State ───────────────────────────────────────
|
|
90
|
+
/** The host element appended to the user's container. */
|
|
91
|
+
host;
|
|
92
|
+
/** The open ShadowRoot attached to the host. */
|
|
93
|
+
shadow;
|
|
94
|
+
/** Map of node ID → internal metadata (wrapper + position). */
|
|
95
|
+
nodes = new Map();
|
|
96
|
+
/** Uniform scale applied to host via applied viewport transform. */
|
|
97
|
+
currentScale = 1;
|
|
98
|
+
/**
|
|
99
|
+
* Reverse lookup: wrapper Element → node ID.
|
|
100
|
+
* Required because `ResizeObserver` callbacks receive the
|
|
101
|
+
* observed `Element`, not our application-level ID.
|
|
102
|
+
*/
|
|
103
|
+
elementToId = new Map();
|
|
104
|
+
/** Single shared observer watching all mounted wrappers. */
|
|
105
|
+
resizeObserver;
|
|
106
|
+
/** External rect-change callback, or `null` if none provided. */
|
|
107
|
+
onRectChange;
|
|
108
|
+
/**
|
|
109
|
+
* Guard flag to suppress ResizeObserver notifications during
|
|
110
|
+
* our own programmatic style mutations (e.g. `setNodeSize`).
|
|
111
|
+
* Prevents feedback loops where our write triggers an
|
|
112
|
+
* observer read that triggers another write.
|
|
113
|
+
*/
|
|
114
|
+
suppressObserver = false;
|
|
115
|
+
/** Whether `dispose()` has been called. */
|
|
116
|
+
disposed = false;
|
|
117
|
+
// ── Constructor ─────────────────────────────────────────
|
|
118
|
+
/**
|
|
119
|
+
* @param container - The parent DOM element to mount into.
|
|
120
|
+
* Typically the workspace root `<div>`.
|
|
121
|
+
* @param onRectChange - Optional callback fired whenever a
|
|
122
|
+
* mounted node's bounding rect changes.
|
|
123
|
+
*/
|
|
124
|
+
constructor(container, onRectChange) {
|
|
125
|
+
this.onRectChange = onRectChange ?? null;
|
|
126
|
+
// ── Host Element ──────────────────────────────────────
|
|
127
|
+
this.host = document.createElement("div");
|
|
128
|
+
this.host.setAttribute("data-canvus-shadow-host", "");
|
|
129
|
+
// ── Shadow Root ───────────────────────────────────────
|
|
130
|
+
this.shadow = this.host.attachShadow({ mode: "open" });
|
|
131
|
+
// Inject the isolation reset stylesheet.
|
|
132
|
+
const style = document.createElement("style");
|
|
133
|
+
style.textContent = SHADOW_RESET_CSS;
|
|
134
|
+
this.shadow.appendChild(style);
|
|
135
|
+
// ── ResizeObserver ────────────────────────────────────
|
|
136
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
137
|
+
if (this.suppressObserver)
|
|
138
|
+
return;
|
|
139
|
+
this.handleResizeEntries(entries);
|
|
140
|
+
});
|
|
141
|
+
// Attach to the DOM tree.
|
|
142
|
+
container.appendChild(this.host);
|
|
143
|
+
}
|
|
144
|
+
// ── Node Lifecycle ──────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Mounts a `WebHTMLNode` into the shadow tree.
|
|
147
|
+
*
|
|
148
|
+
* Creates an absolutely-positioned wrapper `<div>`, injects
|
|
149
|
+
* the raw markup via `innerHTML`, positions it in canvas-space,
|
|
150
|
+
* and starts observing it for size changes.
|
|
151
|
+
*
|
|
152
|
+
* @param node - The node descriptor to mount.
|
|
153
|
+
* @returns The initial canvas-space bounding rect after the
|
|
154
|
+
* browser has performed synchronous layout.
|
|
155
|
+
* @throws If a node with the same `id` is already mounted.
|
|
156
|
+
*/
|
|
157
|
+
addNode(node) {
|
|
158
|
+
this.assertNotDisposed();
|
|
159
|
+
if (this.nodes.has(node.id)) {
|
|
160
|
+
throw new Error(`[ShadowMount] Node "${node.id}" is already mounted. ` +
|
|
161
|
+
`Call removeNode() first or use updateMarkup().`);
|
|
162
|
+
}
|
|
163
|
+
// Check if the wrapper is already present in the shadow tree (e.g. from document importer)
|
|
164
|
+
let wrapper = this.shadow.querySelector(`.canvus-node-wrapper[data-canvus-id="${node.id}"]`);
|
|
165
|
+
const isPreMounted = !!wrapper;
|
|
166
|
+
if (!wrapper) {
|
|
167
|
+
// ── Create Wrapper ──────────────────────────────────
|
|
168
|
+
wrapper = document.createElement("div");
|
|
169
|
+
wrapper.className = "canvus-node-wrapper";
|
|
170
|
+
wrapper.setAttribute("data-canvus-id", node.id);
|
|
171
|
+
// Inject user HTML.
|
|
172
|
+
wrapper.innerHTML = node.rawMarkup;
|
|
173
|
+
// ── Position in Canvas-Space ────────────────────────
|
|
174
|
+
const cx = node.currentRect?.x ?? 0;
|
|
175
|
+
const cy = node.currentRect?.y ?? 0;
|
|
176
|
+
wrapper.style.left = `${cx}px`;
|
|
177
|
+
wrapper.style.top = `${cy}px`;
|
|
178
|
+
// ── Mount to Shadow Tree ────────────────────────────
|
|
179
|
+
this.shadow.appendChild(wrapper);
|
|
180
|
+
}
|
|
181
|
+
// Apply explicit width and height if provided (applies to both pre-mounted and new nodes)
|
|
182
|
+
if (node.currentRect) {
|
|
183
|
+
if (node.currentRect.width > 0) {
|
|
184
|
+
wrapper.style.width = `${node.currentRect.width}px`;
|
|
185
|
+
}
|
|
186
|
+
if (node.currentRect.height > 0) {
|
|
187
|
+
wrapper.style.height = `${node.currentRect.height}px`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// ── Position in Canvas-Space ────────────────────────
|
|
191
|
+
const cx = node.currentRect?.x ?? (isPreMounted ? wrapper.offsetLeft : 0);
|
|
192
|
+
const cy = node.currentRect?.y ?? (isPreMounted ? wrapper.offsetTop : 0);
|
|
193
|
+
// ── Sync Grid Styles ────────────────────────────────
|
|
194
|
+
const contentRoot = wrapper.firstElementChild;
|
|
195
|
+
if (contentRoot) {
|
|
196
|
+
const cs = getComputedStyle(contentRoot);
|
|
197
|
+
const gridProps = [
|
|
198
|
+
"grid-column-start",
|
|
199
|
+
"grid-column-end",
|
|
200
|
+
"grid-row-start",
|
|
201
|
+
"grid-row-end",
|
|
202
|
+
"grid-area",
|
|
203
|
+
"grid-column",
|
|
204
|
+
"grid-row",
|
|
205
|
+
];
|
|
206
|
+
for (const prop of gridProps) {
|
|
207
|
+
const val = cs.getPropertyValue(prop);
|
|
208
|
+
if (val && val !== "auto" && val !== "normal" && val !== "none") {
|
|
209
|
+
wrapper.style.setProperty(prop, val);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// ── Register Tracking ───────────────────────────────
|
|
214
|
+
const mounted = { wrapper, canvasX: cx, canvasY: cy };
|
|
215
|
+
this.nodes.set(node.id, mounted);
|
|
216
|
+
const targetToObserve = wrapper.firstElementChild || wrapper;
|
|
217
|
+
this.elementToId.set(targetToObserve, node.id);
|
|
218
|
+
// ── Start Observing Reflow ──────────────────────────
|
|
219
|
+
this.resizeObserver.observe(targetToObserve);
|
|
220
|
+
const dims = this.getBoundingBoxCanvasSpace(targetToObserve);
|
|
221
|
+
const rect = {
|
|
222
|
+
x: cx,
|
|
223
|
+
y: cy,
|
|
224
|
+
width: dims.width,
|
|
225
|
+
height: dims.height,
|
|
226
|
+
};
|
|
227
|
+
return rect;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Mounts a node as a child of another mounted node.
|
|
231
|
+
*
|
|
232
|
+
* The child wrapper is inserted inside the parent's wrapper
|
|
233
|
+
* (not at the shadow root) and uses `position: relative` so
|
|
234
|
+
* it participates in the parent's CSS layout flow (flex, grid,
|
|
235
|
+
* block).
|
|
236
|
+
*
|
|
237
|
+
* @param node - The node descriptor to mount.
|
|
238
|
+
* @param parentId - The ID of the parent node.
|
|
239
|
+
* @param index - Optional insertion index within the parent's
|
|
240
|
+
* DOM children. Defaults to appending at the end.
|
|
241
|
+
* @returns The initial canvas-space bounding rect.
|
|
242
|
+
* @throws If the parent is not mounted or the node ID already exists.
|
|
243
|
+
*/
|
|
244
|
+
addChildNode(node, parentId, index) {
|
|
245
|
+
this.assertNotDisposed();
|
|
246
|
+
if (this.nodes.has(node.id)) {
|
|
247
|
+
throw new Error(`[ShadowMount] Node "${node.id}" is already mounted.`);
|
|
248
|
+
}
|
|
249
|
+
const parent = this.nodes.get(parentId);
|
|
250
|
+
if (!parent) {
|
|
251
|
+
throw new Error(`[ShadowMount] Parent node "${parentId}" is not mounted.`);
|
|
252
|
+
}
|
|
253
|
+
// ── Locate or create the child element ──────────────────
|
|
254
|
+
// Priority 1: pre-mounted wrapper (legacy path)
|
|
255
|
+
let wrapper = this.shadow.querySelector(`.canvus-node-wrapper[data-canvus-id="${node.id}"]`);
|
|
256
|
+
let isDirect = false;
|
|
257
|
+
if (!wrapper) {
|
|
258
|
+
// Priority 2: direct element marked by the importer (no wrapper div)
|
|
259
|
+
const directEl = this.shadow.querySelector(`[data-canvus-id="${node.id}"]:not(.canvus-node-wrapper)`);
|
|
260
|
+
if (directEl) {
|
|
261
|
+
wrapper = directEl;
|
|
262
|
+
isDirect = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!wrapper) {
|
|
266
|
+
// Fallback: programmatic addChildNode — insert raw markup directly
|
|
267
|
+
// as a child of the parent's content root, no wrapper div.
|
|
268
|
+
const parentContentRoot = this.getContentRootInternal(parent);
|
|
269
|
+
const insertTarget = parentContentRoot ?? parent.wrapper;
|
|
270
|
+
const temp = document.createElement("div");
|
|
271
|
+
temp.innerHTML = node.rawMarkup;
|
|
272
|
+
const newElement = temp.firstElementChild;
|
|
273
|
+
if (newElement) {
|
|
274
|
+
newElement.setAttribute("data-canvus-id", node.id);
|
|
275
|
+
// Insert at the specified index if provided.
|
|
276
|
+
const existingChildren = insertTarget.querySelectorAll(":scope > [data-canvus-id]");
|
|
277
|
+
if (index !== undefined && index >= 0 && index < existingChildren.length) {
|
|
278
|
+
insertTarget.insertBefore(newElement, existingChildren[index] ?? null);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
insertTarget.appendChild(newElement);
|
|
282
|
+
}
|
|
283
|
+
wrapper = newElement;
|
|
284
|
+
isDirect = true;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// Fallback to wrapper-based approach for text-only nodes
|
|
288
|
+
const wrapperDiv = document.createElement("div");
|
|
289
|
+
wrapperDiv.className = "canvus-node-wrapper canvus-flow-child";
|
|
290
|
+
wrapperDiv.setAttribute("data-canvus-id", node.id);
|
|
291
|
+
wrapperDiv.innerHTML = node.rawMarkup;
|
|
292
|
+
insertTarget.appendChild(wrapperDiv);
|
|
293
|
+
wrapper = wrapperDiv;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Apply explicit dimensions if provided.
|
|
297
|
+
if (node.currentRect) {
|
|
298
|
+
if (node.currentRect.width > 0) {
|
|
299
|
+
wrapper.style.width = `${node.currentRect.width}px`;
|
|
300
|
+
}
|
|
301
|
+
if (node.currentRect.height > 0) {
|
|
302
|
+
wrapper.style.height = `${node.currentRect.height}px`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Grid style sync only needed for wrapper-based nodes (the wrapper
|
|
306
|
+
// needs grid placement copied from the content root). Direct elements
|
|
307
|
+
// already participate in the parent grid natively.
|
|
308
|
+
if (!isDirect) {
|
|
309
|
+
const contentRoot = wrapper.firstElementChild;
|
|
310
|
+
if (contentRoot) {
|
|
311
|
+
const cs = getComputedStyle(contentRoot);
|
|
312
|
+
const gridProps = [
|
|
313
|
+
"grid-column-start",
|
|
314
|
+
"grid-column-end",
|
|
315
|
+
"grid-row-start",
|
|
316
|
+
"grid-row-end",
|
|
317
|
+
"grid-area",
|
|
318
|
+
"grid-column",
|
|
319
|
+
"grid-row",
|
|
320
|
+
];
|
|
321
|
+
for (const prop of gridProps) {
|
|
322
|
+
const val = cs.getPropertyValue(prop);
|
|
323
|
+
if (val && val !== "auto" && val !== "normal" && val !== "none") {
|
|
324
|
+
wrapper.style.setProperty(prop, val);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Register tracking.
|
|
330
|
+
const mounted = { wrapper, canvasX: 0, canvasY: 0, isDirect };
|
|
331
|
+
this.nodes.set(node.id, mounted);
|
|
332
|
+
const targetToObserve = isDirect
|
|
333
|
+
? wrapper
|
|
334
|
+
: (wrapper.firstElementChild || wrapper);
|
|
335
|
+
this.elementToId.set(targetToObserve, node.id);
|
|
336
|
+
this.resizeObserver.observe(targetToObserve);
|
|
337
|
+
// Measure canvas-space rect (accounts for nesting).
|
|
338
|
+
const rect = this.measureNodeCanvasSpace(node.id) ?? {
|
|
339
|
+
x: 0, y: 0,
|
|
340
|
+
width: this.getBoundingBoxCanvasSpace(targetToObserve).width,
|
|
341
|
+
height: this.getBoundingBoxCanvasSpace(targetToObserve).height,
|
|
342
|
+
};
|
|
343
|
+
// Update tracked position.
|
|
344
|
+
mounted.canvasX = rect.x;
|
|
345
|
+
mounted.canvasY = rect.y;
|
|
346
|
+
return rect;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Unmounts and destroys a node by ID.
|
|
350
|
+
*
|
|
351
|
+
* Stops observing, removes the wrapper from the shadow tree,
|
|
352
|
+
* and cleans up all internal references.
|
|
353
|
+
*
|
|
354
|
+
* @param id - The node ID to remove.
|
|
355
|
+
* @returns `true` if the node existed and was removed.
|
|
356
|
+
*/
|
|
357
|
+
removeNode(id) {
|
|
358
|
+
const mounted = this.nodes.get(id);
|
|
359
|
+
if (!mounted)
|
|
360
|
+
return false;
|
|
361
|
+
// Clean up dynamic scripts appended for this node
|
|
362
|
+
const scriptElements = this.shadow.querySelectorAll(`script[data-canvus-script-id^="${id}:"]`);
|
|
363
|
+
for (const el of Array.from(scriptElements)) {
|
|
364
|
+
el.remove();
|
|
365
|
+
}
|
|
366
|
+
const targetToObserve = mounted.isDirect
|
|
367
|
+
? mounted.wrapper
|
|
368
|
+
: (mounted.wrapper.firstElementChild || mounted.wrapper);
|
|
369
|
+
this.resizeObserver.unobserve(targetToObserve);
|
|
370
|
+
this.elementToId.delete(targetToObserve);
|
|
371
|
+
mounted.wrapper.remove();
|
|
372
|
+
this.nodes.delete(id);
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Registers an existing DOM element for tracking without modifying
|
|
377
|
+
* the DOM structure. Used for lazy child registration: when the user
|
|
378
|
+
* drills into a node, its immediate children are tracked so they
|
|
379
|
+
* get hover states, selection handles, resize, and drag.
|
|
380
|
+
*
|
|
381
|
+
* The element receives a `data-canvus-id` attribute for identity,
|
|
382
|
+
* but NO wrapper div is added — CSS selectors remain intact.
|
|
383
|
+
*
|
|
384
|
+
* @param id - The node ID to assign.
|
|
385
|
+
* @param element - The existing DOM element to track.
|
|
386
|
+
* @returns The element's canvas-space bounding rect, or null.
|
|
387
|
+
*/
|
|
388
|
+
trackExistingElement(id, element) {
|
|
389
|
+
this.assertNotDisposed();
|
|
390
|
+
if (this.nodes.has(id)) {
|
|
391
|
+
return this.measureNodeCanvasSpace(id);
|
|
392
|
+
}
|
|
393
|
+
// Tag the element for identity (non-destructive — just a data attribute)
|
|
394
|
+
element.setAttribute("data-canvus-id", id);
|
|
395
|
+
// Register tracking
|
|
396
|
+
const mounted = {
|
|
397
|
+
wrapper: element,
|
|
398
|
+
canvasX: 0,
|
|
399
|
+
canvasY: 0,
|
|
400
|
+
isDirect: true,
|
|
401
|
+
};
|
|
402
|
+
this.nodes.set(id, mounted);
|
|
403
|
+
this.elementToId.set(element, id);
|
|
404
|
+
this.resizeObserver.observe(element);
|
|
405
|
+
// Measure canvas-space rect
|
|
406
|
+
const rect = this.measureNodeCanvasSpace(id) ?? {
|
|
407
|
+
x: 0,
|
|
408
|
+
y: 0,
|
|
409
|
+
width: this.getBoundingBoxCanvasSpace(element).width,
|
|
410
|
+
height: this.getBoundingBoxCanvasSpace(element).height,
|
|
411
|
+
};
|
|
412
|
+
mounted.canvasX = rect.x;
|
|
413
|
+
mounted.canvasY = rect.y;
|
|
414
|
+
return rect;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Stops tracking a node without removing the DOM element.
|
|
418
|
+
* The inverse of `trackExistingElement` — cleans up the
|
|
419
|
+
* `data-canvus-id` attribute, ResizeObserver, and internal maps,
|
|
420
|
+
* but leaves the element in the DOM untouched.
|
|
421
|
+
*
|
|
422
|
+
* Used for lazy deregistration when the user drills back up
|
|
423
|
+
* or deselects a parent node.
|
|
424
|
+
*
|
|
425
|
+
* @param id - The node ID to stop tracking.
|
|
426
|
+
* @returns `true` if the node was being tracked and was untracked.
|
|
427
|
+
*/
|
|
428
|
+
untrackNode(id) {
|
|
429
|
+
const mounted = this.nodes.get(id);
|
|
430
|
+
if (!mounted)
|
|
431
|
+
return false;
|
|
432
|
+
// Only untrack direct (wrapper-less) nodes.
|
|
433
|
+
// Wrapper-based nodes should use removeNode() instead.
|
|
434
|
+
if (!mounted.isDirect)
|
|
435
|
+
return false;
|
|
436
|
+
// Stop observing
|
|
437
|
+
this.resizeObserver.unobserve(mounted.wrapper);
|
|
438
|
+
this.elementToId.delete(mounted.wrapper);
|
|
439
|
+
// Clean up the data attribute
|
|
440
|
+
mounted.wrapper.removeAttribute("data-canvus-id");
|
|
441
|
+
// Remove from tracking
|
|
442
|
+
this.nodes.delete(id);
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Moves a node's DOM wrapper from its current parent into a
|
|
447
|
+
* new parent's wrapper at the specified index.
|
|
448
|
+
*
|
|
449
|
+
* If `newParentId` is `null`, the node is moved to the shadow
|
|
450
|
+
* root and becomes absolutely positioned (root-level node).
|
|
451
|
+
*
|
|
452
|
+
* @param id - The node to move.
|
|
453
|
+
* @param newParentId - The new parent ID, or `null` for root.
|
|
454
|
+
* @param index - Insertion index in the new parent.
|
|
455
|
+
*/
|
|
456
|
+
reparentNodeDOM(id, newParentId, index) {
|
|
457
|
+
const mounted = this.nodes.get(id);
|
|
458
|
+
if (!mounted)
|
|
459
|
+
return;
|
|
460
|
+
// Suppress observer during reparenting to avoid stale callbacks.
|
|
461
|
+
this.suppressObserver = true;
|
|
462
|
+
// Detach from current location.
|
|
463
|
+
mounted.wrapper.remove();
|
|
464
|
+
if (newParentId === null) {
|
|
465
|
+
// Move to shadow root — become absolutely positioned.
|
|
466
|
+
mounted.wrapper.classList.remove("canvus-flow-child");
|
|
467
|
+
mounted.wrapper.style.position = "absolute";
|
|
468
|
+
this.shadow.appendChild(mounted.wrapper);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
const newParent = this.nodes.get(newParentId);
|
|
472
|
+
if (!newParent) {
|
|
473
|
+
this.suppressObserver = false;
|
|
474
|
+
throw new Error(`[ShadowMount] New parent "${newParentId}" is not mounted.`);
|
|
475
|
+
}
|
|
476
|
+
// Become a flow child.
|
|
477
|
+
if (!mounted.isDirect) {
|
|
478
|
+
mounted.wrapper.classList.add("canvus-flow-child");
|
|
479
|
+
}
|
|
480
|
+
mounted.wrapper.style.position = "";
|
|
481
|
+
mounted.wrapper.style.left = "auto";
|
|
482
|
+
mounted.wrapper.style.top = "auto";
|
|
483
|
+
// Insert into parent's CONTENT ROOT (user's markup root).
|
|
484
|
+
const parentContentRoot = this.getContentRootInternal(newParent);
|
|
485
|
+
const insertTarget = parentContentRoot ?? newParent.wrapper;
|
|
486
|
+
const parentChildren = insertTarget.querySelectorAll(":scope > .canvus-node-wrapper, :scope > [data-canvus-id]");
|
|
487
|
+
if (index !== undefined && index >= 0 && index < parentChildren.length) {
|
|
488
|
+
insertTarget.insertBefore(mounted.wrapper, parentChildren[index] ?? null);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
insertTarget.appendChild(mounted.wrapper);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
this.suppressObserver = false;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Replaces the inner HTML content of an already-mounted node.
|
|
498
|
+
*
|
|
499
|
+
* Preserves the wrapper's position and size constraints.
|
|
500
|
+
* After the markup swap, forces a synchronous layout read
|
|
501
|
+
* and fires the rect-change callback if dimensions changed.
|
|
502
|
+
*
|
|
503
|
+
* @param id - The mounted node's ID.
|
|
504
|
+
* @param markup - The new raw HTML fragment string.
|
|
505
|
+
* @returns The new canvas-space bounding rect, or `null` if
|
|
506
|
+
* the node is not mounted.
|
|
507
|
+
*/
|
|
508
|
+
updateMarkup(id, markup) {
|
|
509
|
+
const mounted = this.nodes.get(id);
|
|
510
|
+
if (!mounted)
|
|
511
|
+
return null;
|
|
512
|
+
// Suppress observer during our own mutation to avoid
|
|
513
|
+
// a redundant callback before we've finished measuring.
|
|
514
|
+
this.suppressObserver = true;
|
|
515
|
+
mounted.wrapper.innerHTML = markup;
|
|
516
|
+
this.suppressObserver = false;
|
|
517
|
+
// Sync layout read.
|
|
518
|
+
const rect = this.readWrapperRect(mounted);
|
|
519
|
+
// Notify consumer.
|
|
520
|
+
this.onRectChange?.(id, rect);
|
|
521
|
+
return rect;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Returns whether a node with the given ID is currently mounted.
|
|
525
|
+
*/
|
|
526
|
+
hasNode(id) {
|
|
527
|
+
return this.nodes.has(id);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Returns an array of all currently mounted node IDs.
|
|
531
|
+
*/
|
|
532
|
+
getNodeIds() {
|
|
533
|
+
return Array.from(this.nodes.keys());
|
|
534
|
+
}
|
|
535
|
+
// ── Viewport Synchronization ────────────────────────────
|
|
536
|
+
/**
|
|
537
|
+
* Applies a CSS transform to the shadow host so that all
|
|
538
|
+
* child wrappers (positioned in canvas-space) are projected
|
|
539
|
+
* correctly onto the screen in sync with the canvas overlay.
|
|
540
|
+
*
|
|
541
|
+
* Must be called every time the viewport changes (pan/zoom).
|
|
542
|
+
*
|
|
543
|
+
* The transform maps canvas-space → screen-space:
|
|
544
|
+
* `translate(offsetX, offsetY) scale(scale)`
|
|
545
|
+
*
|
|
546
|
+
* @param viewport - The current viewport matrix state.
|
|
547
|
+
*/
|
|
548
|
+
applyViewportTransform(viewport) {
|
|
549
|
+
this.assertNotDisposed();
|
|
550
|
+
this.currentScale = viewport.scale;
|
|
551
|
+
this.host.style.transform =
|
|
552
|
+
`translate(${viewport.offsetX}px, ${viewport.offsetY}px) scale(${viewport.scale})`;
|
|
553
|
+
}
|
|
554
|
+
// ── Geometry Extraction ─────────────────────────────────
|
|
555
|
+
/**
|
|
556
|
+
* Returns the wrapper DOM element for a mounted node.
|
|
557
|
+
* Useful for layout introspection (reading getComputedStyle).
|
|
558
|
+
*
|
|
559
|
+
* @param id - The node ID.
|
|
560
|
+
* @returns The wrapper element, or `null` if not mounted.
|
|
561
|
+
*/
|
|
562
|
+
getWrapper(id) {
|
|
563
|
+
return this.nodes.get(id)?.wrapper ?? null;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Returns the content root element for a mounted node by its ID.
|
|
567
|
+
* For wrapper-based nodes, this is `wrapper.firstElementChild`.
|
|
568
|
+
* For direct (wrapper-less) nodes, the wrapper IS the content root.
|
|
569
|
+
*
|
|
570
|
+
* @param id - The node ID.
|
|
571
|
+
* @returns The content root element, or `null` if not mounted.
|
|
572
|
+
*/
|
|
573
|
+
getContentRoot(id) {
|
|
574
|
+
const mounted = this.nodes.get(id);
|
|
575
|
+
if (!mounted)
|
|
576
|
+
return null;
|
|
577
|
+
return this.getContentRootInternal(mounted);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Temporarily disables or re-enables all CSS transitions and animations
|
|
581
|
+
* inside the shadow DOM (useful to avoid layout lag during drag-and-drop).
|
|
582
|
+
*/
|
|
583
|
+
setTransitionsEnabled(enabled) {
|
|
584
|
+
if (enabled) {
|
|
585
|
+
this.host.classList.remove("canvus-no-transitions");
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
this.host.classList.add("canvus-no-transitions");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Reads the current canvas-space bounding rect of a mounted
|
|
593
|
+
* node by performing a synchronous layout query.
|
|
594
|
+
*
|
|
595
|
+
* Uses `offsetWidth` / `offsetHeight` (which return pre-transform
|
|
596
|
+
* layout dimensions) combined with our tracked canvas-space
|
|
597
|
+
* position to avoid inverse-transform math.
|
|
598
|
+
*
|
|
599
|
+
* @param id - The node ID to measure.
|
|
600
|
+
* @returns The canvas-space bounding rect, or `null` if not mounted.
|
|
601
|
+
*/
|
|
602
|
+
measureNode(id) {
|
|
603
|
+
return this.measureNodeCanvasSpace(id);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Batch-measures all mounted nodes in a single pass.
|
|
607
|
+
*
|
|
608
|
+
* Returns a `Map<id, Rect>` of canvas-space bounding rects.
|
|
609
|
+
* Triggers a single synchronous reflow for the entire batch.
|
|
610
|
+
*
|
|
611
|
+
* This is the "Geometry Extraction Loop" from the architecture
|
|
612
|
+
* spec — a fast initialization sweep to populate state caches.
|
|
613
|
+
*/
|
|
614
|
+
measureAll() {
|
|
615
|
+
const results = new Map();
|
|
616
|
+
for (const id of this.nodes.keys()) {
|
|
617
|
+
const rect = this.measureNodeCanvasSpace(id);
|
|
618
|
+
if (rect)
|
|
619
|
+
results.set(id, rect);
|
|
620
|
+
}
|
|
621
|
+
return results;
|
|
622
|
+
}
|
|
623
|
+
// ── Style Surgery (Direct Mutation) ─────────────────────
|
|
624
|
+
/**
|
|
625
|
+
* Moves a node to a new canvas-space position by directly
|
|
626
|
+
* mutating its inline `left` / `top` styles.
|
|
627
|
+
*
|
|
628
|
+
* This is the "Transient Style Surgery Pass" for drag-node
|
|
629
|
+
* interactions — no async message bus, just a direct write.
|
|
630
|
+
*
|
|
631
|
+
* @param id - The node ID to reposition.
|
|
632
|
+
* @param x - New canvas-space X position.
|
|
633
|
+
* @param y - New canvas-space Y position.
|
|
634
|
+
*/
|
|
635
|
+
setNodePosition(id, x, y) {
|
|
636
|
+
const mounted = this.nodes.get(id);
|
|
637
|
+
if (!mounted)
|
|
638
|
+
return;
|
|
639
|
+
mounted.canvasX = x;
|
|
640
|
+
mounted.canvasY = y;
|
|
641
|
+
mounted.wrapper.style.left = `${x}px`;
|
|
642
|
+
mounted.wrapper.style.top = `${y}px`;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Sets explicit width and/or height on a node's wrapper.
|
|
646
|
+
*
|
|
647
|
+
* This is the "Transient Style Surgery Pass" for resize-node
|
|
648
|
+
* interactions. The browser will reflow the inner content
|
|
649
|
+
* (e.g. text wrapping) synchronously, and the ResizeObserver
|
|
650
|
+
* will fire a rect-change callback with the new dimensions.
|
|
651
|
+
*
|
|
652
|
+
* Pass `null` for either dimension to leave it unchanged.
|
|
653
|
+
* Pass `"auto"` to clear an explicit dimension and let content
|
|
654
|
+
* determine the size.
|
|
655
|
+
*
|
|
656
|
+
* @param id - The node ID to resize.
|
|
657
|
+
* @param width - New width in canvas-space pixels, `"auto"`, or `null`.
|
|
658
|
+
* @param height - New height in canvas-space pixels, `"auto"`, or `null`.
|
|
659
|
+
*/
|
|
660
|
+
setNodeSize(id, width, height) {
|
|
661
|
+
const mounted = this.nodes.get(id);
|
|
662
|
+
if (!mounted)
|
|
663
|
+
return;
|
|
664
|
+
if (width !== null) {
|
|
665
|
+
mounted.wrapper.style.width =
|
|
666
|
+
width === "auto" ? "auto" : `${width}px`;
|
|
667
|
+
}
|
|
668
|
+
if (height !== null) {
|
|
669
|
+
mounted.wrapper.style.height =
|
|
670
|
+
height === "auto" ? "auto" : `${height}px`;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Convenience: sets both position and size in a single call.
|
|
675
|
+
* Useful during resize-from-anchor operations where both
|
|
676
|
+
* origin and dimensions change simultaneously.
|
|
677
|
+
*/
|
|
678
|
+
setNodeRect(id, rect) {
|
|
679
|
+
this.setNodePosition(id, rect.x, rect.y);
|
|
680
|
+
this.setNodeSize(id, rect.width, rect.height);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Sets a single CSS style property directly on the node's content element
|
|
684
|
+
* (the first child element of the wrapper), and synchronizes width/height
|
|
685
|
+
* wrapper bounds if applicable.
|
|
686
|
+
*/
|
|
687
|
+
setNodeStyle(id, property, value) {
|
|
688
|
+
const mounted = this.nodes.get(id);
|
|
689
|
+
if (!mounted)
|
|
690
|
+
return;
|
|
691
|
+
const contentRoot = this.getContentRootInternal(mounted);
|
|
692
|
+
if (!contentRoot)
|
|
693
|
+
return;
|
|
694
|
+
if (value === null || value === "") {
|
|
695
|
+
contentRoot.style.removeProperty(property);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
contentRoot.style.setProperty(property, value);
|
|
699
|
+
}
|
|
700
|
+
// Synchronize geometry styling with SDK wrapper chrome
|
|
701
|
+
// (only needed for wrapper-based nodes)
|
|
702
|
+
if (!mounted.isDirect) {
|
|
703
|
+
if (property === "width") {
|
|
704
|
+
if (value === null || value === "" || value === "auto") {
|
|
705
|
+
this.setNodeSize(id, "auto", null);
|
|
706
|
+
}
|
|
707
|
+
else if (value.endsWith("px")) {
|
|
708
|
+
const val = parseFloat(value);
|
|
709
|
+
if (!isNaN(val))
|
|
710
|
+
this.setNodeSize(id, val, null);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
else if (property === "height") {
|
|
714
|
+
if (value === null || value === "" || value === "auto") {
|
|
715
|
+
this.setNodeSize(id, null, "auto");
|
|
716
|
+
}
|
|
717
|
+
else if (value.endsWith("px")) {
|
|
718
|
+
const val = parseFloat(value);
|
|
719
|
+
if (!isNaN(val))
|
|
720
|
+
this.setNodeSize(id, null, val);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// Synchronize grid placement styles with the wrapper
|
|
724
|
+
if (property.startsWith("grid-") ||
|
|
725
|
+
property === "grid" ||
|
|
726
|
+
property === "grid-area") {
|
|
727
|
+
if (value === null || value === "") {
|
|
728
|
+
mounted.wrapper.style.removeProperty(property);
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
mounted.wrapper.style.setProperty(property, value);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Sets multiple CSS style properties directly on the node's content element
|
|
738
|
+
* (the first child element of the wrapper) in a single batch.
|
|
739
|
+
*/
|
|
740
|
+
setNodeStyles(id, styles) {
|
|
741
|
+
const mounted = this.nodes.get(id);
|
|
742
|
+
if (!mounted)
|
|
743
|
+
return;
|
|
744
|
+
const contentRoot = this.getContentRootInternal(mounted);
|
|
745
|
+
if (!contentRoot)
|
|
746
|
+
return;
|
|
747
|
+
for (const [property, value] of Object.entries(styles)) {
|
|
748
|
+
if (value === null || value === "") {
|
|
749
|
+
contentRoot.style.removeProperty(property);
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
contentRoot.style.setProperty(property, value);
|
|
753
|
+
}
|
|
754
|
+
// Synchronize geometry styling with SDK wrapper chrome
|
|
755
|
+
// (only needed for wrapper-based nodes)
|
|
756
|
+
if (!mounted.isDirect) {
|
|
757
|
+
if (property === "width") {
|
|
758
|
+
if (value === null || value === "" || value === "auto") {
|
|
759
|
+
this.setNodeSize(id, "auto", null);
|
|
760
|
+
}
|
|
761
|
+
else if (value.endsWith("px")) {
|
|
762
|
+
const val = parseFloat(value);
|
|
763
|
+
if (!isNaN(val))
|
|
764
|
+
this.setNodeSize(id, val, null);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
else if (property === "height") {
|
|
768
|
+
if (value === null || value === "" || value === "auto") {
|
|
769
|
+
this.setNodeSize(id, null, "auto");
|
|
770
|
+
}
|
|
771
|
+
else if (value.endsWith("px")) {
|
|
772
|
+
const val = parseFloat(value);
|
|
773
|
+
if (!isNaN(val))
|
|
774
|
+
this.setNodeSize(id, null, val);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Synchronize grid placement styles with the wrapper
|
|
778
|
+
if (property.startsWith("grid-") ||
|
|
779
|
+
property === "grid" ||
|
|
780
|
+
property === "grid-area") {
|
|
781
|
+
if (value === null || value === "") {
|
|
782
|
+
mounted.wrapper.style.removeProperty(property);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
mounted.wrapper.style.setProperty(property, value);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Computes the canvas-space bounding rect of a node by walking
|
|
793
|
+
* the `offsetLeft`/`offsetTop` chain up to the shadow host.
|
|
794
|
+
*
|
|
795
|
+
* This handles arbitrarily nested elements — each child's offset
|
|
796
|
+
* is accumulated relative to its offsetParent until we reach the
|
|
797
|
+
* shadow host (the transform origin).
|
|
798
|
+
*
|
|
799
|
+
* The result is in **canvas-space** (pre-viewport-transform),
|
|
800
|
+
* consistent with all other rect measurements in the SDK.
|
|
801
|
+
*/
|
|
802
|
+
measureNodeCanvasSpace(id) {
|
|
803
|
+
const mounted = this.nodes.get(id);
|
|
804
|
+
if (!mounted)
|
|
805
|
+
return null;
|
|
806
|
+
const wrapper = mounted.wrapper;
|
|
807
|
+
const target = mounted.isDirect
|
|
808
|
+
? wrapper
|
|
809
|
+
: (wrapper.firstElementChild || wrapper);
|
|
810
|
+
const rect = this.getBoundingBoxCanvasSpace(target);
|
|
811
|
+
// Update the tracked position.
|
|
812
|
+
mounted.canvasX = rect.x;
|
|
813
|
+
mounted.canvasY = rect.y;
|
|
814
|
+
return rect;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Computes the accumulated CSS zoom and transform scale factors of an element
|
|
818
|
+
* relative to the shadow root host.
|
|
819
|
+
*/
|
|
820
|
+
getElementScale(element) {
|
|
821
|
+
this.assertNotDisposed();
|
|
822
|
+
let scale = 1;
|
|
823
|
+
let curr = element;
|
|
824
|
+
while (curr && curr !== this.host) {
|
|
825
|
+
const cs = getComputedStyle(curr);
|
|
826
|
+
// 1. Account for CSS zoom
|
|
827
|
+
const zoom = parseFloat(cs.zoom) || 1;
|
|
828
|
+
scale *= zoom;
|
|
829
|
+
// 2. Account for CSS transform scale (2D matrix)
|
|
830
|
+
const transform = cs.transform;
|
|
831
|
+
if (transform && transform !== "none") {
|
|
832
|
+
const match = transform.match(/^matrix\(([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+)/);
|
|
833
|
+
if (match) {
|
|
834
|
+
const a = parseFloat(match[1]);
|
|
835
|
+
const b = parseFloat(match[2]);
|
|
836
|
+
const s = Math.sqrt(a * a + b * b);
|
|
837
|
+
scale *= s;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Traverse up parent chain, crossing Shadow DOM boundaries if necessary.
|
|
841
|
+
let parent = curr.parentElement || curr.parentNode;
|
|
842
|
+
if (parent && parent.host) {
|
|
843
|
+
parent = parent.host;
|
|
844
|
+
}
|
|
845
|
+
if (parent === curr)
|
|
846
|
+
break;
|
|
847
|
+
curr = parent;
|
|
848
|
+
}
|
|
849
|
+
return scale;
|
|
850
|
+
}
|
|
851
|
+
// ── Flat String Bridge ──────────────────────────────────
|
|
852
|
+
/**
|
|
853
|
+
* Extracts the pristine semantic HTML string from a mounted
|
|
854
|
+
* node's wrapper. Returns the `.innerHTML` of the wrapper,
|
|
855
|
+
* which is the user's manipulated HTML fragment without any
|
|
856
|
+
* SDK wrapper chrome.
|
|
857
|
+
*
|
|
858
|
+
* This is the "Flat String Bridge" output described in the
|
|
859
|
+
* architecture spec — clean HTML ready for AST commit.
|
|
860
|
+
*
|
|
861
|
+
* @param id - The node ID to extract HTML from.
|
|
862
|
+
* @returns The inner HTML string, or `null` if not mounted.
|
|
863
|
+
*/
|
|
864
|
+
extractHTML(id) {
|
|
865
|
+
const mounted = this.nodes.get(id);
|
|
866
|
+
if (!mounted)
|
|
867
|
+
return null;
|
|
868
|
+
// Get the user's content element.
|
|
869
|
+
const contentRoot = this.getContentRootInternal(mounted);
|
|
870
|
+
if (!contentRoot) {
|
|
871
|
+
return mounted.wrapper.innerHTML;
|
|
872
|
+
}
|
|
873
|
+
// Clone the content element to avoid modifying the active DOM.
|
|
874
|
+
const clone = contentRoot.cloneNode(true);
|
|
875
|
+
// Remove SDK tracking attribute from the clone.
|
|
876
|
+
if (mounted.isDirect) {
|
|
877
|
+
clone.removeAttribute("data-canvus-id");
|
|
878
|
+
}
|
|
879
|
+
// Clean up forced state classes if present
|
|
880
|
+
clone.classList.remove("canvus-state-hover", "canvus-state-active", "canvus-state-focus");
|
|
881
|
+
const descendantsWithStates = clone.querySelectorAll(".canvus-state-hover, .canvus-state-active, .canvus-state-focus");
|
|
882
|
+
for (const el of descendantsWithStates) {
|
|
883
|
+
el.classList.remove("canvus-state-hover", "canvus-state-active", "canvus-state-focus");
|
|
884
|
+
}
|
|
885
|
+
// Find all child markers (both wrapper-based and direct elements).
|
|
886
|
+
const childMarkers = clone.querySelectorAll(".canvus-node-wrapper[data-canvus-id], [data-canvus-id]");
|
|
887
|
+
for (const marker of childMarkers) {
|
|
888
|
+
// Skip the clone root itself (relevant for direct elements).
|
|
889
|
+
if (marker === clone)
|
|
890
|
+
continue;
|
|
891
|
+
const childId = marker.getAttribute("data-canvus-id");
|
|
892
|
+
if (childId) {
|
|
893
|
+
// Recursively extract the clean HTML for this child.
|
|
894
|
+
const cleanChildHTML = this.extractHTML(childId);
|
|
895
|
+
if (cleanChildHTML !== null) {
|
|
896
|
+
const temp = document.createElement("div");
|
|
897
|
+
temp.innerHTML = cleanChildHTML;
|
|
898
|
+
const cleanChildNode = temp.firstElementChild;
|
|
899
|
+
if (cleanChildNode) {
|
|
900
|
+
marker.replaceWith(cleanChildNode);
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
marker.remove();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
marker.remove();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
marker.remove();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return clone.outerHTML;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Extracts the outer HTML of the wrapper (includes the wrapper
|
|
918
|
+
* `<div>` itself). Useful for debugging or serialization that
|
|
919
|
+
* needs the positioning context.
|
|
920
|
+
*
|
|
921
|
+
* @param id - The node ID to extract.
|
|
922
|
+
* @returns The outer HTML string, or `null` if not mounted.
|
|
923
|
+
*/
|
|
924
|
+
extractOuterHTML(id) {
|
|
925
|
+
const mounted = this.nodes.get(id);
|
|
926
|
+
if (!mounted)
|
|
927
|
+
return null;
|
|
928
|
+
return mounted.wrapper.outerHTML;
|
|
929
|
+
}
|
|
930
|
+
// ── Direct Wrapper Access ───────────────────────────────
|
|
931
|
+
/**
|
|
932
|
+
* Returns the `ShadowRoot` reference.
|
|
933
|
+
* Useful for injecting additional stylesheets (e.g. user theme
|
|
934
|
+
* CSS, Google Fonts `@import`, Tailwind resets).
|
|
935
|
+
*/
|
|
936
|
+
getShadowRoot() {
|
|
937
|
+
return this.shadow;
|
|
938
|
+
}
|
|
939
|
+
// ── Stylesheet Injection ────────────────────────────────
|
|
940
|
+
/**
|
|
941
|
+
* Injects an additional `<style>` element into the shadow root.
|
|
942
|
+
* Returns the created element so it can be removed later.
|
|
943
|
+
*
|
|
944
|
+
* @param css - Raw CSS text to inject.
|
|
945
|
+
* @returns The created `HTMLStyleElement`.
|
|
946
|
+
*/
|
|
947
|
+
injectStylesheet(css) {
|
|
948
|
+
this.assertNotDisposed();
|
|
949
|
+
const el = document.createElement("style");
|
|
950
|
+
el.textContent = rewriteForShadowDOM(css);
|
|
951
|
+
this.shadow.appendChild(el);
|
|
952
|
+
return el;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Injects a `<link rel="stylesheet">` into the shadow root
|
|
956
|
+
* for loading external CSS (e.g. Google Fonts, Tailwind CDN).
|
|
957
|
+
*
|
|
958
|
+
* @param href - The stylesheet URL.
|
|
959
|
+
* @returns A promise that resolves when the stylesheet loads,
|
|
960
|
+
* or rejects on error.
|
|
961
|
+
*/
|
|
962
|
+
injectStylesheetLink(href) {
|
|
963
|
+
this.assertNotDisposed();
|
|
964
|
+
const link = document.createElement("link");
|
|
965
|
+
link.rel = "stylesheet";
|
|
966
|
+
link.href = href;
|
|
967
|
+
const promise = new Promise((resolve, reject) => {
|
|
968
|
+
link.onload = () => resolve(link);
|
|
969
|
+
link.onerror = () => reject(new Error(`[ShadowMount] Failed to load stylesheet: ${href}`));
|
|
970
|
+
});
|
|
971
|
+
this.shadow.appendChild(link);
|
|
972
|
+
return promise;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Evaluates a script string inside a scoped closure where 'document' and 'window'
|
|
976
|
+
* are proxied to target the ShadowRoot.
|
|
977
|
+
*/
|
|
978
|
+
executeScopedScript(code, context) {
|
|
979
|
+
this.assertNotDisposed();
|
|
980
|
+
const shadowRoot = this.shadow;
|
|
981
|
+
const callContext = context ?? shadowRoot.firstElementChild ?? shadowRoot;
|
|
982
|
+
const documentProxy = new Proxy(document, {
|
|
983
|
+
get(target, prop, receiver) {
|
|
984
|
+
if (prop === "querySelector" ||
|
|
985
|
+
prop === "querySelectorAll" ||
|
|
986
|
+
prop === "getElementById" ||
|
|
987
|
+
prop === "getElementsByClassName" ||
|
|
988
|
+
prop === "getElementsByTagName") {
|
|
989
|
+
const shadowMethod = shadowRoot[prop];
|
|
990
|
+
if (typeof shadowMethod === "function") {
|
|
991
|
+
return (...args) => {
|
|
992
|
+
return shadowMethod.apply(shadowRoot, args);
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (prop === "body") {
|
|
997
|
+
return shadowRoot.firstElementChild || shadowRoot;
|
|
998
|
+
}
|
|
999
|
+
const val = Reflect.get(target, prop, receiver);
|
|
1000
|
+
if (typeof val === "function") {
|
|
1001
|
+
return val.bind(target);
|
|
1002
|
+
}
|
|
1003
|
+
return val;
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
const windowProxy = new Proxy(window, {
|
|
1007
|
+
get(target, prop, receiver) {
|
|
1008
|
+
if (prop === "document") {
|
|
1009
|
+
return documentProxy;
|
|
1010
|
+
}
|
|
1011
|
+
const val = Reflect.get(target, prop, receiver);
|
|
1012
|
+
if (typeof val === "function") {
|
|
1013
|
+
return val.bind(target);
|
|
1014
|
+
}
|
|
1015
|
+
return val;
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
try {
|
|
1019
|
+
const fn = new Function("document", "window", code);
|
|
1020
|
+
fn.call(callContext, documentProxy, windowProxy);
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
console.error(`[ShadowMount] Error executing scoped script:`, err);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// ── Disposal ────────────────────────────────────────────
|
|
1027
|
+
/**
|
|
1028
|
+
* Tears down the entire shadow mount.
|
|
1029
|
+
*
|
|
1030
|
+
* Disconnects the ResizeObserver, removes all wrappers,
|
|
1031
|
+
* detaches the host element from the DOM, and clears all
|
|
1032
|
+
* internal maps. After calling `dispose()`, the instance
|
|
1033
|
+
* is inert — all mutating methods will throw.
|
|
1034
|
+
*/
|
|
1035
|
+
dispose() {
|
|
1036
|
+
if (this.disposed)
|
|
1037
|
+
return;
|
|
1038
|
+
this.disposed = true;
|
|
1039
|
+
this.resizeObserver.disconnect();
|
|
1040
|
+
this.elementToId.clear();
|
|
1041
|
+
this.nodes.clear();
|
|
1042
|
+
this.host.remove();
|
|
1043
|
+
}
|
|
1044
|
+
// ── Private Helpers ─────────────────────────────────────
|
|
1045
|
+
/**
|
|
1046
|
+
* Reads the canvas-space bounding rect of a mounted wrapper
|
|
1047
|
+
* using pre-transform layout dimensions.
|
|
1048
|
+
*/
|
|
1049
|
+
readWrapperRect(mounted) {
|
|
1050
|
+
return this.getBoundingBoxCanvasSpace(mounted.wrapper);
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Computes the bounding box of an element in canvas-space relative to the shadow host.
|
|
1054
|
+
* Handles scale adjustments correctly and is robust for all elements including SVGs.
|
|
1055
|
+
*/
|
|
1056
|
+
getBoundingBoxCanvasSpace(el) {
|
|
1057
|
+
const elRect = el.getBoundingClientRect();
|
|
1058
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
1059
|
+
const scale = this.currentScale || 1;
|
|
1060
|
+
return {
|
|
1061
|
+
x: (elRect.left - hostRect.left) / scale,
|
|
1062
|
+
y: (elRect.top - hostRect.top) / scale,
|
|
1063
|
+
width: elRect.width / scale,
|
|
1064
|
+
height: elRect.height / scale,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Returns the content root element for a mounted node.
|
|
1069
|
+
* For wrapper-based nodes, this is `wrapper.firstElementChild`.
|
|
1070
|
+
* For direct (wrapper-less) nodes, the wrapper IS the content root.
|
|
1071
|
+
*/
|
|
1072
|
+
getContentRootInternal(mounted) {
|
|
1073
|
+
if (mounted.isDirect) {
|
|
1074
|
+
return mounted.wrapper;
|
|
1075
|
+
}
|
|
1076
|
+
return mounted.wrapper.firstElementChild;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Processes a batch of `ResizeObserverEntry` records, resolving
|
|
1080
|
+
* each observed element back to its node ID and firing the
|
|
1081
|
+
* external `onRectChange` callback.
|
|
1082
|
+
*/
|
|
1083
|
+
handleResizeEntries(entries) {
|
|
1084
|
+
if (!this.onRectChange)
|
|
1085
|
+
return;
|
|
1086
|
+
for (const entry of entries) {
|
|
1087
|
+
const id = this.elementToId.get(entry.target);
|
|
1088
|
+
if (!id)
|
|
1089
|
+
continue;
|
|
1090
|
+
const rect = this.measureNodeCanvasSpace(id);
|
|
1091
|
+
if (rect) {
|
|
1092
|
+
this.onRectChange(id, rect);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/** Throws if `dispose()` has been called. */
|
|
1097
|
+
assertNotDisposed() {
|
|
1098
|
+
if (this.disposed) {
|
|
1099
|
+
throw new Error("[ShadowMount] Instance has been disposed. " +
|
|
1100
|
+
"Create a new ShadowMount to continue.");
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// ── Minimal CSS Rewriting for Shadow DOM ───────────────────
|
|
1105
|
+
/**
|
|
1106
|
+
* Performs minimal CSS rewriting for Shadow DOM compatibility.
|
|
1107
|
+
* Only rewrites `body`, `html`, and `:root` selectors to `:host`
|
|
1108
|
+
* so that page-level styles work correctly inside the shadow tree.
|
|
1109
|
+
*
|
|
1110
|
+
* This is intentionally minimal — forced-state duplication,
|
|
1111
|
+
* @-rule handling, and advanced CSS transforms are the
|
|
1112
|
+
* host application's responsibility.
|
|
1113
|
+
*/
|
|
1114
|
+
function rewriteForShadowDOM(css) {
|
|
1115
|
+
return css
|
|
1116
|
+
.replace(/(?<![.\-\w])body(?![.\-\w])/g, ":host")
|
|
1117
|
+
.replace(/(?<![.\-\w])html(?![.\-\w])/g, ":host")
|
|
1118
|
+
.replace(/(^|[\s,]):root\b/gm, "$1:host");
|
|
1119
|
+
}
|
|
1120
|
+
//# sourceMappingURL=shadow-mount.js.map
|