@hankchen/canvas-editor 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/dist/index.js ADDED
@@ -0,0 +1,4256 @@
1
+ // src/session/editor.ts
2
+ import {
3
+ docPathToViewPath,
4
+ lineHeightPx as lineHeightPx2,
5
+ renderCanvas,
6
+ textOrigin,
7
+ viewPathToDocPath,
8
+ withTemplatePreview
9
+ } from "@hankchen/canvas";
10
+
11
+ // src/spatial/camera.ts
12
+ var DEFAULT_ZOOM_BOUNDS = { min: 0.05, max: 8 };
13
+ function sceneToScreen(viewport, sceneX, sceneY) {
14
+ return {
15
+ x: (sceneX - viewport.x) * viewport.zoom,
16
+ y: (sceneY - viewport.y) * viewport.zoom
17
+ };
18
+ }
19
+ function screenToScene(viewport, screenX, screenY) {
20
+ return {
21
+ x: screenX / viewport.zoom + viewport.x,
22
+ y: screenY / viewport.zoom + viewport.y
23
+ };
24
+ }
25
+ function clampZoom(zoom, bounds) {
26
+ return Math.min(bounds.max, Math.max(bounds.min, zoom));
27
+ }
28
+ function panBy(viewport, deltaScreenX, deltaScreenY) {
29
+ return {
30
+ x: viewport.x - deltaScreenX / viewport.zoom,
31
+ y: viewport.y - deltaScreenY / viewport.zoom,
32
+ zoom: viewport.zoom
33
+ };
34
+ }
35
+ function zoomAtPoint(viewport, screenX, screenY, nextZoom, bounds) {
36
+ const zoom = clampZoom(nextZoom, bounds);
37
+ if (zoom === viewport.zoom) return viewport;
38
+ return {
39
+ x: screenX / viewport.zoom + viewport.x - screenX / zoom,
40
+ y: screenY / viewport.zoom + viewport.y - screenY / zoom,
41
+ zoom
42
+ };
43
+ }
44
+ function fitViewport(canvas, surface, bounds, margin) {
45
+ return fitRect({ x: 0, y: 0, width: canvas.width, height: canvas.height }, surface, bounds, margin);
46
+ }
47
+ function fitRect(rect, surface, bounds, margin) {
48
+ const innerW = surface.width - margin * 2;
49
+ const innerH = surface.height - margin * 2;
50
+ if (rect.width <= 0 || rect.height <= 0 || innerW <= 0 || innerH <= 0) {
51
+ return { x: 0, y: 0, zoom: 1 };
52
+ }
53
+ const zoom = Math.min(bounds.max, Math.min(innerW / rect.width, innerH / rect.height));
54
+ return {
55
+ // 矩形中心 (rect.cx, rect.cy) 落在视口中心 (surface/2):cam = 中心 - 视口中心 / zoom
56
+ x: rect.x + rect.width / 2 - surface.width / 2 / zoom,
57
+ y: rect.y + rect.height / 2 - surface.height / 2 / zoom,
58
+ zoom
59
+ };
60
+ }
61
+ var PAN_TO_BOX_MARGIN_PX = 12;
62
+ function panToBox(viewport, box, surface, margin) {
63
+ if (surface.width <= 0 || surface.height <= 0 || !(viewport.zoom > 0)) return viewport;
64
+ const panAxis = (boxPos, boxSize, surfaceSize, cam) => {
65
+ const left = (boxPos - cam) * viewport.zoom;
66
+ const right = left + boxSize * viewport.zoom;
67
+ if (left >= 0 && right <= surfaceSize) return cam;
68
+ const slack = surfaceSize - boxSize * viewport.zoom;
69
+ if (slack >= margin * 2) {
70
+ const lo = boxPos + boxSize - (surfaceSize - margin) / viewport.zoom;
71
+ const hi = boxPos - margin / viewport.zoom;
72
+ return Math.min(hi, Math.max(lo, cam));
73
+ }
74
+ if (slack >= 0) {
75
+ const lo = boxPos + boxSize - surfaceSize / viewport.zoom;
76
+ const hi = boxPos;
77
+ return Math.min(hi, Math.max(lo, cam));
78
+ }
79
+ return boxPos - margin / viewport.zoom;
80
+ };
81
+ const x = panAxis(box.x, box.width, surface.width, viewport.x);
82
+ const y = panAxis(box.y, box.height, surface.height, viewport.y);
83
+ if (x === viewport.x && y === viewport.y) return viewport;
84
+ return { x, y, zoom: viewport.zoom };
85
+ }
86
+ function snapViewportToPhysicalPixels(viewport, dpr) {
87
+ if (!(dpr > 0)) return viewport;
88
+ const gridX = viewport.zoom * dpr;
89
+ return {
90
+ x: Math.round(viewport.x * gridX) / gridX,
91
+ y: Math.round(viewport.y * gridX) / gridX,
92
+ zoom: viewport.zoom
93
+ };
94
+ }
95
+ var WHEEL_ZOOM_STEP = 0.1;
96
+ function nextZoomByWheel(zoom, deltaY, bounds) {
97
+ if (deltaY === 0) return clampZoom(zoom, bounds);
98
+ const sign = Math.sign(deltaY);
99
+ const absDelta = Math.abs(deltaY);
100
+ const delta = Math.min(absDelta, WHEEL_ZOOM_STEP * 100) * sign;
101
+ let next = zoom - delta / 100;
102
+ next += Math.log10(Math.max(1, zoom)) * -sign * Math.min(1, absDelta / 20);
103
+ return clampZoom(next, bounds);
104
+ }
105
+
106
+ // src/spatial/hitTest.ts
107
+ import { resolveChildAt as resolveChildAt2, resolveLayerBox as resolveLayerBox2 } from "@hankchen/canvas";
108
+
109
+ // src/shared/layerPath.ts
110
+ import { resolveChildAt, resolveLayerBox } from "@hankchen/canvas";
111
+ function isIndexSegment(value) {
112
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
113
+ }
114
+ function isLayerPath(value) {
115
+ if (!Array.isArray(value) || value.length < 2) return false;
116
+ if (value[0] !== "layers" || !isIndexSegment(value[1])) return false;
117
+ const descent = ["rows", "cells", "content"];
118
+ let stage = 0;
119
+ let i = 2;
120
+ while (i < value.length) {
121
+ const key = value[i];
122
+ if (stage === 0 && key === "template") {
123
+ if (i === value.length - 1) return true;
124
+ if (value[i + 1] !== "cells") return false;
125
+ stage = 1;
126
+ i += 1;
127
+ continue;
128
+ }
129
+ const expected = descent[stage];
130
+ if (key !== expected) return false;
131
+ if (expected === "content") return i === value.length - 1;
132
+ if (!isIndexSegment(value[i + 1])) return false;
133
+ stage += 1;
134
+ i += 2;
135
+ }
136
+ return true;
137
+ }
138
+ function isTemplateSubtreePath(path) {
139
+ return path.includes("template");
140
+ }
141
+ function isRootLayerPath(path) {
142
+ return isLayerPath(path) && path.length === 2;
143
+ }
144
+ function pathsEqual(a, b) {
145
+ if (a === b) return true;
146
+ if (!a || !b || a.length !== b.length) return false;
147
+ for (let i = 0; i < a.length; i += 1) {
148
+ if (a[i] !== b[i]) return false;
149
+ }
150
+ return true;
151
+ }
152
+ function pathStartsWith(path, prefix) {
153
+ if (path.length < prefix.length) return false;
154
+ for (let i = 0; i < prefix.length; i += 1) {
155
+ if (path[i] !== prefix[i]) return false;
156
+ }
157
+ return true;
158
+ }
159
+ function isLockedPath(path, lockedPaths) {
160
+ return lockedPaths.some((locked) => pathStartsWith(path, locked));
161
+ }
162
+ function navigate(root, path) {
163
+ let current = root;
164
+ let i = 0;
165
+ while (i < path.length) {
166
+ const container = current;
167
+ if (container === null || typeof container !== "object") return null;
168
+ const key = path[i];
169
+ if (typeof key !== "string") return null;
170
+ current = container[key];
171
+ if (current === null || current === void 0) return null;
172
+ if (key === "template") {
173
+ i += 1;
174
+ continue;
175
+ }
176
+ if (i + 1 >= path.length) break;
177
+ const list = current;
178
+ const index = path[i + 1];
179
+ if (!Array.isArray(list) || typeof index !== "number") return null;
180
+ current = list[index];
181
+ if (current === null || current === void 0) return null;
182
+ i += 2;
183
+ }
184
+ return current;
185
+ }
186
+ function resolveLayer(doc, path) {
187
+ if (!isLayerPath(path)) return null;
188
+ const layer = navigate(doc, path);
189
+ if (layer === null || typeof layer !== "object" || !("type" in layer)) return null;
190
+ return layer;
191
+ }
192
+ function rootLayerOf(doc, path) {
193
+ const index = path[1];
194
+ if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0) return null;
195
+ return doc.layers[index] ?? null;
196
+ }
197
+ function selectionParentPath(path) {
198
+ if (path.length < 2) return null;
199
+ if (path[path.length - 1] === "content") return path.slice(0, -1);
200
+ if (path[path.length - 1] === "template") return path.slice(0, -1);
201
+ if (path.length > 2) {
202
+ return path.slice(0, -2);
203
+ }
204
+ return null;
205
+ }
206
+ function layerBoxByPath(doc, path, policies) {
207
+ if (!isLayerPath(path)) return null;
208
+ const rootIndex = path[1];
209
+ const root = doc.layers[rootIndex];
210
+ if (root === void 0) return null;
211
+ let layer = root;
212
+ let box = resolveLayerBox(root, 0, 0, doc.width, doc.height, policies);
213
+ let i = 2;
214
+ while (i < path.length) {
215
+ const key = path[i];
216
+ if (key === "template") {
217
+ const child2 = resolveChildAt(layer, box, "template", 0, policies);
218
+ if (!child2) return null;
219
+ layer = child2.layer;
220
+ box = child2.box;
221
+ i += 1;
222
+ continue;
223
+ }
224
+ const index = path[i + 1];
225
+ const child = resolveChildAt(layer, box, key, index, policies);
226
+ if (!child) return null;
227
+ layer = child.layer;
228
+ box = child.box;
229
+ i += 2;
230
+ }
231
+ return box;
232
+ }
233
+ function remapPathAfterSplice(path, containerPath, key, from, to) {
234
+ if (path.length < containerPath.length + 2) return path;
235
+ for (let i = 0; i < containerPath.length; i += 1) {
236
+ if (path[i] !== containerPath[i]) return path;
237
+ }
238
+ if (path[containerPath.length] !== key) return path;
239
+ const index = path[containerPath.length + 1];
240
+ if (typeof index !== "number") return path;
241
+ const rest = path.slice(containerPath.length + 2);
242
+ const head = [...containerPath, key];
243
+ if (from === to) {
244
+ if (index === from) return null;
245
+ return index > from ? [...head, index - 1, ...rest] : path;
246
+ }
247
+ if (index === from) return [...head, to, ...rest];
248
+ if (from < index && index <= to) return [...head, index - 1, ...rest];
249
+ if (to <= index && index < from) return [...head, index + 1, ...rest];
250
+ return path;
251
+ }
252
+
253
+ // src/spatial/hitTest.ts
254
+ function contains(box, x, y) {
255
+ return box.width > 0 && box.height > 0 && x >= box.x && x < box.x + box.width && y >= box.y && y < box.y + box.height;
256
+ }
257
+ function coversCanvas(box, canvasWidth, canvasHeight) {
258
+ return box.x <= 0 && box.y <= 0 && box.x + box.width >= canvasWidth && box.y + box.height >= canvasHeight;
259
+ }
260
+ function hitWalk(layer, path, box, x, y, policies, skipOwnBox = false) {
261
+ switch (layer.type) {
262
+ case "TableLayer":
263
+ for (let i = layer.rows.length - 1; i >= 0; i -= 1) {
264
+ const child = resolveChildAt2(layer, box, "rows", i, policies);
265
+ if (!child) continue;
266
+ const hit = hitWalk(child.layer, [...path, "rows", i], child.box, x, y, policies);
267
+ if (hit) return hit;
268
+ }
269
+ break;
270
+ case "TableRowLayer":
271
+ for (let i = layer.cells.length - 1; i >= 0; i -= 1) {
272
+ const child = resolveChildAt2(layer, box, "cells", i, policies);
273
+ if (!child) continue;
274
+ const hit = hitWalk(child.layer, [...path, "cells", i], child.box, x, y, policies);
275
+ if (hit) return hit;
276
+ }
277
+ break;
278
+ case "TableCellLayer":
279
+ if (layer.content) {
280
+ const child = resolveChildAt2(layer, box, "content", 0, policies);
281
+ if (child) {
282
+ const hit = hitWalk(child.layer, [...path, "content"], child.box, x, y, policies);
283
+ if (hit) return hit;
284
+ }
285
+ }
286
+ break;
287
+ case "ImageLayer":
288
+ case "TextLayer":
289
+ case "QrCodeLayer":
290
+ case "TableRowTemplate":
291
+ break;
292
+ }
293
+ return !skipOwnBox && contains(box, x, y) ? path : null;
294
+ }
295
+ function hitTest(canvas, sceneX, sceneY, policies, options) {
296
+ const lockedPaths = options?.lockedPaths;
297
+ for (let i = canvas.layers.length - 1; i >= 0; i -= 1) {
298
+ const layer = canvas.layers[i];
299
+ if (layer.visible === false) continue;
300
+ const rootPath = ["layers", i];
301
+ if (lockedPaths !== void 0 && isLockedPath(rootPath, lockedPaths)) continue;
302
+ const box = resolveLayerBox2(layer, 0, 0, canvas.width, canvas.height, policies);
303
+ const skipOwnBox = i === 0 && coversCanvas(box, canvas.width, canvas.height);
304
+ const hit = hitWalk(layer, rootPath, box, sceneX, sceneY, policies, skipOwnBox);
305
+ if (hit) return hit;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ // src/spatial/snap.ts
311
+ import { resolveLayerBox as resolveLayerBox3 } from "@hankchen/canvas";
312
+ var SNAP_THRESHOLD_SCREEN_PX = 6;
313
+ function snapThresholdScene(zoom) {
314
+ return SNAP_THRESHOLD_SCREEN_PX / zoom;
315
+ }
316
+ function visibleRootBoxes(doc, excludeRootIndex, policies) {
317
+ const boxes = [];
318
+ for (let i = 0; i < doc.layers.length; i += 1) {
319
+ if (excludeRootIndex !== null && i === excludeRootIndex) continue;
320
+ const layer = doc.layers[i];
321
+ if (layer.visible === false) continue;
322
+ boxes.push(resolveLayerBox3(layer, 0, 0, doc.width, doc.height, policies));
323
+ }
324
+ return boxes;
325
+ }
326
+ function snapAxesFromBoxes(canvasWidth, canvasHeight, boxes, guides) {
327
+ const axes = [];
328
+ for (const box of boxes) {
329
+ axes.push(
330
+ { orientation: "vertical", position: box.x, source: "layer" },
331
+ { orientation: "vertical", position: box.x + box.width / 2, source: "layer" },
332
+ { orientation: "vertical", position: box.x + box.width, source: "layer" },
333
+ { orientation: "horizontal", position: box.y, source: "layer" },
334
+ { orientation: "horizontal", position: box.y + box.height / 2, source: "layer" },
335
+ { orientation: "horizontal", position: box.y + box.height, source: "layer" }
336
+ );
337
+ }
338
+ axes.push(
339
+ { orientation: "vertical", position: canvasWidth / 2, source: "canvas-center" },
340
+ { orientation: "horizontal", position: canvasHeight / 2, source: "canvas-center" }
341
+ );
342
+ for (const guide of guides) {
343
+ axes.push({ orientation: guide.orientation, position: guide.position, source: "guide" });
344
+ }
345
+ return axes;
346
+ }
347
+ function nearestAxisHit(points, axes, orientation, threshold) {
348
+ let best = null;
349
+ for (const axis of axes) {
350
+ if (axis.orientation !== orientation) continue;
351
+ for (const point of points) {
352
+ const delta = axis.position - point;
353
+ const distance = Math.abs(delta);
354
+ if (distance > threshold) continue;
355
+ if (best === null || distance < Math.abs(best.delta)) best = { delta, axis };
356
+ }
357
+ }
358
+ return best;
359
+ }
360
+ function resolveSnapPoints(points, axes, threshold) {
361
+ const vertical = nearestAxisHit(points.vertical, axes, "vertical", threshold);
362
+ const horizontal = nearestAxisHit(points.horizontal, axes, "horizontal", threshold);
363
+ return {
364
+ dx: vertical ? vertical.delta : 0,
365
+ dy: horizontal ? horizontal.delta : 0,
366
+ axes: [
367
+ ...vertical ? [vertical.axis] : [],
368
+ ...horizontal ? [horizontal.axis] : []
369
+ ]
370
+ };
371
+ }
372
+ function resolveSnap(box, axes, threshold) {
373
+ return resolveSnapPoints(
374
+ {
375
+ vertical: [box.x, box.x + box.width / 2, box.x + box.width],
376
+ horizontal: [box.y, box.y + box.height / 2, box.y + box.height]
377
+ },
378
+ axes,
379
+ threshold
380
+ );
381
+ }
382
+
383
+ // src/spatial/create.ts
384
+ var CREATE_DEAD_ZONE_SCREEN_PX = 4;
385
+ function createRubberBandRect(type, start, current, axes, threshold) {
386
+ const x = Math.min(start.x, current.x);
387
+ const y = Math.min(start.y, current.y);
388
+ const width = Math.abs(current.x - start.x);
389
+ const height = type === "QrCodeLayer" ? width : Math.abs(current.y - start.y);
390
+ const resolution = resolveSnapPoints(
391
+ { vertical: [x, x + width], horizontal: [y, y + height] },
392
+ axes,
393
+ threshold
394
+ );
395
+ return {
396
+ rect: { x: x + resolution.dx, y: y + resolution.dy, width, height },
397
+ axes: resolution.axes
398
+ };
399
+ }
400
+
401
+ // src/spatial/resize.ts
402
+ import "@hankchen/canvas";
403
+ var RESIZE_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
404
+ var RESIZE_MIN_SIZE_PX = 1;
405
+ var WEST_HANDLES = /* @__PURE__ */ new Set(["nw", "w", "sw"]);
406
+ var EAST_HANDLES = /* @__PURE__ */ new Set(["ne", "e", "se"]);
407
+ var NORTH_HANDLES = /* @__PURE__ */ new Set(["nw", "n", "ne"]);
408
+ var SOUTH_HANDLES = /* @__PURE__ */ new Set(["sw", "s", "se"]);
409
+ function handleResizesWidth(handle) {
410
+ return WEST_HANDLES.has(handle) || EAST_HANDLES.has(handle);
411
+ }
412
+ function handleResizesHeight(handle) {
413
+ return NORTH_HANDLES.has(handle) || SOUTH_HANDLES.has(handle);
414
+ }
415
+ function resizeBox(start, handle, dx, dy, minSize = RESIZE_MIN_SIZE_PX) {
416
+ const west = WEST_HANDLES.has(handle);
417
+ const north = NORTH_HANDLES.has(handle);
418
+ const width = west ? start.width - dx : EAST_HANDLES.has(handle) ? start.width + dx : start.width;
419
+ const height = north ? start.height - dy : SOUTH_HANDLES.has(handle) ? start.height + dy : start.height;
420
+ const clampedWidth = Math.max(width, minSize);
421
+ const clampedHeight = Math.max(height, minSize);
422
+ return {
423
+ x: west ? start.x + start.width - clampedWidth : start.x,
424
+ y: north ? start.y + start.height - clampedHeight : start.y,
425
+ width: clampedWidth,
426
+ height: clampedHeight
427
+ };
428
+ }
429
+ function resizeSnapPoints(start, handle, dx, dy) {
430
+ const vertical = [];
431
+ const horizontal = [];
432
+ if (WEST_HANDLES.has(handle)) vertical.push(start.x + dx);
433
+ if (EAST_HANDLES.has(handle)) vertical.push(start.x + start.width + dx);
434
+ if (NORTH_HANDLES.has(handle)) horizontal.push(start.y + dy);
435
+ if (SOUTH_HANDLES.has(handle)) horizontal.push(start.y + start.height + dy);
436
+ return { vertical, horizontal };
437
+ }
438
+ var NO_AXES = { width: false, height: false };
439
+ function resizableAxesAt(doc, path) {
440
+ const layer = resolveLayer(doc, path);
441
+ if (!layer) return NO_AXES;
442
+ if (path[path.length - 1] === "content") {
443
+ const templateContent = path.length > 4 && path[path.length - 4] === "template";
444
+ return templateContent ? { width: false, height: true } : NO_AXES;
445
+ }
446
+ if (path[path.length - 1] === "template") return NO_AXES;
447
+ const containerKey = path[path.length - 2];
448
+ if (containerKey === "rows") return { width: false, height: true };
449
+ if (containerKey === "cells") return { width: true, height: true };
450
+ return { width: true, height: true };
451
+ }
452
+ function resizeHandlesAt(doc, path) {
453
+ const axes = resizableAxesAt(doc, path);
454
+ return RESIZE_HANDLES.filter((handle) => {
455
+ const needsWidth = handleResizesWidth(handle);
456
+ const needsHeight = handleResizesHeight(handle);
457
+ return (!needsWidth || axes.width) && (!needsHeight || axes.height);
458
+ });
459
+ }
460
+ var RESIZE_HANDLE_HIT_PX = 6;
461
+ function resizeHandlePoint(box, handle) {
462
+ const right = box.x + box.width;
463
+ const bottom = box.y + box.height;
464
+ switch (handle) {
465
+ case "nw":
466
+ return { x: box.x, y: box.y };
467
+ case "n":
468
+ return { x: box.x + box.width / 2, y: box.y };
469
+ case "ne":
470
+ return { x: right, y: box.y };
471
+ case "e":
472
+ return { x: right, y: box.y + box.height / 2 };
473
+ case "se":
474
+ return { x: right, y: bottom };
475
+ case "s":
476
+ return { x: box.x + box.width / 2, y: bottom };
477
+ case "sw":
478
+ return { x: box.x, y: bottom };
479
+ case "w":
480
+ return { x: box.x, y: box.y + box.height / 2 };
481
+ }
482
+ }
483
+ function resizeHandleAt(handles, box, sceneX, sceneY, zoom) {
484
+ const tolerance = RESIZE_HANDLE_HIT_PX / zoom;
485
+ for (const handle of handles) {
486
+ const point = resizeHandlePoint(box, handle);
487
+ if (Math.hypot(sceneX - point.x, sceneY - point.y) <= tolerance) return handle;
488
+ }
489
+ return null;
490
+ }
491
+
492
+ // src/session/editor.ts
493
+ import { anchorOffset as anchorOffset2, layerHeight as layerHeight2, layerWidth } from "@hankchen/canvas";
494
+
495
+ // src/shared/expressionSchema.ts
496
+ function isPlainObject(value) {
497
+ return typeof value === "object" && value !== null && !Array.isArray(value);
498
+ }
499
+ function normalizeSchemaType(value) {
500
+ if (typeof value === "string") return value === "" ? void 0 : value;
501
+ const primary = value.find((entry) => entry !== "null" && entry !== "");
502
+ return primary;
503
+ }
504
+ function annotationsOf(raw) {
505
+ const node = {};
506
+ const rawType = raw["type"];
507
+ if (typeof rawType === "string") {
508
+ const type = normalizeSchemaType(rawType);
509
+ if (type !== void 0) node.type = type;
510
+ } else if (Array.isArray(rawType)) {
511
+ const type = normalizeSchemaType(rawType.filter((entry) => typeof entry === "string"));
512
+ if (type !== void 0) node.type = type;
513
+ }
514
+ const rawDescription = raw["description"];
515
+ if (typeof rawDescription === "string") node.description = rawDescription;
516
+ const rawTitle = raw["title"];
517
+ if (typeof rawTitle === "string") node.title = rawTitle;
518
+ return node;
519
+ }
520
+ function pointerEscape(key) {
521
+ return key.replace(/~/g, "~0").replace(/\//g, "~1");
522
+ }
523
+ function unescapePointerToken(token) {
524
+ let out = "";
525
+ for (let i = 0; i < token.length; i += 1) {
526
+ const ch = token[i];
527
+ if (ch === "~" && (token[i + 1] === "0" || token[i + 1] === "1")) {
528
+ out += token[i + 1] === "0" ? "~" : "/";
529
+ i += 1;
530
+ } else {
531
+ out += ch;
532
+ }
533
+ }
534
+ return out;
535
+ }
536
+ function resolveInternalRef(ref, root) {
537
+ let fragment = ref.slice(1);
538
+ try {
539
+ fragment = decodeURIComponent(fragment);
540
+ } catch {
541
+ return { ok: false, why: "\u6307\u9488\u7247\u6BB5\u542B\u65E0\u6548\u767E\u5206\u53F7\u7F16\u7801" };
542
+ }
543
+ const tokens = fragment === "" ? [] : fragment.replace(/^\//, "").split("/");
544
+ let current = root;
545
+ for (const token of tokens) {
546
+ if (!isPlainObject(current)) return { ok: false, why: `\u6307\u9488\u9014\u7ECF\u975E\u5BF9\u8C61\uFF08\u6BB5 "${token}"\uFF09` };
547
+ const key = unescapePointerToken(token);
548
+ if (!(key in current)) return { ok: false, why: `\u6307\u9488\u6BB5\u672A\u547D\u4E2D\uFF08"${token}"\uFF09` };
549
+ current = current[key];
550
+ }
551
+ return { ok: true, target: current };
552
+ }
553
+ function degradeWithDiagnostic(ctx, raw, path, code, detail) {
554
+ ctx.diagnostics.push({ path, code, detail });
555
+ return annotationsOf(raw);
556
+ }
557
+ function compileSchemaNode(raw, declPath, ctx) {
558
+ if (ctx.activePath.has(raw)) {
559
+ return degradeWithDiagnostic(ctx, raw, declPath, "circular_ref", "\u8282\u70B9\u5728\u6D3B\u8DC3\u8DEF\u5F84\u4E0A\u91CD\u5165\uFF08\u73AF\u5F15\u7528\uFF09\uFF0C\u964D\u7EA7\u4E3A\u53F6\u5B50");
560
+ }
561
+ ctx.activePath.add(raw);
562
+ try {
563
+ const rawRef = raw["$ref"];
564
+ if (rawRef !== void 0) return compileRefNode(raw, rawRef, declPath, ctx);
565
+ return compileInlineNode(raw, declPath, ctx);
566
+ } finally {
567
+ ctx.activePath.delete(raw);
568
+ }
569
+ }
570
+ function compileRefNode(raw, rawRef, declPath, ctx) {
571
+ if (typeof rawRef !== "string") {
572
+ return degradeWithDiagnostic(ctx, raw, declPath, "broken_ref", "$ref \u5FC5\u987B\u662F string\uFF0C\u8282\u70B9\u964D\u7EA7\u4E3A\u53F6\u5B50");
573
+ }
574
+ if (!rawRef.startsWith("#")) {
575
+ return degradeWithDiagnostic(
576
+ ctx,
577
+ raw,
578
+ declPath,
579
+ "external_ref",
580
+ `$ref \u6307\u5411\u5916\u90E8/\u8FDC\u7A0B\u76EE\u6807 "${rawRef}"\uFF08\u4EC5\u652F\u6301\u5185\u90E8 #/ \u6307\u9488\uFF0C\u5F52\u5BBF\u4E3B\u9884\u5185\u8054\uFF09\uFF0C\u8282\u70B9\u964D\u7EA7\u4E3A\u53F6\u5B50`
581
+ );
582
+ }
583
+ const resolved = resolveInternalRef(rawRef, ctx.root);
584
+ if (!resolved.ok) {
585
+ return degradeWithDiagnostic(ctx, raw, declPath, "broken_ref", `$ref \u5185\u90E8\u6307\u9488\u89E3\u6790\u5931\u8D25\uFF08${resolved.why}\uFF09\uFF0C\u8282\u70B9\u964D\u7EA7\u4E3A\u53F6\u5B50`);
586
+ }
587
+ if (!isPlainObject(resolved.target)) {
588
+ return degradeWithDiagnostic(ctx, raw, declPath, "target_shape_mismatch", `$ref \u76EE\u6807\u4E0D\u662F schema \u5BF9\u8C61\uFF0C\u8282\u70B9\u964D\u7EA7\u4E3A\u53F6\u5B50`);
589
+ }
590
+ if (ctx.activePath.has(resolved.target)) {
591
+ return degradeWithDiagnostic(ctx, raw, declPath, "circular_ref", `$ref \u76EE\u6807\u5728\u6D3B\u8DC3\u8DEF\u5F84\u4E0A\uFF08\u73AF\u5F15\u7528\uFF09"${rawRef}"\uFF0C\u8282\u70B9\u964D\u7EA7\u4E3A\u53F6\u5B50`);
592
+ }
593
+ const base = compileSchemaNode(resolved.target, rawRef, ctx);
594
+ const localDescription = raw["description"];
595
+ if (typeof localDescription === "string") base.description = localDescription;
596
+ return base;
597
+ }
598
+ function compileInlineNode(raw, declPath, ctx) {
599
+ const node = annotationsOf(raw);
600
+ if (raw["additionalProperties"] === true) node.open = true;
601
+ const rawProperties = raw["properties"];
602
+ if (isPlainObject(rawProperties)) {
603
+ const properties = /* @__PURE__ */ new Map();
604
+ for (const [key, child] of Object.entries(rawProperties)) {
605
+ if (!isPlainObject(child)) continue;
606
+ properties.set(key, compileSchemaNode(child, `${declPath}/properties/${pointerEscape(key)}`, ctx));
607
+ }
608
+ node.properties = properties;
609
+ }
610
+ const rawItems = raw["items"];
611
+ if (isPlainObject(rawItems)) node.items = compileSchemaNode(rawItems, `${declPath}/items`, ctx);
612
+ return node;
613
+ }
614
+ function parseExpressionSchema(raw) {
615
+ if (!isPlainObject(raw)) {
616
+ return { ok: false, reason: "invalid_schema", detail: "schema \u6839\u5FC5\u987B\u662F\u5BF9\u8C61\u58F0\u660E" };
617
+ }
618
+ const rawProperties = raw["properties"];
619
+ if (isPlainObject(rawProperties)) {
620
+ for (const key of Object.keys(rawProperties)) {
621
+ if (key === "row" || key.startsWith("$")) {
622
+ return { ok: false, reason: "reserved_root_key", detail: `\u6839\u7EA7\u952E "${key}" \u4FDD\u7559\uFF08row. \u524D\u7F00/$ \u540D\u4E0E\u6C42\u503C\u4E0A\u4E0B\u6587\u51B2\u7A81\uFF09` };
623
+ }
624
+ }
625
+ }
626
+ if (!isPlainObject(rawProperties)) {
627
+ return { ok: false, reason: "invalid_schema", detail: "\u6839\u58F0\u660E\u7F3A properties \u5C42\u7EA7\uFF08schema \u5373\u8F7D\u8377\u5F62\u72B6\uFF0C\u5FC5\u987B\u58F0\u660E\u9876\u5C42\u952E\uFF09" };
628
+ }
629
+ const diagnostics = [];
630
+ const schema = compileSchemaNode(raw, "#", { root: raw, diagnostics, activePath: /* @__PURE__ */ new Set() });
631
+ return { ok: true, schema, diagnostics };
632
+ }
633
+ function normalizeExpressionSchemaSource(raw) {
634
+ const parsed = parseExpressionSchema(raw);
635
+ if (!parsed.ok) {
636
+ console.warn(
637
+ `[canvas-editor] \u6570\u636E\u6E90 schema \u58F0\u660E\u964D\u7EA7\u4E3A\u65E0\u5019\u9009\uFF08${parsed.reason}: ${parsed.detail}\uFF09\u2014\u2014\u8865\u5168\u662F\u8F85\u52A9\u58F0\u660E\u4E0D\u4F5C\u6743\u5A01\uFF0C\u4E0D\u5F39\u9519`
638
+ );
639
+ return null;
640
+ }
641
+ if (parsed.diagnostics.length > 0) {
642
+ const lines = parsed.diagnostics.map((diagnostic) => `- ${diagnostic.path}\uFF1A${diagnostic.code}\u2014\u2014${diagnostic.detail}`);
643
+ console.warn(
644
+ `[canvas-editor] \u6570\u636E\u6E90 schema \u7F16\u8BD1\u8BCA\u65AD ${parsed.diagnostics.length} \u6761\uFF08\u5C40\u90E8\u964D\u7EA7\u4E3A\u53F6\u5B50\uFF0C\u6811\u5176\u4F59\u90E8\u5206\u7167\u5E38\u670D\u52A1\uFF09\uFF1A
645
+ ${lines.join("\n")}`
646
+ );
647
+ }
648
+ return parsed.schema;
649
+ }
650
+ function objectShapeOf(node) {
651
+ let cursor = node;
652
+ while (cursor !== void 0) {
653
+ if (cursor.properties !== void 0) return cursor.properties;
654
+ cursor = cursor.items;
655
+ }
656
+ return null;
657
+ }
658
+ function schemaNodeAtPath(node, segments) {
659
+ let current = node;
660
+ for (const segment of segments) {
661
+ const child = objectShapeOf(current)?.get(segment);
662
+ if (child === void 0) return null;
663
+ current = child;
664
+ }
665
+ return current;
666
+ }
667
+ function schemaChildEntries(node) {
668
+ if (node.type === "array" || node.items !== void 0) return { entries: [] };
669
+ const entries = node.properties !== void 0 ? [...node.properties].map(([key, child]) => ({ key, node: child })) : [];
670
+ return node.open === true ? { entries, open: true } : { entries };
671
+ }
672
+ function resolveRowSchema(root, rowsPath) {
673
+ const segments = rowsPath.split(".");
674
+ if (rowsPath === "" || segments.includes("")) return null;
675
+ const node = schemaNodeAtPath(root, segments);
676
+ return node?.items ?? null;
677
+ }
678
+
679
+ // src/shared/expressionPath.ts
680
+ function parseExpressionPath(expr) {
681
+ if (expr === "") {
682
+ return { ok: false, error: "expression_syntax_error" };
683
+ }
684
+ const segments = expr.split(".");
685
+ if (segments.includes("")) {
686
+ return { ok: false, error: "expression_syntax_error" };
687
+ }
688
+ const head = segments[0];
689
+ const rest = segments.slice(1);
690
+ if (head === "row") return { ok: true, head: { kind: "row" }, rest };
691
+ if (head === "$root") return { ok: true, head: { kind: "root" }, rest };
692
+ return { ok: true, head: { kind: "bare", name: head }, rest };
693
+ }
694
+
695
+ // src/shared/expressionScan.ts
696
+ var PHP_TRIM_CHARS = " \n\r\0\v";
697
+ function phpTrim(value) {
698
+ let start = 0;
699
+ let end = value.length;
700
+ while (start < end && PHP_TRIM_CHARS.includes(value[start])) start += 1;
701
+ while (end > start && PHP_TRIM_CHARS.includes(value[end - 1])) end -= 1;
702
+ return value.slice(start, end);
703
+ }
704
+ function scanExpressionFragments(template) {
705
+ const parts = [];
706
+ let pos = 0;
707
+ let open = template.indexOf("{{");
708
+ while (open !== -1) {
709
+ const close = template.indexOf("}}", open + 2);
710
+ if (close === -1) {
711
+ if (open > pos) {
712
+ parts.push({ kind: "literal", start: pos, end: open, text: template.slice(pos, open) });
713
+ }
714
+ const text = template.slice(open);
715
+ parts.push({ kind: "open", start: open, end: template.length, text, expr: phpTrim(text.slice(2)) });
716
+ return { template, parts };
717
+ }
718
+ if (open > pos) {
719
+ parts.push({ kind: "literal", start: pos, end: open, text: template.slice(pos, open) });
720
+ }
721
+ const raw = template.slice(open + 2, close);
722
+ const expr = phpTrim(raw);
723
+ const error = parseExpressionPath(expr).ok ? null : "expression_syntax_error";
724
+ parts.push({ kind: "fragment", start: open, end: close + 2, raw, expr, error });
725
+ pos = close + 2;
726
+ open = template.indexOf("{{", pos);
727
+ }
728
+ if (pos < template.length) {
729
+ parts.push({ kind: "literal", start: pos, end: template.length, text: template.slice(pos) });
730
+ }
731
+ return { template, parts };
732
+ }
733
+ function expressionFragmentAtCursor(scan, cursor) {
734
+ for (const part of scan.parts) {
735
+ if (part.kind === "literal") continue;
736
+ const inside = part.kind === "open" ? cursor > part.start && cursor <= part.end : cursor > part.start && cursor < part.end;
737
+ if (inside) return part;
738
+ }
739
+ return null;
740
+ }
741
+
742
+ // src/editing/alignCanvas.ts
743
+ var ALIGN_CORNER_MARGIN_PX = 40;
744
+ function alignToCanvasTarget(mode, box, canvasWidth, canvasHeight, margin) {
745
+ switch (mode) {
746
+ case "left":
747
+ return { x: 0, y: box.y };
748
+ case "right":
749
+ return { x: canvasWidth - box.width, y: box.y };
750
+ case "top":
751
+ return { x: box.x, y: 0 };
752
+ case "bottom":
753
+ return { x: box.x, y: canvasHeight - box.height };
754
+ case "h-center":
755
+ return { x: (canvasWidth - box.width) / 2, y: box.y };
756
+ case "v-center":
757
+ return { x: box.x, y: (canvasHeight - box.height) / 2 };
758
+ case "corner-tl":
759
+ return { x: margin, y: margin };
760
+ case "corner-tr":
761
+ return { x: canvasWidth - box.width - margin, y: margin };
762
+ case "corner-bl":
763
+ return { x: margin, y: canvasHeight - box.height - margin };
764
+ case "corner-br":
765
+ return { x: canvasWidth - box.width - margin, y: canvasHeight - box.height - margin };
766
+ default:
767
+ return null;
768
+ }
769
+ }
770
+
771
+ // src/editing/clipboard.ts
772
+ import { anchorOffset } from "@hankchen/canvas";
773
+ var PASTE_OFFSET_PX = 20;
774
+ var ROOT_PASTEABLE_TYPES = /* @__PURE__ */ new Set([
775
+ "TextLayer",
776
+ "ImageLayer",
777
+ "QrCodeLayer",
778
+ "TableLayer"
779
+ ]);
780
+ function isRootPasteableType(type) {
781
+ return ROOT_PASTEABLE_TYPES.has(type);
782
+ }
783
+ function canCopyLayerAt(doc, path) {
784
+ const layer = resolveLayer(doc, path);
785
+ return layer !== null && isRootPasteableType(layer.type);
786
+ }
787
+ function cloneLayerSubtree(layer) {
788
+ return JSON.parse(JSON.stringify(layer));
789
+ }
790
+ function pastePosition(sourceBox, anchor, docWidth, docHeight, dx, dy) {
791
+ const offset = anchorOffset(anchor, docWidth, docHeight, sourceBox.width, sourceBox.height);
792
+ return { x: sourceBox.x + dx - offset.x, y: sourceBox.y + dy - offset.y };
793
+ }
794
+ function prepareRootPaste(layer, sourceBox, docWidth, docHeight, dx, dy) {
795
+ const copy = cloneLayerSubtree(layer);
796
+ const position = pastePosition(sourceBox, copy.position.anchor, docWidth, docHeight, dx, dy);
797
+ return { ...copy, position: { ...copy.position, ...position } };
798
+ }
799
+
800
+ // src/editing/findReplace.ts
801
+ function collectFieldHits(layer, path, query, out) {
802
+ let from = 0;
803
+ for (; ; ) {
804
+ const index = layer.text.indexOf(query, from);
805
+ if (index === -1) return;
806
+ out.push({ path, start: index, end: index + query.length });
807
+ from = index + query.length;
808
+ }
809
+ }
810
+ function collectLayerHits(layer, path, query, out) {
811
+ switch (layer.type) {
812
+ case "TableLayer":
813
+ if (layer.template) {
814
+ for (let i = layer.template.cells.length - 1; i >= 0; i -= 1) {
815
+ collectLayerHits(layer.template.cells[i], [...path, "template", "cells", i], query, out);
816
+ }
817
+ }
818
+ for (let i = layer.rows.length - 1; i >= 0; i -= 1) {
819
+ collectLayerHits(layer.rows[i], [...path, "rows", i], query, out);
820
+ }
821
+ break;
822
+ case "TableRowLayer":
823
+ for (let i = layer.cells.length - 1; i >= 0; i -= 1) {
824
+ collectLayerHits(layer.cells[i], [...path, "cells", i], query, out);
825
+ }
826
+ break;
827
+ case "TableRowTemplate":
828
+ for (let i = layer.cells.length - 1; i >= 0; i -= 1) {
829
+ collectLayerHits(layer.cells[i], [...path, "cells", i], query, out);
830
+ }
831
+ break;
832
+ case "TableCellLayer":
833
+ if (layer.content) collectLayerHits(layer.content, [...path, "content"], query, out);
834
+ break;
835
+ case "TextLayer":
836
+ if (layer.expression === null) collectFieldHits(layer, path, query, out);
837
+ break;
838
+ case "ImageLayer":
839
+ case "QrCodeLayer":
840
+ break;
841
+ }
842
+ }
843
+ function scanTextMatches(doc, query) {
844
+ const hits = [];
845
+ if (query === "") return hits;
846
+ for (let i = doc.layers.length - 1; i >= 0; i -= 1) {
847
+ collectLayerHits(doc.layers[i], ["layers", i], query, hits);
848
+ }
849
+ return hits;
850
+ }
851
+ function replaceHitsInDraft(draft, hits, replacement) {
852
+ const byPath = /* @__PURE__ */ new Map();
853
+ for (const hit of hits) {
854
+ const key = hit.path.map(String).join("\0");
855
+ const group = byPath.get(key);
856
+ if (group) group.push(hit);
857
+ else byPath.set(key, [hit]);
858
+ }
859
+ let applied = 0;
860
+ for (const group of byPath.values()) {
861
+ const layer = resolveLayer(draft, group[0].path);
862
+ if (!layer || layer.type !== "TextLayer") continue;
863
+ let text = layer.text;
864
+ for (const { start, end } of [...group].sort((a, b) => b.start - a.start)) {
865
+ if (start < 0 || end > text.length || start > end) continue;
866
+ text = text.slice(0, start) + replacement + text.slice(end);
867
+ applied += 1;
868
+ }
869
+ layer.text = text;
870
+ layer.expression = null;
871
+ }
872
+ return applied;
873
+ }
874
+
875
+ // src/editing/layerPanel.ts
876
+ function childLists(layer) {
877
+ switch (layer.type) {
878
+ case "TableLayer":
879
+ if (layer.template !== null) return [{ key: "template", layers: [layer.template] }];
880
+ return [{ key: "rows", layers: layer.rows }];
881
+ case "TableRowLayer":
882
+ return [{ key: "cells", layers: layer.cells }];
883
+ case "TableCellLayer":
884
+ return layer.content ? [{ key: "content", layers: [layer.content] }] : [];
885
+ case "ImageLayer":
886
+ case "TextLayer":
887
+ case "QrCodeLayer":
888
+ return [];
889
+ case "TableRowTemplate":
890
+ return [{ key: "cells", layers: layer.cells }];
891
+ }
892
+ }
893
+ function markedExpressionOf(layer) {
894
+ switch (layer.type) {
895
+ case "TextLayer":
896
+ case "ImageLayer":
897
+ case "QrCodeLayer":
898
+ return layer.expression;
899
+ default:
900
+ return null;
901
+ }
902
+ }
903
+ var EXPRESSION_PREFIX_PLACEHOLDER = "{{\u2026}}";
904
+ function expressionPrefixFor(layer) {
905
+ const expression = markedExpressionOf(layer);
906
+ if (expression === null) return "";
907
+ const exprs = scanExpressionFragments(expression).parts.flatMap(
908
+ (part) => part.kind === "fragment" && part.expr !== "" ? [part.expr] : []
909
+ );
910
+ if (exprs.length === 0) return EXPRESSION_PREFIX_PLACEHOLDER;
911
+ return exprs.map((expr) => `{{${expr}}}`).join(" ");
912
+ }
913
+ function outlineWalk(layer, path, role, lockedPaths) {
914
+ const children = [];
915
+ for (const { key, layers } of childLists(layer)) {
916
+ for (let i = 0; i < layers.length; i += 1) {
917
+ const roleOfChild = key === "rows" ? "row" : key === "cells" ? "cell" : key === "template" ? "templateRow" : "content";
918
+ const childPath = key === "content" ? [...path, "content"] : key === "template" ? [...path, "template"] : [...path, key, i];
919
+ children.push(outlineWalk(layers[i], childPath, roleOfChild, lockedPaths));
920
+ }
921
+ }
922
+ return {
923
+ type: layer.type,
924
+ path,
925
+ role,
926
+ name: layer.name,
927
+ visible: layer.visible,
928
+ locked: isLockedPath(path, lockedPaths),
929
+ expressionPrefix: expressionPrefixFor(layer),
930
+ ...layer.type === "TableLayer" ? { templated: layer.template !== null } : null,
931
+ children
932
+ };
933
+ }
934
+ function buildLayerOutline(doc, lockedPaths = []) {
935
+ const roots = [];
936
+ for (let i = doc.layers.length - 1; i >= 0; i -= 1) {
937
+ roots.push(outlineWalk(doc.layers[i], ["layers", i], "root", lockedPaths));
938
+ }
939
+ return roots;
940
+ }
941
+ function createDefaultLayer(type) {
942
+ const base = {
943
+ // name/visible 缺省态(layer-panel-ux 工单 01):未命名 + 可见,wire 键省略
944
+ name: "",
945
+ visible: true,
946
+ priority: 0,
947
+ shape: {
948
+ width: 0,
949
+ height: 0,
950
+ autoWidth: false,
951
+ autoHeight: false,
952
+ lineHeight: 1.2,
953
+ padding: { top: 0, bottom: 0, left: 0, right: 0 },
954
+ border: { top: null, bottom: null, left: null, right: null },
955
+ backgroundColor: null
956
+ },
957
+ align: { horizontal: "left", vertical: "top" },
958
+ position: { anchor: "top-left", x: 0, y: 0 }
959
+ };
960
+ switch (type) {
961
+ case "TextLayer":
962
+ return {
963
+ ...base,
964
+ type,
965
+ shape: { ...base.shape, width: 200, height: 60 },
966
+ text: "\u6587\u672C",
967
+ expression: null,
968
+ font: "",
969
+ fontSize: 24,
970
+ fontColor: "#111827",
971
+ angle: 0,
972
+ autowrap: false
973
+ };
974
+ case "ImageLayer":
975
+ return { ...base, type, shape: { ...base.shape, width: 200, height: 150 }, src: null, expression: null };
976
+ case "QrCodeLayer":
977
+ return { ...base, type, shape: { ...base.shape, width: 120, height: 120 }, value: "canvas-web", expression: null };
978
+ case "TableLayer":
979
+ return { ...base, type, shape: { ...base.shape, width: 400, height: 120 }, template: null, rowsPath: "", rows: [] };
980
+ case "TableRowLayer":
981
+ return { ...base, type, shape: { ...base.shape, width: 400, height: 60 }, cells: [] };
982
+ case "TableRowTemplate":
983
+ return { ...base, type, shape: { ...base.shape, width: 400, height: 60 }, cells: [] };
984
+ case "TableCellLayer":
985
+ return { ...base, type, shape: { ...base.shape, width: 200, height: 60 }, content: null };
986
+ }
987
+ }
988
+ function createTemplateTable(options) {
989
+ const table = createDefaultLayer("TableLayer");
990
+ const templateBase = createDefaultLayer("TableRowTemplate");
991
+ const cellBase = createDefaultLayer("TableCellLayer");
992
+ const contentBase = createDefaultLayer("TextLayer");
993
+ const cell = {
994
+ ...cellBase,
995
+ shape: { ...cellBase.shape, width: table.shape.width },
996
+ content: { ...contentBase, shape: { ...contentBase.shape, width: table.shape.width, autoWidth: false } }
997
+ };
998
+ const template = {
999
+ ...templateBase,
1000
+ shape: { ...templateBase.shape, width: table.shape.width, autoWidth: false, autoHeight: true, height: 0 },
1001
+ cells: [cell]
1002
+ };
1003
+ return { ...table, template, rows: [], rowsPath: options.rowsPath };
1004
+ }
1005
+ function moveGuard(n, from, to) {
1006
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) return null;
1007
+ if (from < 0 || from >= n || to < 0 || to > n) return null;
1008
+ if (to === from || to === from + 1) return null;
1009
+ return to <= from ? to : to - 1;
1010
+ }
1011
+ function moveRootLayerInDraft(draft, fromPanel, toPanel) {
1012
+ const layers = draft.layers;
1013
+ const n = layers.length;
1014
+ const reduced = moveGuard(n, fromPanel, toPanel);
1015
+ if (reduced === null) return null;
1016
+ const arrFrom = n - 1 - fromPanel;
1017
+ const arrTo = n - 1 - reduced;
1018
+ const [movedRaw] = layers.splice(arrFrom, 1);
1019
+ const moved = movedRaw;
1020
+ layers.splice(arrTo, 0, moved);
1021
+ const below = arrTo > 0 ? layers[arrTo - 1].priority : null;
1022
+ const above = arrTo < n - 1 ? layers[arrTo + 1].priority : null;
1023
+ const priority = midpointPriority(below, above);
1024
+ if (priority !== null) {
1025
+ moved.priority = priority;
1026
+ } else {
1027
+ normalizeRootPriorities(layers);
1028
+ }
1029
+ return { from: arrFrom, to: arrTo };
1030
+ }
1031
+ function midpointPriority(below, above) {
1032
+ if (below !== null && above !== null) {
1033
+ return below - above >= 2 ? Math.floor((below + above) / 2) : null;
1034
+ }
1035
+ if (below !== null) return below - 1;
1036
+ return above === null ? null : above + 1;
1037
+ }
1038
+ var ROOT_PRIORITY_GAP = 1024;
1039
+ function normalizeRootPriorities(layers) {
1040
+ for (let i = 0; i < layers.length; i += 1) {
1041
+ layers[i].priority = (layers.length - 1 - i) * ROOT_PRIORITY_GAP;
1042
+ }
1043
+ }
1044
+ function moveTableRowInDraft(draft, tablePath, fromRow, toRow) {
1045
+ const table = resolveLayer(draft, tablePath);
1046
+ if (!table || table.type !== "TableLayer") return null;
1047
+ const rows = table.rows;
1048
+ const reduced = moveGuard(rows.length, fromRow, toRow);
1049
+ if (reduced === null) return null;
1050
+ const [moved] = rows.splice(fromRow, 1);
1051
+ rows.splice(reduced, 0, moved);
1052
+ return { from: fromRow, to: reduced };
1053
+ }
1054
+ function addRootLayerInDraft(draft, type) {
1055
+ return insertRootLayerInDraft(draft, createDefaultLayer(type));
1056
+ }
1057
+ function insertRootLayerInDraft(draft, layer) {
1058
+ let min = Number.POSITIVE_INFINITY;
1059
+ for (const existing of draft.layers) min = Math.min(min, existing.priority);
1060
+ layer.priority = Number.isFinite(min) ? min - 1 : 0;
1061
+ draft.layers.push(layer);
1062
+ return draft.layers.length - 1;
1063
+ }
1064
+ function insertRootLayerAdjacentInDraft(draft, layer, source) {
1065
+ const rootIndex = typeof source === "number" ? source : source[1];
1066
+ if (typeof rootIndex !== "number" || !Number.isSafeInteger(rootIndex)) return null;
1067
+ const layers = draft.layers;
1068
+ if (rootIndex < 0 || rootIndex >= layers.length) return null;
1069
+ const sourcePriority = layers[rootIndex].priority;
1070
+ const above = rootIndex + 1 < layers.length ? layers[rootIndex + 1].priority : null;
1071
+ layers.splice(rootIndex + 1, 0, layer);
1072
+ const priority = midpointPriority(sourcePriority, above);
1073
+ if (priority !== null) {
1074
+ layer.priority = priority;
1075
+ } else {
1076
+ normalizeRootPriorities(layers);
1077
+ }
1078
+ return rootIndex + 1;
1079
+ }
1080
+ function deleteLayerInDraft(draft, path) {
1081
+ if (!isLayerPath(path)) return null;
1082
+ if (path[path.length - 1] === "content") {
1083
+ const cell = resolveLayer(draft, path.slice(0, -1));
1084
+ if (!cell || cell.type !== "TableCellLayer" || cell.content === null) return null;
1085
+ cell.content = null;
1086
+ return { kind: "content", path };
1087
+ }
1088
+ if (path.length === 2) {
1089
+ const index2 = path[1];
1090
+ const layers = draft.layers;
1091
+ if (typeof index2 !== "number" || index2 >= layers.length) return null;
1092
+ layers.splice(index2, 1);
1093
+ return { kind: "list", containerPath: [], key: "layers", index: index2 };
1094
+ }
1095
+ const index = path[path.length - 1];
1096
+ const key = path[path.length - 2];
1097
+ if (typeof index !== "number" || key !== "rows" && key !== "cells") return null;
1098
+ if (key === "rows") {
1099
+ const table = resolveLayer(draft, path.slice(0, -2));
1100
+ if (!table || table.type !== "TableLayer" || index >= table.rows.length) return null;
1101
+ table.rows.splice(index, 1);
1102
+ return { kind: "list", containerPath: path.slice(0, -2), key: "rows", index };
1103
+ }
1104
+ const row = resolveLayer(draft, path.slice(0, -2));
1105
+ if (!row || row.type !== "TableRowLayer" && row.type !== "TableRowTemplate" || index >= row.cells.length) {
1106
+ return null;
1107
+ }
1108
+ ;
1109
+ row.cells.splice(index, 1);
1110
+ return { kind: "list", containerPath: path.slice(0, -2), key: "cells", index };
1111
+ }
1112
+
1113
+ // src/editing/tableEditing.ts
1114
+ import { layerHeight, lineHeightPx, textLines } from "@hankchen/canvas";
1115
+ function resolveDraft(root, path) {
1116
+ return resolveLayer(root, path);
1117
+ }
1118
+ function normZero(value) {
1119
+ return value === 0 ? 0 : value;
1120
+ }
1121
+ function contentDynamicHeight(content, policies) {
1122
+ if (content.type === "TextLayer") {
1123
+ const padHeight = normZero(Math.trunc(content.shape.padding.top + content.shape.padding.bottom));
1124
+ if (content.autowrap) {
1125
+ return lineHeightPx(content) * textLines(content, policies).length + padHeight;
1126
+ }
1127
+ return content.text === "" ? padHeight : lineHeightPx(content) + padHeight;
1128
+ }
1129
+ return layerHeight(content, policies);
1130
+ }
1131
+ function syncRowWidthInDraft(row, tableWidth) {
1132
+ row.shape.width = tableWidth;
1133
+ row.shape.autoWidth = false;
1134
+ }
1135
+ function syncTemplateContentWidthInDraft(content, cell) {
1136
+ content.shape.width = cell.shape.width;
1137
+ content.shape.autoWidth = false;
1138
+ }
1139
+ function growRowToCellInDraft(row, cellHeight) {
1140
+ if (row.shape.height < cellHeight) {
1141
+ row.shape.height = cellHeight;
1142
+ row.shape.autoHeight = false;
1143
+ }
1144
+ }
1145
+ function syncContentIntoCellInDraft(content, cell, policies) {
1146
+ content.shape.width = cell.shape.width;
1147
+ content.shape.autoWidth = false;
1148
+ if (cell.shape.autoHeight) {
1149
+ cell.shape.height = contentDynamicHeight(content, policies);
1150
+ cell.shape.autoHeight = false;
1151
+ } else {
1152
+ content.shape.height = cell.shape.height;
1153
+ content.shape.autoHeight = false;
1154
+ }
1155
+ }
1156
+ function maxCellHeight(row, policies) {
1157
+ let max = 0;
1158
+ for (const cell of row.cells) {
1159
+ const height = layerHeight(cell, policies);
1160
+ if (height > max) max = height;
1161
+ }
1162
+ return max;
1163
+ }
1164
+ function growRowOfCellInDraft(cellPath, cellHeight, root) {
1165
+ const row = resolveDraft(root, cellPath.slice(0, -2));
1166
+ if (row && row.type === "TableRowLayer") growRowToCellInDraft(row, cellHeight);
1167
+ }
1168
+ function buildDefaultCellIntoRowInDraft(row, fallbackWidth) {
1169
+ const cells = row.cells;
1170
+ const cell = createDefaultLayer("TableCellLayer");
1171
+ cell.shape.width = cells.length > 0 ? cells[cells.length - 1].shape.width : fallbackWidth;
1172
+ const content = createDefaultLayer("TextLayer");
1173
+ syncContentIntoCellInDraft(content, cell);
1174
+ cell.content = content;
1175
+ growRowToCellInDraft(row, cell.shape.height);
1176
+ return cell;
1177
+ }
1178
+ function addTableRowInDraft(draft, tablePath) {
1179
+ const table = resolveDraft(draft, tablePath);
1180
+ if (!table || table.type !== "TableLayer") return null;
1181
+ if (table.template !== null) return null;
1182
+ const rows = table.rows;
1183
+ const row = createDefaultLayer("TableRowLayer");
1184
+ syncRowWidthInDraft(row, table.shape.width);
1185
+ const cell = buildDefaultCellIntoRowInDraft(row, row.shape.width);
1186
+ row.cells = [cell];
1187
+ rows.push(row);
1188
+ return rows.length - 1;
1189
+ }
1190
+ function addTableCellInDraft(draft, rowPath) {
1191
+ const row = resolveDraft(draft, rowPath);
1192
+ if (!row || row.type !== "TableRowLayer") return null;
1193
+ const cells = row.cells;
1194
+ const cell = buildDefaultCellIntoRowInDraft(row, row.shape.width);
1195
+ cells.push(cell);
1196
+ return cells.length - 1;
1197
+ }
1198
+ function addTemplateCellInDraft(draft, tablePath) {
1199
+ const table = resolveDraft(draft, tablePath);
1200
+ if (!table || table.type !== "TableLayer" || table.template === null) return null;
1201
+ const template = table.template;
1202
+ const cells = template.cells;
1203
+ const cell = createDefaultLayer("TableCellLayer");
1204
+ cell.shape.width = cells.length > 0 ? cells[cells.length - 1].shape.width : template.shape.width;
1205
+ const content = createDefaultLayer("TextLayer");
1206
+ syncTemplateContentWidthInDraft(content, cell);
1207
+ cell.content = content;
1208
+ cells.push(cell);
1209
+ return cells.length - 1;
1210
+ }
1211
+ function convertTableToTemplateInDraft(draft, tablePath, rowsPath) {
1212
+ const table = resolveDraft(draft, tablePath);
1213
+ if (!table || table.type !== "TableLayer" || table.template !== null) return null;
1214
+ if (rowsPath === "") return null;
1215
+ const rows = table.rows;
1216
+ const last = rows[rows.length - 1];
1217
+ if (last === void 0 || last.cells.length === 0) return null;
1218
+ const template = JSON.parse(JSON.stringify(last));
1219
+ template.type = "TableRowTemplate";
1220
+ syncRowWidthInDraft(template, table.shape.width);
1221
+ table.rows = [];
1222
+ table.template = template;
1223
+ table.rowsPath = rowsPath;
1224
+ return true;
1225
+ }
1226
+ function convertTableToRowsInDraft(draft, tablePath, policies) {
1227
+ const table = resolveDraft(draft, tablePath);
1228
+ if (!table || table.type !== "TableLayer" || table.template === null) return null;
1229
+ const row = JSON.parse(JSON.stringify(table.template));
1230
+ row.type = "TableRowLayer";
1231
+ syncRowWidthInDraft(row, table.shape.width);
1232
+ for (const cell of row.cells) {
1233
+ if (cell.content !== null) {
1234
+ if (cell.shape.autoHeight) {
1235
+ cell.shape.height = contentDynamicHeight(cell.content, policies);
1236
+ cell.shape.autoHeight = false;
1237
+ }
1238
+ syncContentIntoCellInDraft(cell.content, cell, policies);
1239
+ } else if (cell.shape.autoHeight) {
1240
+ cell.shape.height = 0;
1241
+ }
1242
+ }
1243
+ if (row.shape.autoHeight) row.shape.height = 0;
1244
+ growRowToCellInDraft(row, maxCellHeight(row, policies));
1245
+ table.template = null;
1246
+ table.rowsPath = "";
1247
+ table.rows = [row];
1248
+ return true;
1249
+ }
1250
+ function moveTableCellInDraft(draft, rowPath, fromCell, toCell) {
1251
+ const row = resolveDraft(draft, rowPath);
1252
+ if (!row || row.type !== "TableRowLayer" && row.type !== "TableRowTemplate") return null;
1253
+ const cells = row.cells;
1254
+ const reduced = moveGuard(cells.length, fromCell, toCell);
1255
+ if (reduced === null) return null;
1256
+ const [moved] = cells.splice(fromCell, 1);
1257
+ cells.splice(reduced, 0, moved);
1258
+ return { from: fromCell, to: reduced };
1259
+ }
1260
+ function moveTableRowToTableInDraft(draft, rowPath, targetTablePath, toIndex) {
1261
+ const sourceTablePath = rowPath.slice(0, -2);
1262
+ if (pathsEqual(sourceTablePath, targetTablePath)) return null;
1263
+ const from = rowPath[rowPath.length - 1];
1264
+ if (typeof from !== "number") return null;
1265
+ const sourceTable = resolveDraft(draft, sourceTablePath);
1266
+ const targetTable = resolveDraft(draft, targetTablePath);
1267
+ if (!sourceTable || !targetTable || sourceTable.type !== "TableLayer" || targetTable.type !== "TableLayer") {
1268
+ return null;
1269
+ }
1270
+ if (targetTable.template !== null) return null;
1271
+ if (!Number.isSafeInteger(toIndex) || toIndex < 0 || toIndex > targetTable.rows.length) return null;
1272
+ const sourceRows = sourceTable.rows;
1273
+ if (from >= sourceRows.length) return null;
1274
+ const [movedRaw] = sourceRows.splice(from, 1);
1275
+ const moved = movedRaw;
1276
+ syncRowWidthInDraft(moved, targetTable.shape.width);
1277
+ const targetRows = targetTable.rows;
1278
+ const finalTo = Math.min(toIndex, targetRows.length);
1279
+ targetRows.splice(finalTo, 0, moved);
1280
+ return {
1281
+ fromPath: rowPath,
1282
+ toPath: [...targetTablePath, "rows", finalTo],
1283
+ source: { containerPath: sourceTablePath, key: "rows", from, to: from },
1284
+ // 目标容器是纯插入:remap 的 from 取插入前长度(该长度不受源删除影响)
1285
+ target: { containerPath: targetTablePath, key: "rows", from: targetRows.length - 1, to: finalTo }
1286
+ };
1287
+ }
1288
+ function moveTableCellToRowInDraft(draft, cellPath, targetRowPath, toIndex, policies) {
1289
+ const sourceRowPath = cellPath.slice(0, -2);
1290
+ if (pathsEqual(sourceRowPath, targetRowPath)) return null;
1291
+ const from = cellPath[cellPath.length - 1];
1292
+ if (typeof from !== "number") return null;
1293
+ const sourceRow = resolveDraft(draft, sourceRowPath);
1294
+ const targetRow = resolveDraft(draft, targetRowPath);
1295
+ if (!sourceRow || !targetRow || sourceRow.type !== "TableRowLayer" || targetRow.type !== "TableRowLayer") {
1296
+ return null;
1297
+ }
1298
+ if (!Number.isSafeInteger(toIndex) || toIndex < 0 || toIndex > targetRow.cells.length) return null;
1299
+ const sourceCells = sourceRow.cells;
1300
+ if (from >= sourceCells.length) return null;
1301
+ const [movedRaw] = sourceCells.splice(from, 1);
1302
+ const moved = movedRaw;
1303
+ growRowToCellInDraft(targetRow, moved.shape.height);
1304
+ if (moved.content !== null) {
1305
+ syncContentIntoCellInDraft(moved.content, moved, policies);
1306
+ }
1307
+ const targetCells = targetRow.cells;
1308
+ const finalTo = Math.min(toIndex, targetCells.length);
1309
+ targetCells.splice(finalTo, 0, moved);
1310
+ return {
1311
+ fromPath: cellPath,
1312
+ toPath: [...targetRowPath, "cells", finalTo],
1313
+ source: { containerPath: sourceRowPath, key: "cells", from, to: from },
1314
+ target: { containerPath: targetRowPath, key: "cells", from: targetCells.length - 1, to: finalTo }
1315
+ };
1316
+ }
1317
+ function setCellAutoHeightInDraft(draft, cellPath, on, policies) {
1318
+ const cell = resolveDraft(draft, cellPath);
1319
+ if (!cell || cell.type !== "TableCellLayer") return false;
1320
+ if (on && cell.content !== null) {
1321
+ cell.shape.height = contentDynamicHeight(cell.content, policies);
1322
+ cell.shape.autoHeight = false;
1323
+ syncContentIntoCellInDraft(cell.content, cell, policies);
1324
+ } else if (on) {
1325
+ cell.shape.height = 0;
1326
+ cell.shape.autoHeight = true;
1327
+ } else {
1328
+ cell.shape.autoHeight = false;
1329
+ }
1330
+ growRowOfCellInDraft(cellPath, cell.shape.height, draft);
1331
+ return true;
1332
+ }
1333
+ function canonicalizeTableSyncInDraft(draft, path, key, policies) {
1334
+ if (path[path.length - 1] === "content") {
1335
+ const cellPath = path.slice(0, -1);
1336
+ const cell = resolveDraft(draft, cellPath);
1337
+ if (!cell || cell.type !== "TableCellLayer" || cell.content === null) return;
1338
+ if (isTemplateSubtreePath(path)) {
1339
+ syncTemplateContentWidthInDraft(cell.content, cell);
1340
+ return;
1341
+ }
1342
+ syncContentIntoCellInDraft(cell.content, cell, policies);
1343
+ growRowOfCellInDraft(cellPath, cell.shape.height, draft);
1344
+ return;
1345
+ }
1346
+ const containerKey = path[path.length - 2];
1347
+ if (containerKey === "rows") {
1348
+ const row = resolveDraft(draft, path);
1349
+ if (!row || row.type !== "TableRowLayer") return;
1350
+ const table = resolveDraft(draft, path.slice(0, -2));
1351
+ if (table && table.type === "TableLayer") syncRowWidthInDraft(row, table.shape.width);
1352
+ if (row.shape.autoHeight) row.shape.height = 0;
1353
+ growRowToCellInDraft(row, maxCellHeight(row, policies));
1354
+ return;
1355
+ }
1356
+ if (containerKey === "cells") {
1357
+ const cell = resolveDraft(draft, path);
1358
+ if (!cell || cell.type !== "TableCellLayer") return;
1359
+ if (isTemplateSubtreePath(path)) {
1360
+ if (cell.content !== null) syncTemplateContentWidthInDraft(cell.content, cell);
1361
+ return;
1362
+ }
1363
+ if (cell.content !== null) syncContentIntoCellInDraft(cell.content, cell, policies);
1364
+ else if (cell.shape.autoHeight) cell.shape.height = 0;
1365
+ growRowOfCellInDraft(path, cell.shape.height, draft);
1366
+ return;
1367
+ }
1368
+ if (path[0] === "layers" && typeof path[1] === "number" && key[0] === "shape" && (key[1] === "width" || key[1] === "autoWidth")) {
1369
+ const layer = draft.layers[path[1]];
1370
+ if (!layer || layer.type !== "TableLayer") return;
1371
+ if (layer.shape.autoHeight) layer.shape.height = 0;
1372
+ if (layer.template !== null) syncRowWidthInDraft(layer.template, layer.shape.width);
1373
+ for (const row of layer.rows) syncRowWidthInDraft(row, layer.shape.width);
1374
+ }
1375
+ }
1376
+
1377
+ // src/session/store.ts
1378
+ import { applyPatches, enablePatches, produceWithPatches, setAutoFreeze } from "immer";
1379
+ enablePatches();
1380
+ setAutoFreeze(false);
1381
+ var initialFindSession = () => ({ open: false, query: "", replacement: "", cursor: 0 });
1382
+ var MAX_HISTORY_STEPS = 100;
1383
+ function snapAxesEqual(a, b) {
1384
+ if (a === b) return true;
1385
+ if (a.length !== b.length) return false;
1386
+ for (let i = 0; i < a.length; i += 1) {
1387
+ const x = a[i];
1388
+ const y = b[i];
1389
+ if (x.orientation !== y.orientation || x.position !== y.position || x.source !== y.source) return false;
1390
+ }
1391
+ return true;
1392
+ }
1393
+ function lockedPathsEqual(a, b) {
1394
+ if (a === b) return true;
1395
+ if (a.length !== b.length) return false;
1396
+ for (let i = 0; i < a.length; i += 1) {
1397
+ if (!pathsEqual(a[i], b[i])) return false;
1398
+ }
1399
+ return true;
1400
+ }
1401
+ function resourceStatusesEqual(a, b) {
1402
+ if (a === b) return true;
1403
+ const aKeys = Object.keys(a);
1404
+ if (aKeys.length !== Object.keys(b).length) return false;
1405
+ for (const key of aKeys) {
1406
+ if (a[key] !== b[key]) return false;
1407
+ }
1408
+ return true;
1409
+ }
1410
+ var EditorStore = class {
1411
+ docValue = null;
1412
+ uiValue = {
1413
+ viewport: { x: 0, y: 0, zoom: 1 },
1414
+ selection: null,
1415
+ hovered: null,
1416
+ drag: null,
1417
+ resize: null,
1418
+ armedCreate: null,
1419
+ create: null,
1420
+ editing: null,
1421
+ renaming: null,
1422
+ anchorExpanded: false,
1423
+ dataSourceSchema: null,
1424
+ guides: [],
1425
+ rulersVisible: true,
1426
+ snapAxes: [],
1427
+ lockedPaths: [],
1428
+ find: initialFindSession(),
1429
+ resourceStatuses: {}
1430
+ };
1431
+ /** undo 栈:已提交步,栈尾最新 */
1432
+ undoSteps = [];
1433
+ /** redo 栈:被撤销步,栈尾最近一次撤销;transact 落新事务时整体改写 */
1434
+ redoSteps = [];
1435
+ listeners = /* @__PURE__ */ new Set();
1436
+ get doc() {
1437
+ return this.docValue;
1438
+ }
1439
+ get ui() {
1440
+ return this.uiValue;
1441
+ }
1442
+ /** 已提交步(undo 栈,栈尾最新);可撤销性走 canUndo */
1443
+ get history() {
1444
+ return this.undoSteps;
1445
+ }
1446
+ get canUndo() {
1447
+ return this.undoSteps.length > 0;
1448
+ }
1449
+ get canRedo() {
1450
+ return this.redoSteps.length > 0;
1451
+ }
1452
+ /** 打开/替换文档:ui 选择/编辑/重命名会话与双向历史一并重置(新文档不继承旧路径/旧事务);
1453
+ * 参考线/锁定集合/查找会话/建层武装与画拉会话随当次会话清空(对位轴/文档内容防护/
1454
+ * 查询词/待命层型不跨文档),标尺显隐作为偏好保留 */
1455
+ openDocument(canvas) {
1456
+ this.docValue = canvas;
1457
+ this.undoSteps = [];
1458
+ this.redoSteps = [];
1459
+ this.uiValue = {
1460
+ ...this.uiValue,
1461
+ selection: null,
1462
+ hovered: null,
1463
+ drag: null,
1464
+ resize: null,
1465
+ armedCreate: null,
1466
+ create: null,
1467
+ editing: null,
1468
+ renaming: null,
1469
+ guides: [],
1470
+ snapAxes: [],
1471
+ lockedPaths: [],
1472
+ find: initialFindSession(),
1473
+ resourceStatuses: {}
1474
+ };
1475
+ this.notify({ scope: "doc", patches: [], inversePatches: [] });
1476
+ }
1477
+ /**
1478
+ * 文档事务唯一入口:produceWithPatches 求新树,无变化即空转(不进历史不通知);
1479
+ * 有变化则浅替换 doc、按 mergeKey 合并或追加 undo 栈(超限丢最旧步)、
1480
+ * 改写 redo 栈(undo 后的新事务弃用被撤销分支,Figma 语义)、广播 patch 组。
1481
+ */
1482
+ transact(recipe, options = {}) {
1483
+ if (this.docValue === null) return;
1484
+ const [next, patches, inversePatches] = produceWithPatches(this.docValue, recipe);
1485
+ if (patches.length === 0) return;
1486
+ this.redoSteps = [];
1487
+ const mergeKey = options.mergeKey ?? null;
1488
+ const last = this.undoSteps[this.undoSteps.length - 1];
1489
+ if (mergeKey !== null && last !== void 0 && last.mergeKey === mergeKey) {
1490
+ this.undoSteps[this.undoSteps.length - 1] = {
1491
+ mergeKey,
1492
+ patches: [...last.patches, ...patches],
1493
+ inversePatches: [...inversePatches, ...last.inversePatches]
1494
+ };
1495
+ } else {
1496
+ this.undoSteps.push({ mergeKey, patches, inversePatches });
1497
+ if (this.undoSteps.length > MAX_HISTORY_STEPS) this.undoSteps.shift();
1498
+ }
1499
+ this.docValue = next;
1500
+ this.notify({ scope: "doc", patches, inversePatches });
1501
+ }
1502
+ /** 撤销最近一步:逆向 patch 回放,该步整体移入 redo 栈 */
1503
+ undo() {
1504
+ const step = this.undoSteps[this.undoSteps.length - 1];
1505
+ if (step === void 0 || this.docValue === null) return;
1506
+ this.undoSteps.pop();
1507
+ this.redoSteps.push(step);
1508
+ this.closeMerge();
1509
+ this.docValue = applyPatches(this.docValue, step.inversePatches);
1510
+ this.notify({ scope: "doc", patches: step.inversePatches, inversePatches: step.patches });
1511
+ }
1512
+ /** 重做最近一次撤销:正向 patch 回放,该步移回 undo 栈 */
1513
+ redo() {
1514
+ const step = this.redoSteps[this.redoSteps.length - 1];
1515
+ if (step === void 0 || this.docValue === null) return;
1516
+ this.redoSteps.pop();
1517
+ this.undoSteps.push(step);
1518
+ this.closeMerge();
1519
+ this.docValue = applyPatches(this.docValue, step.patches);
1520
+ this.notify({ scope: "doc", patches: step.patches, inversePatches: step.inversePatches });
1521
+ }
1522
+ /**
1523
+ * 闭合拖动/滑杆事务(pointerup、blur 提交,undo/redo 导航同理):最后一步的
1524
+ * mergeKey 清空后,同键的后续事务不再并入。给键名时只在键匹配时闭合
1525
+ * (防误伤其它键的开放步)。
1526
+ */
1527
+ closeMerge(mergeKey) {
1528
+ const last = this.undoSteps[this.undoSteps.length - 1];
1529
+ if (last === void 0 || last.mergeKey === null) return;
1530
+ if (mergeKey !== void 0 && last.mergeKey !== mergeKey) return;
1531
+ this.undoSteps[this.undoSteps.length - 1] = { ...last, mergeKey: null };
1532
+ }
1533
+ /** 视口更新:ui 分支整体替换,旧切片引用保持原值(computed 引用短路依赖此语义) */
1534
+ setViewport(viewport) {
1535
+ this.uiValue = { ...this.uiValue, viewport };
1536
+ this.notify({ scope: "ui", branch: "viewport" });
1537
+ }
1538
+ /** 选中:值等短路(重复点选同一层不重绘) */
1539
+ setSelection(path) {
1540
+ this.setUiPath("selection", path);
1541
+ }
1542
+ /** 悬停:值等短路(指针在同一层内移动不重绘覆盖层) */
1543
+ setHovered(path) {
1544
+ this.setUiPath("hovered", path);
1545
+ }
1546
+ /** 路径类 ui 切片的共同形状:值等短路 → 整体替换 → 分支通知 */
1547
+ setUiPath(branch, path) {
1548
+ if (pathsEqual(this.uiValue[branch], path)) return;
1549
+ this.uiValue = { ...this.uiValue, [branch]: path };
1550
+ this.notify({ scope: "ui", branch });
1551
+ }
1552
+ /** 拖动会话开始/结束(null);会话对象住 ui 分支,文档只收位置事务 */
1553
+ setDrag(gesture) {
1554
+ this.uiValue = { ...this.uiValue, drag: gesture };
1555
+ this.notify({ scope: "ui", branch: "drag" });
1556
+ }
1557
+ /** 缩放会话开始/结束(null,工单 07);会话对象住 ui 分支,文档只收缩放事务 */
1558
+ setResize(gesture) {
1559
+ this.uiValue = { ...this.uiValue, resize: gesture };
1560
+ this.notify({ scope: "ui", branch: "resize" });
1561
+ }
1562
+ /** 建层武装置/解除(null,drag-create 工单 01);标量值等短路(重复武装同型不通知) */
1563
+ setArmedCreate(type) {
1564
+ if (this.uiValue.armedCreate === type) return;
1565
+ this.uiValue = { ...this.uiValue, armedCreate: type };
1566
+ this.notify({ scope: "ui", branch: "armedCreate" });
1567
+ }
1568
+ /** 画拉橡皮筋会话开始/更新/结束(null);会话对象住 ui 分支,落库在 endCreate 单事务 */
1569
+ setCreate(gesture) {
1570
+ this.uiValue = { ...this.uiValue, create: gesture };
1571
+ this.notify({ scope: "ui", branch: "create" });
1572
+ }
1573
+ /** 文本编辑会话开始/结束(null);会话住 ui 分支,文本经 commitTextEdit 一次性落文档 */
1574
+ setEditing(session) {
1575
+ this.uiValue = { ...this.uiValue, editing: session };
1576
+ this.notify({ scope: "ui", branch: "editing" });
1577
+ }
1578
+ /** 重命名会话开始/结束(null);路径类切片,值等短路(重复 begin 同一路径不重绘) */
1579
+ setRenaming(path) {
1580
+ this.setUiPath("renaming", path);
1581
+ }
1582
+ /** 锚点折叠区开合:布尔值等短路(重复点击同一态不重绘) */
1583
+ setAnchorExpanded(open) {
1584
+ if (this.uiValue.anchorExpanded === open) return;
1585
+ this.uiValue = { ...this.uiValue, anchorExpanded: open };
1586
+ this.notify({ scope: "ui", branch: "anchorExpanded" });
1587
+ }
1588
+ /**
1589
+ * 数据源 schema 声明替换(归一产物或降级 null,校验收口在注入缝不在这里):
1590
+ * 同引用短路(重复注入同一归一产物不惊动订阅方);仅 ui 通知,不参与重绘
1591
+ * 脏标(onStoreChange 对该分支短路)。
1592
+ */
1593
+ setDataSourceSchema(schema) {
1594
+ if (this.uiValue.dataSourceSchema === schema) return;
1595
+ this.uiValue = { ...this.uiValue, dataSourceSchema: schema };
1596
+ this.notify({ scope: "ui", branch: "dataSourceSchema" });
1597
+ }
1598
+ /** 参考线列表替换(增删动作在会话门面收口);仅 ui 通知,不参与历史 */
1599
+ setGuides(guides) {
1600
+ this.uiValue = { ...this.uiValue, guides };
1601
+ this.notify({ scope: "ui", branch: "guides" });
1602
+ }
1603
+ /** 标尺显隐:布尔值等短路(重复置同态不重绘) */
1604
+ setRulersVisible(visible) {
1605
+ if (this.uiValue.rulersVisible === visible) return;
1606
+ this.uiValue = { ...this.uiValue, rulersVisible: visible };
1607
+ this.notify({ scope: "ui", branch: "rulersVisible" });
1608
+ }
1609
+ /** 当次命中吸附轴替换(dragTo 副产物 / endDrag 清空);内容等短路(未命中步不重绘) */
1610
+ setSnapAxes(axes) {
1611
+ if (snapAxesEqual(this.uiValue.snapAxes, axes)) return;
1612
+ this.uiValue = { ...this.uiValue, snapAxes: axes };
1613
+ this.notify({ scope: "ui", branch: "snapAxes" });
1614
+ }
1615
+ /** 锁定集合替换(toggleLayerLock 收口,翻转逻辑在会话门面);内容等短路(集合未变不重绘) */
1616
+ setLockedPaths(paths) {
1617
+ if (lockedPathsEqual(this.uiValue.lockedPaths, paths)) return;
1618
+ this.uiValue = { ...this.uiValue, lockedPaths: paths };
1619
+ this.notify({ scope: "ui", branch: "lockedPaths" });
1620
+ }
1621
+ /**
1622
+ * 查找会话替换(beginFind/closeFind/输入写入经会话门面收口到这里);内容等
1623
+ * 短路(重复开合/同词写入不通知)。仅 ui 通知,不参与历史。
1624
+ */
1625
+ setFind(find) {
1626
+ const current = this.uiValue.find;
1627
+ if (current.open === find.open && current.query === find.query && current.replacement === find.replacement && current.cursor === find.cursor) {
1628
+ return;
1629
+ }
1630
+ this.uiValue = { ...this.uiValue, find };
1631
+ this.notify({ scope: "ui", branch: "find" });
1632
+ }
1633
+ /**
1634
+ * 资源物化状态替换(placeholder-padding-hint 工单 02):宿主从渲染端物化状态机
1635
+ * 桥接注入的唯一写入口,整体替换语义;内容等短路(同态快照不惊动订阅方,
1636
+ * 桥接的高频整体替换零噪声)。仅 ui 通知,不参与历史、不写 graph(红线 3 延伸)。
1637
+ */
1638
+ setResourceStatuses(statuses) {
1639
+ if (resourceStatusesEqual(this.uiValue.resourceStatuses, statuses)) return;
1640
+ this.uiValue = { ...this.uiValue, resourceStatuses: statuses };
1641
+ this.notify({ scope: "ui", branch: "resourceStatuses" });
1642
+ }
1643
+ subscribe(listener) {
1644
+ this.listeners.add(listener);
1645
+ return () => {
1646
+ this.listeners.delete(listener);
1647
+ };
1648
+ }
1649
+ notify(change) {
1650
+ for (const listener of this.listeners) listener(change);
1651
+ }
1652
+ };
1653
+
1654
+ // src/session/shortcuts.ts
1655
+ var ARM_CREATE_LAYER_TYPES = {
1656
+ armCreateText: "TextLayer",
1657
+ armCreateTable: "TableLayer",
1658
+ armCreateQrCode: "QrCodeLayer",
1659
+ armCreateImage: "ImageLayer"
1660
+ };
1661
+ var DEFAULT_EDITOR_SHORTCUTS = [
1662
+ { combo: { key: "z", mod: true, shift: false }, action: "undo", label: "\u64A4\u9500", group: "history" },
1663
+ { combo: { key: "z", mod: true, shift: true }, action: "redo", label: "\u91CD\u505A", group: "history" },
1664
+ { combo: { key: "y", mod: true, shift: false }, action: "redo", label: "\u91CD\u505A", group: "history" },
1665
+ { combo: { key: "c", mod: true, shift: false }, action: "copy", label: "\u590D\u5236", group: "clipboard" },
1666
+ { combo: { key: "v", mod: true, shift: false }, action: "paste", label: "\u7C98\u8D34", group: "clipboard" },
1667
+ { combo: { key: "d", mod: true, shift: false }, action: "duplicate", label: "\u521B\u5EFA\u526F\u672C", group: "clipboard" },
1668
+ // 样式粘贴(canvas-web-style-paste 工单 01):⌥⌘C/⌥⌘V 复制/粘贴样式(Figma/
1669
+ // Sketch 同构,行业同键位)。⌥ 修饰下 mac event.key 是变体字符(US 布局
1670
+ // ⌥C = 'ç'),key 匹配不可靠——按物理键 code 匹配(kbd-nav 括号条目同门);
1671
+ // alt 精确匹配使 ⌘C(无 ⌥)不失配到样式条目、⌥⌘C 也不吞 ⌘C(undo/redo
1672
+ // 只差 shift 的先例扩展到 alt)。浏览器 DevTools 占 ⌥⌘C 时右键菜单保底
1673
+ {
1674
+ combo: { key: "c", mod: true, shift: false, alt: true, code: "KeyC" },
1675
+ action: "copyStyle",
1676
+ label: "\u590D\u5236\u6837\u5F0F",
1677
+ group: "clipboard"
1678
+ },
1679
+ {
1680
+ combo: { key: "v", mod: true, shift: false, alt: true, code: "KeyV" },
1681
+ action: "pasteStyle",
1682
+ label: "\u7C98\u8D34\u6837\u5F0F",
1683
+ group: "clipboard"
1684
+ },
1685
+ { combo: { key: "delete", mod: false, shift: false }, action: "delete", label: "\u5220\u9664\u56FE\u5C42", group: "layer" },
1686
+ { combo: { key: "backspace", mod: false, shift: false }, action: "delete", label: "\u5220\u9664\u56FE\u5C42", group: "layer" },
1687
+ { combo: { key: "f2", mod: false, shift: false }, action: "rename", label: "\u91CD\u547D\u540D\u56FE\u5C42", group: "layer" },
1688
+ // 查找替换(canvas-web-find-replace 工单 01,CONTEXT「查找替换」词条):⌘F
1689
+ // 呼出画布顶部浮动查找条(Canva 同构;浏览器页内查找可 preventDefault 拦截,
1690
+ // ⌘D 抢书签先例)。mod+shift 精确匹配先例——⇧⌘F 变体不入表
1691
+ {
1692
+ combo: { key: "f", mod: true, shift: false },
1693
+ action: "findReplace",
1694
+ label: "\u67E5\u627E\u66FF\u6362",
1695
+ group: "text"
1696
+ },
1697
+ // 标尺显隐(ruler-guides-snap 工单 01):裸键 + shift(mod 变体不占用,
1698
+ // 不抢浏览器刷新等既有语义)
1699
+ { combo: { key: "r", mod: false, shift: true }, action: "toggleRulers", label: "\u6807\u5C3A\u663E\u9690", group: "view" },
1700
+ // 锁定 ⇧⌘L / 显隐 ⇧⌘H(canvas-web-layer-lock 工单 02,显隐键位系 feature-status
1701
+ // §一挂账补位):行业趋同(Figma/Sketch 同款);mod+shift 组合不抢浏览器
1702
+ // ⌘L 地址栏、⌘H 历史页既有语义
1703
+ { combo: { key: "l", mod: true, shift: true }, action: "toggleLayerLock", label: "\u9501\u5B9A/\u89E3\u9501\u56FE\u5C42", group: "layer" },
1704
+ {
1705
+ combo: { key: "h", mod: true, shift: true },
1706
+ action: "toggleLayerVisibility",
1707
+ label: "\u663E\u793A/\u9690\u85CF\u56FE\u5C42",
1708
+ group: "layer"
1709
+ },
1710
+ // z 序四件套(kbd-nav 工单 01):⌘]/⌘[ 前移/后移一格,⌥⌘]/⌥⌘[ 置顶/置底
1711
+ // (Canva/Sketch 同构);⌥ 变体字符下 key 不可靠,置顶/置底按物理键 code 匹配
1712
+ { combo: { key: "]", mod: true, shift: false }, action: "bringForward", label: "\u524D\u79FB\u4E00\u5C42", group: "layer" },
1713
+ { combo: { key: "[", mod: true, shift: false }, action: "sendBackward", label: "\u540E\u79FB\u4E00\u5C42", group: "layer" },
1714
+ {
1715
+ combo: { key: "]", mod: true, shift: false, alt: true, code: "BracketRight" },
1716
+ action: "bringToFront",
1717
+ label: "\u7F6E\u9876",
1718
+ group: "layer"
1719
+ },
1720
+ {
1721
+ combo: { key: "[", mod: true, shift: false, alt: true, code: "BracketLeft" },
1722
+ action: "sendToBack",
1723
+ label: "\u7F6E\u5E95",
1724
+ group: "layer"
1725
+ },
1726
+ // 微调(kbd-nav 工单 02):裸方向键 1px 基础步、⇧+方向键 10px 大步,方向 ×
1727
+ // 步长独立 action id(shift 精确匹配先例所致,undo/redo 同款);mod/alt 修饰
1728
+ // 不入表(⌘←/→ 浏览器历史导航、⌥+方向既有语义不抢)
1729
+ { combo: { key: "arrowup", mod: false, shift: false }, action: "nudgeUp", label: "\u5FAE\u8C03\u4E0A\u79FB", group: "layer" },
1730
+ { combo: { key: "arrowdown", mod: false, shift: false }, action: "nudgeDown", label: "\u5FAE\u8C03\u4E0B\u79FB", group: "layer" },
1731
+ { combo: { key: "arrowleft", mod: false, shift: false }, action: "nudgeLeft", label: "\u5FAE\u8C03\u5DE6\u79FB", group: "layer" },
1732
+ { combo: { key: "arrowright", mod: false, shift: false }, action: "nudgeRight", label: "\u5FAE\u8C03\u53F3\u79FB", group: "layer" },
1733
+ { combo: { key: "arrowup", mod: false, shift: true }, action: "nudgeUpCoarse", label: "\u5927\u6B65\u4E0A\u79FB", group: "layer" },
1734
+ { combo: { key: "arrowdown", mod: false, shift: true }, action: "nudgeDownCoarse", label: "\u5927\u6B65\u4E0B\u79FB", group: "layer" },
1735
+ {
1736
+ combo: { key: "arrowleft", mod: false, shift: true },
1737
+ action: "nudgeLeftCoarse",
1738
+ label: "\u5927\u6B65\u5DE6\u79FB",
1739
+ group: "layer"
1740
+ },
1741
+ {
1742
+ combo: { key: "arrowright", mod: false, shift: true },
1743
+ action: "nudgeRightCoarse",
1744
+ label: "\u5927\u6B65\u53F3\u79FB",
1745
+ group: "layer"
1746
+ },
1747
+ // 循环选层(kbd-nav 工单 03):Tab 朝面板垫底方向、⇧Tab 反向(shift 精确
1748
+ // 匹配先例——⇧Tab 不落裸 Tab 条目);mod/alt 修饰不入表(⌘Tab/⌥Tab 浏览器
1749
+ // 与系统既有语义不抢)。文本编辑/输入框中的 Tab 由让路规则天然放行原生焦点
1750
+ // 移动,不劫持
1751
+ { combo: { key: "tab", mod: false, shift: false }, action: "selectNextLayer", label: "\u5FAA\u73AF\u9009\u4E0B\u4E00\u5C42", group: "layer" },
1752
+ { combo: { key: "tab", mod: false, shift: true }, action: "selectPrevLayer", label: "\u5FAA\u73AF\u9009\u4E0A\u4E00\u5C42", group: "layer" },
1753
+ // 画拉建层武装四条目(canvas-web-drag-create 工单 03,CONTEXT「武装」词条):
1754
+ // T/G/Q/I 层型待命——下一次画布按下即开画拉,建层或 Esc 即解除(一次性待命,
1755
+ // 无粘性工具)。按物理键 code 匹配(⇧1/⇧2 数字行同门先例):非拉丁布局的
1756
+ // key 是本地字符(西里尔布局 T 位产出 'т'),code 恒 KeyT 两边通吃;mod/shift/
1757
+ // alt 精确匹配(⌘T 新标签、⇧T、⌥T 变体不入表)。文本编辑态/输入框焦点由让路
1758
+ // 规则天然放行(编辑文本时 T 打字不武装)——绑定层 useShortcuts 桥对命中条目
1759
+ // 直写状态栏瞬时提示,不经本表
1760
+ { combo: { key: "t", mod: false, shift: false, code: "KeyT" }, action: "armCreateText", label: "\u753B\u62C9\u5EFA\u6587\u672C\u5C42", group: "layer" },
1761
+ { combo: { key: "g", mod: false, shift: false, code: "KeyG" }, action: "armCreateTable", label: "\u753B\u62C9\u5EFA\u8868\u683C", group: "layer" },
1762
+ { combo: { key: "q", mod: false, shift: false, code: "KeyQ" }, action: "armCreateQrCode", label: "\u753B\u62C9\u5EFA\u4E8C\u7EF4\u7801", group: "layer" },
1763
+ { combo: { key: "i", mod: false, shift: false, code: "KeyI" }, action: "armCreateImage", label: "\u753B\u62C9\u5EFA\u56FE\u7247", group: "layer" },
1764
+ // 缩放(kbd-nav 工单 01):⌘0 复位 100%(视口中心为锚;浏览器吞键时的降级
1765
+ // 预案 = Figma 纯 shift ⇧0/1/2,宿主注入亦可覆盖);⇧1/⇧2 适应画布/选区,
1766
+ // 数字行按 code 匹配(US 布局 ⇧1 的 key 是 '!',法国布局是 '1',code 恒
1767
+ // Digit1 两边通吃)
1768
+ { combo: { key: "0", mod: true, shift: false }, action: "zoomReset", label: "\u7F29\u653E\u590D\u4F4D 100%", group: "view" },
1769
+ { combo: { key: "1", mod: false, shift: true, code: "Digit1" }, action: "fitToSurface", label: "\u9002\u5E94\u753B\u5E03", group: "view" },
1770
+ {
1771
+ combo: { key: "2", mod: false, shift: true, code: "Digit2" },
1772
+ action: "fitToSelection",
1773
+ label: "\u9002\u5E94\u9009\u533A",
1774
+ group: "view"
1775
+ },
1776
+ // 帮助面板(kbd-nav 工单 04):⌘/ 开合快捷键帮助。UI 面动作——对话态是 UI
1777
+ // 关注点,绑定层桥(useShortcuts)拦截路由到 useShortcutsHelp 单例态,不经
1778
+ // 内核 dispatcher、不进内核 store;注册表是键位唯一事实源,条目在列帮助面板
1779
+ // 才渲染(group 'help' 系元数据扩展时预留的归组)
1780
+ {
1781
+ combo: { key: "/", mod: true, shift: false },
1782
+ action: "helpShortcuts",
1783
+ label: "\u5FEB\u6377\u952E\u5E2E\u52A9",
1784
+ group: "help"
1785
+ }
1786
+ ];
1787
+ function classifyEditorShortcut(input, bindings = DEFAULT_EDITOR_SHORTCUTS) {
1788
+ if (input.composing || input.editing || input.editableTarget) return null;
1789
+ const key = input.key.toLowerCase();
1790
+ const alt = input.alt ?? false;
1791
+ for (const binding of bindings) {
1792
+ const keyMatches = binding.combo.code !== void 0 ? binding.combo.code === input.code : binding.combo.key === key;
1793
+ if (keyMatches && binding.combo.mod === input.mod && binding.combo.shift === input.shift && (binding.combo.alt ?? false) === alt) {
1794
+ return binding.action;
1795
+ }
1796
+ }
1797
+ return null;
1798
+ }
1799
+
1800
+ // src/editing/fontCatalog.ts
1801
+ var FontCatalog = class {
1802
+ builtin;
1803
+ custom = [];
1804
+ listeners = /* @__PURE__ */ new Set();
1805
+ constructor(builtin = []) {
1806
+ this.builtin = [...builtin];
1807
+ }
1808
+ /** 当前清单:内置在前(配置序),自定义追加在后(上传序) */
1809
+ get entries() {
1810
+ return [...this.builtin, ...this.custom];
1811
+ }
1812
+ /** 追加自定义条目;同引用去重(重复上传不产生重复项,首见条目胜出) */
1813
+ addCustom(entry) {
1814
+ if (this.entries.some((existing) => existing.ref === entry.ref)) return false;
1815
+ this.custom = [...this.custom, entry];
1816
+ this.notify();
1817
+ return true;
1818
+ }
1819
+ /** 订阅清单变更(整体快照推送,绑定层的响应式 ref 据此联动);返回退订函数 */
1820
+ subscribe(listener) {
1821
+ this.listeners.add(listener);
1822
+ return () => {
1823
+ this.listeners.delete(listener);
1824
+ };
1825
+ }
1826
+ notify() {
1827
+ const entries = this.entries;
1828
+ for (const listener of this.listeners) listener(entries);
1829
+ }
1830
+ };
1831
+
1832
+ // src/editing/upload.ts
1833
+ function uploadDisplayName(file) {
1834
+ const base = file.name.replace(/\.[^.]+$/, "");
1835
+ return base === "" ? file.name : base;
1836
+ }
1837
+ var UploadHandlerMissingError = class extends Error {
1838
+ constructor() {
1839
+ super("\u672A\u6CE8\u5165\u4E0A\u4F20\u5B9E\u73B0\uFF08uploadHandler\uFF09\uFF1A\u5BBF\u4E3B\u53EF\u5728\u521B\u5EFA EditorSession \u65F6\u63D0\u4F9B\u672C\u673A\u8D44\u6E90 \u2192 \u53EF\u7269\u5316\u5F15\u7528\u7684\u5B9E\u73B0");
1840
+ this.name = "UploadHandlerMissingError";
1841
+ }
1842
+ };
1843
+
1844
+ // src/shared/specField.ts
1845
+ function readSpecField(target, key) {
1846
+ let node = target;
1847
+ for (let i = 0; i < key.length - 1; i += 1) {
1848
+ if (node === null || typeof node !== "object") return void 0;
1849
+ node = node[key[i]];
1850
+ }
1851
+ if (node === null || typeof node !== "object") return void 0;
1852
+ return node[key[key.length - 1]];
1853
+ }
1854
+ function writeSpecField(target, key, value) {
1855
+ if (target === null || typeof target !== "object") return false;
1856
+ let node = target;
1857
+ for (let i = 0; i < key.length - 1; i += 1) {
1858
+ const next = node[key[i]];
1859
+ if (next === null || typeof next !== "object") return false;
1860
+ node = next;
1861
+ }
1862
+ ;
1863
+ node[key[key.length - 1]] = value;
1864
+ return true;
1865
+ }
1866
+
1867
+ // src/editing/styleClipboard.ts
1868
+ var ALIGN_FAMILY = [
1869
+ ["align", "horizontal"],
1870
+ ["align", "vertical"]
1871
+ ];
1872
+ var SKIN_FAMILY = [
1873
+ ["shape", "backgroundColor"],
1874
+ ["shape", "border"],
1875
+ ["shape", "padding"]
1876
+ ];
1877
+ var TEXT_FAMILY = [
1878
+ ["font"],
1879
+ ["fontSize"],
1880
+ ["fontColor"],
1881
+ ["autowrap"],
1882
+ ["angle"],
1883
+ ["shape", "lineHeight"]
1884
+ ];
1885
+ var STYLE_FIELD_KEYS_BY_TYPE = {
1886
+ TextLayer: [...ALIGN_FAMILY, ...SKIN_FAMILY, ...TEXT_FAMILY],
1887
+ ImageLayer: [...ALIGN_FAMILY, ...SKIN_FAMILY],
1888
+ QrCodeLayer: [...ALIGN_FAMILY, ...SKIN_FAMILY],
1889
+ TableLayer: [...ALIGN_FAMILY, ...SKIN_FAMILY],
1890
+ TableRowLayer: [...ALIGN_FAMILY, ...SKIN_FAMILY],
1891
+ TableCellLayer: [...ALIGN_FAMILY, ...SKIN_FAMILY],
1892
+ TableRowTemplate: []
1893
+ };
1894
+ var fieldId = (key) => key.join(".");
1895
+ function deepClone(value) {
1896
+ return JSON.parse(JSON.stringify(value));
1897
+ }
1898
+ function jsonEqual(a, b) {
1899
+ return JSON.stringify(a) === JSON.stringify(b);
1900
+ }
1901
+ function captureStyleSnapshot(layer) {
1902
+ const fields = STYLE_FIELD_KEYS_BY_TYPE[layer.type];
1903
+ if (fields.length === 0) return null;
1904
+ const values = {};
1905
+ for (const key of fields) {
1906
+ const value = readSpecField(layer, key);
1907
+ if (value !== void 0) values[fieldId(key)] = deepClone(value);
1908
+ }
1909
+ return { sourceType: layer.type, values };
1910
+ }
1911
+ function applyStyleFieldsInDraft(layer, snapshot) {
1912
+ const written = [];
1913
+ for (const key of STYLE_FIELD_KEYS_BY_TYPE[layer.type]) {
1914
+ const value = snapshot.values[fieldId(key)];
1915
+ if (value === void 0) continue;
1916
+ const current = readSpecField(layer, key);
1917
+ if (jsonEqual(current, value)) continue;
1918
+ if (writeSpecField(layer, key, deepClone(value))) written.push(key);
1919
+ }
1920
+ return written;
1921
+ }
1922
+
1923
+ // src/session/editor.ts
1924
+ var DRAG_MERGE_KEY = "drag";
1925
+ var RESIZE_MERGE_KEY = "resize";
1926
+ var ALT_DRAG_DEAD_ZONE_SCREEN_PX = 4;
1927
+ var NUDGE_STEP_PX = 1;
1928
+ var NUDGE_COARSE_STEP_PX = 10;
1929
+ var nudgeMergeKey = (path) => `nudge:${path.join(":")}`;
1930
+ function clampFindCursor(cursor, count) {
1931
+ if (count <= 0) return 0;
1932
+ return Math.min(Math.max(cursor, 0), count - 1);
1933
+ }
1934
+ function hasValidExpressionFragment(template) {
1935
+ return scanExpressionFragments(template).parts.some(
1936
+ (part) => part.kind === "fragment" && part.error === null
1937
+ );
1938
+ }
1939
+ function renormalizeAutoHeightInDraft(layer) {
1940
+ if (layer.shape?.autoHeight === true) {
1941
+ ;
1942
+ layer.shape.height = 0;
1943
+ }
1944
+ }
1945
+ var EditorSession = class {
1946
+ store;
1947
+ scheduleFrame;
1948
+ zoomBounds;
1949
+ fitMargin;
1950
+ /**
1951
+ * 生效的文本布局策略(构造注入值,缺省 undefined = 启发式对齐版):公开只读,
1952
+ * 供导出等旁路面复用同一注入值——导出与编辑画布的断行/盒高才不分叉(工单 13)。
1953
+ */
1954
+ textPolicies;
1955
+ uploadHandler;
1956
+ fontCatalogValue;
1957
+ backend = null;
1958
+ overlayPainter = null;
1959
+ dpr = 1;
1960
+ surfaceSize = { width: 0, height: 0 };
1961
+ dirty = { content: false, overlay: false };
1962
+ frameQueued = false;
1963
+ cancelFrame = null;
1964
+ unsubscribeStore;
1965
+ /**
1966
+ * 预览视图 memo(决策 2026-09):模板态表格实例化一行预览行的文档衍生画布,
1967
+ * 画布渲染/命中/导出消费,永不写回 graph(红线 3 延伸)。Canvas 不可变 +
1968
+ * 结构共享,doc 引用相等即视图仍有效(与 skipContent 的引用比较同纪律)。
1969
+ */
1970
+ previewSource = null;
1971
+ previewValue = null;
1972
+ /**
1973
+ * 会话级剪贴板(工单 14;alt-drag-paste 工单 02 收窄):复制源的子树深拷贝
1974
+ * 快照 + 复制时点绝对盒(粘贴落位基准,格内容的格内相对 position 据此换算
1975
+ * 画布绝对落位)+ 源路径(粘贴时紧邻插入的解析凭据)。不跨会话、不碰 OS
1976
+ * 剪贴板;openDocument 不清空——粘贴进另一份文档是合法用法(条目是与源树
1977
+ * 断开引用的普通对象;他文档 sourcePath 必失配 → 粘贴退化置顶)。
1978
+ */
1979
+ clipboardEntry = null;
1980
+ /**
1981
+ * 样式剪贴板(canvas-web-style-paste 工单 01):源类型 + 适用面逐字段值快照。
1982
+ * 与图层剪贴板**分立互不覆盖**(⌘C 与 ⌥⌘C 并存);不碰 OS 剪贴板、不进历史
1983
+ * (剪贴板态不是文档态);openDocument 不清空——跨文档粘贴样式合法(图层
1984
+ * 剪贴板同门,快照是与源树断开引用的普通对象)。
1985
+ */
1986
+ styleClipboardEntry = null;
1987
+ /** 参考线 id 自增序(会话内唯一即可,不持久化——参考线不写 graph) */
1988
+ guideIdSeq = 0;
1989
+ constructor(options) {
1990
+ this.scheduleFrame = options.scheduleFrame;
1991
+ this.zoomBounds = options.zoomBounds ?? DEFAULT_ZOOM_BOUNDS;
1992
+ this.fitMargin = options.fitMargin ?? 0;
1993
+ this.textPolicies = options.textPolicies;
1994
+ this.uploadHandler = options.uploadHandler ?? null;
1995
+ this.fontCatalogValue = new FontCatalog(options.fontCatalog);
1996
+ this.store = new EditorStore();
1997
+ this.unsubscribeStore = this.store.subscribe((change) => this.onStoreChange(change));
1998
+ }
1999
+ // ---- 组装与呈现参数(DOM 侧由绑定层调用) ----
2000
+ attachContentBackend(backend) {
2001
+ this.backend = backend;
2002
+ this.invalidate("content");
2003
+ }
2004
+ setOverlayPainter(painter) {
2005
+ this.overlayPainter = painter;
2006
+ this.invalidate("overlay");
2007
+ }
2008
+ /** 解绑绘制面(surface 卸载时);挂起帧 flush 空转,可重新挂载 */
2009
+ detachSurfaces() {
2010
+ this.backend = null;
2011
+ this.overlayPainter = null;
2012
+ }
2013
+ setDevicePixelRatio(dpr) {
2014
+ if (dpr === this.dpr) return;
2015
+ this.dpr = dpr;
2016
+ this.invalidate("both");
2017
+ }
2018
+ setSurfaceSize(width, height) {
2019
+ if (width === this.surfaceSize.width && height === this.surfaceSize.height) return;
2020
+ this.surfaceSize = { width, height };
2021
+ this.invalidate("both");
2022
+ }
2023
+ getSurfaceSize() {
2024
+ return this.surfaceSize;
2025
+ }
2026
+ // ---- 相机动作(视口 {x, y, zoom} 走 ui 分支,不进历史) ----
2027
+ /** 打开/替换文档(转发 store;解码在宿主侧完成,内核只消费领域结构) */
2028
+ openDocument(canvas) {
2029
+ this.store.openDocument(canvas);
2030
+ }
2031
+ /**
2032
+ * 预览视图(只读衍生):模板态表格在视图中获得一行预览行(文本格 = 表达式
2033
+ * 原文、标记图片/二维码格走占位盒),渲染/命中/导出据此呈现「一行输出效果」;
2034
+ * 文档本身不被改写。宿主(如导出)与内核内部共用同一 memo。
2035
+ */
2036
+ get previewCanvas() {
2037
+ const doc = this.store.doc;
2038
+ if (doc === null) return null;
2039
+ if (doc !== this.previewSource) {
2040
+ this.previewValue = withTemplatePreview(doc, this.textPolicies);
2041
+ this.previewSource = doc;
2042
+ }
2043
+ return this.previewValue;
2044
+ }
2045
+ /** 平移:屏幕位移按 zoom 折算 */
2046
+ panBy(deltaScreenX, deltaScreenY) {
2047
+ this.store.setViewport(panBy(this.store.ui.viewport, deltaScreenX, deltaScreenY));
2048
+ }
2049
+ /** 缩放至指定倍率,以屏幕点(视口坐标系的 css 像素)为锚 */
2050
+ zoomAt(screenX, screenY, nextZoom) {
2051
+ this.store.setViewport(zoomAtPoint(this.store.ui.viewport, screenX, screenY, nextZoom, this.zoomBounds));
2052
+ }
2053
+ /** 滚轮/捏合:曲线见 camera.nextZoomByWheel,缩放以指针为中心 */
2054
+ zoomByWheel(screenX, screenY, deltaY) {
2055
+ const viewport = this.store.ui.viewport;
2056
+ const nextZoom = nextZoomByWheel(viewport.zoom, deltaY, this.zoomBounds);
2057
+ this.store.setViewport(zoomAtPoint(viewport, screenX, screenY, nextZoom, this.zoomBounds));
2058
+ }
2059
+ /**
2060
+ * 缩放复位 100%(⌘0,kbd-nav 工单 01):以视口中心为锚回到 1.0——中心场景点
2061
+ * 不动、平移不跳变(Sketch Actual Size 同构,playground zoomTo100 先例收编为
2062
+ * 内核面);已在 100% 时 zoomAtPoint 恒等短路。视口走 ui 分支不进历史。
2063
+ */
2064
+ resetZoom() {
2065
+ const { width, height } = this.surfaceSize;
2066
+ this.zoomAt(width / 2, height / 2, 1);
2067
+ }
2068
+ /** 一键适应画布:整页可见、居中(表面尺寸未知时 no-op) */
2069
+ fitToSurface() {
2070
+ const doc = this.store.doc;
2071
+ if (!doc || this.surfaceSize.width <= 0 || this.surfaceSize.height <= 0) return;
2072
+ this.store.setViewport(fitViewport(doc, this.surfaceSize, this.zoomBounds, this.fitMargin));
2073
+ }
2074
+ /**
2075
+ * 路径处图层盒:模板子树路径经预览视图解析(auto 高在视图里合成,文档侧
2076
+ * 声明高为零),其余路径直接解析文档——非模板子树结构共享,两棵树同盒。
2077
+ * gizmo/命中/适应选区/文本编辑 overlay 共用,与绘制不漂移。
2078
+ */
2079
+ boxByPath(path) {
2080
+ const doc = this.store.doc;
2081
+ if (!doc) return null;
2082
+ if (isTemplateSubtreePath(path)) {
2083
+ const view = this.previewCanvas;
2084
+ const viewPath = view ? docPathToViewPath(doc, path) : null;
2085
+ return view && viewPath ? layerBoxByPath(view, viewPath, this.textPolicies) : null;
2086
+ }
2087
+ return layerBoxByPath(doc, path, this.textPolicies);
2088
+ }
2089
+ /** 自适应选区视图:视口适配选中盒;无选择/退化盒回落适应画布 */
2090
+ fitToSelection() {
2091
+ const doc = this.store.doc;
2092
+ if (!doc) return;
2093
+ const selection = this.store.ui.selection;
2094
+ const box = selection ? this.boxByPath(selection) : null;
2095
+ if (!box || box.width <= 0 || box.height <= 0 || this.surfaceSize.width <= 0 || this.surfaceSize.height <= 0) {
2096
+ this.fitToSurface();
2097
+ return;
2098
+ }
2099
+ this.store.setViewport(fitRect(box, this.surfaceSize, this.zoomBounds, this.fitMargin));
2100
+ }
2101
+ /**
2102
+ * 保 zoom 平移入视(canvas-web-find-replace 工单 02,spec 决策 6):查找导航
2103
+ * 的视口跟随——目标盒完全在视口内视口不动,出视才最小平移使整盒入视(入视
2104
+ * 小边距与盒大于视口对齐盒左上的边界语义在 camera.panToBox 纯函数内定死)。
2105
+ * 与 fitToSelection 分立不混用:那是变焦语义(fitRect),这是保 zoom 的跟随
2106
+ * 语义。盒几何由调用方解析(工单 03 命中导航经 layerBoxAt 同一解析缝,模板
2107
+ * 子树走预览视图);视口走 ui 分支不进历史,视内不动经恒等短路零通知;表面
2108
+ * 尺寸未知 no-op(fitToSurface 同门)。
2109
+ */
2110
+ panToBox(box) {
2111
+ if (this.surfaceSize.width <= 0 || this.surfaceSize.height <= 0) return;
2112
+ const viewport = this.store.ui.viewport;
2113
+ const next = panToBox(viewport, box, this.surfaceSize, PAN_TO_BOX_MARGIN_PX);
2114
+ if (next !== viewport) this.store.setViewport(next);
2115
+ }
2116
+ // ---- 选择与拖动(工单 06):命中/选择/拖动几何全部经内核纯函数 ----
2117
+ /** 屏幕(css 像素)→ 场景:绑定层的唯一坐标入口(zoom 折算收口在此) */
2118
+ toScenePoint(screenX, screenY) {
2119
+ return screenToScene(this.store.ui.viewport, screenX, screenY);
2120
+ }
2121
+ /**
2122
+ * 场景点命中查询(不产生副作用):在预览视图上执行——模板态表格的预览行
2123
+ * 可命中,命中路径经视图→文档映射落回模板子树身份(选中/属性编辑写文档)。
2124
+ * 锁定集合(canvas-web-layer-lock 工单 01)随 ui 分支传入纯函数:锁定的根层
2125
+ * 整子树退出命中面,selectAt/hoverAt/右键/双击全经此一条缝自动生效。
2126
+ */
2127
+ hitTest(sceneX, sceneY) {
2128
+ const view = this.previewCanvas;
2129
+ if (!view) return null;
2130
+ const hit = hitTest(view, sceneX, sceneY, this.textPolicies, {
2131
+ lockedPaths: this.store.ui.lockedPaths
2132
+ });
2133
+ if (hit === null) return null;
2134
+ const docPath = viewPathToDocPath(view, hit);
2135
+ return docPath ? docPath : null;
2136
+ }
2137
+ /** 路径处图层的绝对盒(gizmo 选择框与后续面板共用;与命中同一布局策略) */
2138
+ layerBoxAt(path) {
2139
+ return this.boxByPath(path);
2140
+ }
2141
+ /** 点选:命中即选中并返回路径,未中清空选择(画布与后续面板同源) */
2142
+ selectAt(sceneX, sceneY) {
2143
+ const path = this.hitTest(sceneX, sceneY);
2144
+ this.store.setSelection(path);
2145
+ return path;
2146
+ }
2147
+ /** 直接设置选择(面板/键盘入口) */
2148
+ setSelection(path) {
2149
+ this.store.setSelection(path);
2150
+ }
2151
+ /** Escape 升级:沿归属链 cell→row→table 逐级取父,链尽清空选择 */
2152
+ escapeSelection() {
2153
+ const selection = this.store.ui.selection;
2154
+ if (!selection) return;
2155
+ this.store.setSelection(selectionParentPath(selection));
2156
+ }
2157
+ /**
2158
+ * 循环选层(kbd-nav 工单 03,CONTEXT「循环选层」词条 / spec 决策 4):Tab
2159
+ * 沿面板序朝更垫底方向移动选中(面板顶 = 视觉最上层 = 数组尾,故数组下标 −1),
2160
+ * ⇧Tab 反向(selectPrevLayer),端点 wrap 回绕;空选从视觉最上层(面板首行)
2161
+ * 开始(spec 决策 4 字面口径,⇧Tab 同一起点);子层选中以根祖先为基准取相邻
2162
+ * 根层(不下钻表格,循环只落根路径)。跳过面与命中面同门(hitTest 同款判定):
2163
+ * 隐藏根层整子树排除(visible=false 是排除语义非保护语义)、锁定根层整子树
2164
+ * 退出交互面(isLocked 谓词——Tab 是命中面的键盘投影,「画布挡误触」同门,
2165
+ * layer-lock 合入后随谓词自动生效);全部根层被跳过时空转返回 false。
2166
+ * 选中变更走 setSelection(ui 分支零历史步;值等短路使单根层 wrap 自身无害
2167
+ * ——不通知)。返回是否落到选中(executeShortcut 可用态口径)。
2168
+ */
2169
+ selectNextLayer() {
2170
+ return this.cycleRootSelection(1);
2171
+ }
2172
+ /** ⇧Tab 反向循环选层(语义见 selectNextLayer) */
2173
+ selectPrevLayer() {
2174
+ return this.cycleRootSelection(-1);
2175
+ }
2176
+ /** 循环选层的共同游走:direction = 1 朝数组头(垫底)、−1 朝数组尾(最上),模 count 回绕 */
2177
+ cycleRootSelection(direction) {
2178
+ const doc = this.store.doc;
2179
+ const count = doc?.layers.length ?? 0;
2180
+ if (doc === null || count === 0) return false;
2181
+ const selection = this.store.ui.selection;
2182
+ const baseline = selection !== null && rootLayerOf(doc, selection) !== null ? selection[1] : null;
2183
+ const startIndex = baseline === null ? count - 1 : baseline - direction;
2184
+ for (let step = 0; step < count; step += 1) {
2185
+ const index = ((startIndex - direction * step) % count + count) % count;
2186
+ const path = ["layers", index];
2187
+ if (doc.layers[index].visible === false || this.isLocked(path)) continue;
2188
+ this.store.setSelection(path);
2189
+ return true;
2190
+ }
2191
+ return false;
2192
+ }
2193
+ /** 悬停命中(指针移动时调用;离场传 null 或用 setHovered) */
2194
+ hoverAt(sceneX, sceneY) {
2195
+ this.store.setHovered(this.hitTest(sceneX, sceneY));
2196
+ }
2197
+ /** 设置悬停路径 */
2198
+ setHovered(path) {
2199
+ this.store.setHovered(path);
2200
+ }
2201
+ /**
2202
+ * 开始拖动会话:记录目标路径、起点场景坐标、起始 position 偏移与起始盒
2203
+ * (吸附求位基准,ui 分支)。已在拖动中、路径或盒几何无法解析时返回 false;
2204
+ * 路径落在锁定子树同样拒绝(canvas-web-layer-lock 工单 01:拖动是变更类
2205
+ * 动作的 guard 入口——画布命中面已过滤锁定层,此守卫兜住其余手势来源,
2206
+ * 缩放手势接入 resize feature 时同用此门)。
2207
+ *
2208
+ * Alt+拖快速复制(alt-drag-paste 工单 01):options.copy 起手一次性判定——
2209
+ * 源不可复制(canCopyLayerAt 为 false:行/格等容器内结构)静默忽略 Alt 走
2210
+ * 普通拖动(spec 决策 1);置位后 copyMode 会话内定死,首移越过死区才插入
2211
+ * 副本(dragTo 消费,见 ALT_DRAG_DEAD_ZONE_SCREEN_PX)。
2212
+ */
2213
+ beginDrag(path, sceneX, sceneY, options = {}) {
2214
+ if (this.store.ui.drag || this.store.ui.resize || this.store.ui.create) return false;
2215
+ if (this.isLocked(path)) return false;
2216
+ const doc = this.store.doc;
2217
+ const layer = doc ? resolveLayer(doc, path) : null;
2218
+ if (!doc || !layer) return false;
2219
+ const box = this.boxByPath(path);
2220
+ if (!box) return false;
2221
+ const copy = options.copy === true && canCopyLayerAt(doc, path);
2222
+ this.store.setDrag({
2223
+ path,
2224
+ startScene: { x: sceneX, y: sceneY },
2225
+ startPosition: { x: layer.position.x, y: layer.position.y },
2226
+ startBox: box,
2227
+ ...copy ? { copy: true, copyPending: true } : {}
2228
+ });
2229
+ return true;
2230
+ }
2231
+ /**
2232
+ * 拖动进行中:position = 起点 + 场景位移,再按吸附修正(工单 01,ADR 0012)。
2233
+ * 吸附求位:暂定盒(startBox + 本步位移)九点对轴集合——其他可见根层盒缘与
2234
+ * 中心、画布水平/垂直中轴、参考线轴——屏幕 6px 阈值按当前缩放换算场景值,
2235
+ * 多轴取最近,至多一垂直 + 一水平;修正并入同一位置事务(同一 mergeKey,
2236
+ * 跟随拖动既有历史合并语义)。命中的吸附轴回写会话态供吸附线呈现(瞬时
2237
+ * 回显,endDrag 清空)。九锚点一视同仁——锚点偏移不动,只累加 x/y(x += dx/
2238
+ * zoom 的 zoom 折算已由 toScenePoint 收口);一次拖动的全部位移经同 mergeKey
2239
+ * 事务并成一步历史,属性面板读同一文档数据即联动。
2240
+ *
2241
+ * Alt+拖 copyMode(alt-drag-paste 工单 01):副本未插入前(copyPending)源层
2242
+ * 不动——未越死区零事务,首移越阈交 dragCopyFirstMove 单事务完成插入 + 位移,
2243
+ * 此后 path 已重指向副本、走普通拖动流程(吸附排除副本、源层供轴)。
2244
+ */
2245
+ dragTo(sceneX, sceneY) {
2246
+ const drag = this.store.ui.drag;
2247
+ if (!drag) return;
2248
+ const doc = this.store.doc;
2249
+ if (!doc || resolveLayer(doc, drag.path) === null) return;
2250
+ if (drag.copy === true && drag.copyPending === true) {
2251
+ this.dragCopyFirstMove(drag, sceneX, sceneY);
2252
+ return;
2253
+ }
2254
+ const dx = sceneX - drag.startScene.x;
2255
+ const dy = sceneY - drag.startScene.y;
2256
+ const resolution = resolveSnap(
2257
+ {
2258
+ x: drag.startBox.x + dx,
2259
+ y: drag.startBox.y + dy,
2260
+ width: drag.startBox.width,
2261
+ height: drag.startBox.height
2262
+ },
2263
+ this.snapAxesForGesture(drag.path),
2264
+ snapThresholdScene(this.store.ui.viewport.zoom)
2265
+ );
2266
+ this.store.transact((draft) => {
2267
+ const layer = resolveLayer(draft, drag.path);
2268
+ if (!layer) return;
2269
+ const position = layer.position;
2270
+ position.x = drag.startPosition.x + dx + resolution.dx;
2271
+ position.y = drag.startPosition.y + dy + resolution.dy;
2272
+ }, { mergeKey: DRAG_MERGE_KEY });
2273
+ this.store.setSnapAxes(resolution.axes);
2274
+ }
2275
+ /**
2276
+ * copyMode 首移(alt-drag-paste 工单 01,spec 决策 1/3):先过死区——位移按
2277
+ * 屏幕 4px 阈值折算(缩放无关),未越阈零事务、源层不动(Alt+点击退化);
2278
+ * 越阈则单事务完成「插入副本(紧邻源层)+ 副本自源盒起步位移」:副本 = 源
2279
+ * 子树深拷贝,落位按根层语义反解(pastePosition——根层源退化为 position +
2280
+ * 位移、格内容源把格内相对 position 换算画布绝对落位),与吸附修正一并写入,
2281
+ * mergeKey 沿用 DRAG_MERGE_KEY,后续 move 同键合并——undo 一次副本整体消失。
2282
+ * 吸附求位在副本入库前:吸附源 = 全部可见根层(副本尚不存在、源层天然在列
2283
+ * ——重叠起步,spec 决策 4);插入后手势重指向副本(startPosition 换基准为
2284
+ * 副本起步位 = 源绝对盒的根层语义反解,startBox 即副本起步盒不变),副本自动
2285
+ * 选中,命中轴照常回显。
2286
+ */
2287
+ dragCopyFirstMove(drag, sceneX, sceneY) {
2288
+ const doc = this.store.doc;
2289
+ if (!doc) return;
2290
+ const source = resolveLayer(doc, drag.path);
2291
+ if (!source) return;
2292
+ const dx = sceneX - drag.startScene.x;
2293
+ const dy = sceneY - drag.startScene.y;
2294
+ if (Math.hypot(dx, dy) <= ALT_DRAG_DEAD_ZONE_SCREEN_PX / this.store.ui.viewport.zoom) return;
2295
+ const resolution = resolveSnap(
2296
+ {
2297
+ x: drag.startBox.x + dx,
2298
+ y: drag.startBox.y + dy,
2299
+ width: drag.startBox.width,
2300
+ height: drag.startBox.height
2301
+ },
2302
+ this.snapAxesForGesture(null),
2303
+ snapThresholdScene(this.store.ui.viewport.zoom)
2304
+ );
2305
+ const copyIndex = { v: -1 };
2306
+ this.store.transact((draft) => {
2307
+ const sourceInDraft = resolveLayer(draft, drag.path);
2308
+ if (!sourceInDraft) return;
2309
+ const prepared = prepareRootPaste(
2310
+ sourceInDraft,
2311
+ drag.startBox,
2312
+ doc.width,
2313
+ doc.height,
2314
+ dx + resolution.dx,
2315
+ dy + resolution.dy
2316
+ );
2317
+ copyIndex.v = insertRootLayerAdjacentInDraft(draft, prepared, drag.path) ?? -1;
2318
+ }, { mergeKey: DRAG_MERGE_KEY });
2319
+ if (copyIndex.v < 0) return;
2320
+ const copyPath = ["layers", copyIndex.v];
2321
+ this.store.setDrag({
2322
+ ...drag,
2323
+ path: copyPath,
2324
+ startPosition: pastePosition(drag.startBox, source.position.anchor, doc.width, doc.height, 0, 0),
2325
+ copyPending: false
2326
+ });
2327
+ this.store.setSelection(copyPath);
2328
+ this.store.setSnapAxes(resolution.axes);
2329
+ }
2330
+ /** 结束拖动:闭合合并事务(下一步历史定格),会话态与命中轴回显清空 */
2331
+ endDrag() {
2332
+ if (!this.store.ui.drag) return;
2333
+ this.store.setDrag(null);
2334
+ this.store.setSnapAxes([]);
2335
+ this.store.closeMerge(DRAG_MERGE_KEY);
2336
+ }
2337
+ /**
2338
+ * 手势可用的吸附轴集合(dragTo/resizeTo 每步重算,几何与其余消费同源):
2339
+ * 可见根层盒(hitTest 同款 visible 过滤)排除手势目标层自身根 + 画布中轴 +
2340
+ * 当前参考线。自身根排除对缩放同样正确——被缩放层的自缘不供轴(对缘与移动
2341
+ * 缘都在自身盒上,供轴只会命中自己)。gesturePath 传 null = 排除面为空
2342
+ * (copyMode 首移:副本未入库,源层供轴,alt-drag-paste 工单 01)。
2343
+ */
2344
+ snapAxesForGesture(gesturePath) {
2345
+ const doc = this.store.doc;
2346
+ if (!doc) return [];
2347
+ const excludeRootIndex = gesturePath === null ? null : gesturePath[1];
2348
+ if (excludeRootIndex !== null && typeof excludeRootIndex !== "number") return [];
2349
+ return snapAxesFromBoxes(
2350
+ doc.width,
2351
+ doc.height,
2352
+ visibleRootBoxes(doc, excludeRootIndex, this.textPolicies),
2353
+ this.store.ui.guides
2354
+ );
2355
+ }
2356
+ /** 当次手势命中的吸附轴(只读查询,吸附线呈现消费);非手势态/未命中为空 */
2357
+ listSnapAxes() {
2358
+ return this.store.ui.snapAxes;
2359
+ }
2360
+ // ---- 八柄缩放(工单 07):几何纯函数在 spatial/resize,写回/吸附/采纳在此收口 ----
2361
+ /**
2362
+ * 路径处图层当前可用的八柄集合(gizmo 柄渲染与手势起点同缝):角色权威
2363
+ * 过滤(行宽=表宽、格内容尺寸强同步面不可经柄改,语义见
2364
+ * spatial/resize.resizableAxesAt)+ 锁定子树不出柄(锁定层只画选中框,
2365
+ * gizmo 同门——柄是变换入口,锁定=可定位不可变换)。
2366
+ */
2367
+ resizeHandlesAt(path) {
2368
+ const doc = this.store.doc;
2369
+ if (!doc || path === null || this.isLocked(path)) return [];
2370
+ return resizeHandlesAt(doc, path);
2371
+ }
2372
+ /**
2373
+ * 场景点命中选中层的可用柄(hitTest/hoverAt 同门的意图级查询,画布接线
2374
+ * 与 gizmo 消费):命中半径按屏幕 6px 常量折算(RESIZE_HANDLE_HIT_PX,随
2375
+ * zoom 收放);无选中/盒不可解析/未命中返回 null。命中判定以当前可用柄
2376
+ * 集合为面——角色过滤与锁定折叠在此一条缝生效。
2377
+ */
2378
+ resizeHandleAt(sceneX, sceneY) {
2379
+ const selection = this.store.ui.selection;
2380
+ if (selection === null) return null;
2381
+ const box = this.boxByPath(selection);
2382
+ if (!box) return null;
2383
+ return resizeHandleAt(
2384
+ this.resizeHandlesAt(selection),
2385
+ box,
2386
+ sceneX,
2387
+ sceneY,
2388
+ this.store.ui.viewport.zoom
2389
+ );
2390
+ }
2391
+ /**
2392
+ * 开始缩放会话:记录目标路径、柄、起点场景坐标与起始解析盒(ui 分支)。
2393
+ * 起点记录指针位置而非柄中心——位移以抓取点为基准,移动缘不跳到指针下
2394
+ * (与拖动保留抓取偏移同语义,两种主流做法中取不跳变侧)。
2395
+ * 已在手势中(拖动/缩放互斥)、路径或盒几何无法解析、路径落在锁定子树、
2396
+ * 柄的任一作用轴不在角色可缩放面内(行上拖 e/w、格内容上任意柄)时返回
2397
+ * false——gizmo 只渲染可用柄,此守卫兜其余手势来源(键盘/宿主直调)。
2398
+ */
2399
+ beginResize(path, handle, sceneX, sceneY) {
2400
+ if (this.store.ui.drag || this.store.ui.resize || this.store.ui.create) return false;
2401
+ if (this.isLocked(path)) return false;
2402
+ if (!RESIZE_HANDLES.includes(handle)) return false;
2403
+ const doc = this.store.doc;
2404
+ if (!doc || resolveLayer(doc, path) === null) return false;
2405
+ const axes = resizableAxesAt(doc, path);
2406
+ if (handleResizesWidth(handle) && !axes.width) return false;
2407
+ if (handleResizesHeight(handle) && !axes.height) return false;
2408
+ const box = this.boxByPath(path);
2409
+ if (!box) return false;
2410
+ this.store.setResize({ path, handle, startScene: { x: sceneX, y: sceneY }, startBox: box });
2411
+ return true;
2412
+ }
2413
+ /**
2414
+ * 缩放进行中(工单 07):八柄几何求位 + 移动缘吸附 + 文档写回,一个 mergeKey
2415
+ * 事务合并成一步历史。
2416
+ *
2417
+ * 几何:移动缘 = 起始缘 + 场景位移,对缘固定,minSize 钳位不翻转
2418
+ * (resizeBox);非本柄轴的位移分量忽略。
2419
+ * 吸附:移动缘位点(resizeSnapPoints,角柄双缘、边柄单缘;盒中心不参与缩放
2420
+ * 吸附)对轴集合求位——轴集合与拖动同源(snapAxesForGesture),屏幕 6px 阈值
2421
+ * 随 zoom 折算;修正施加在指针位移上、再进钳位,吸附不破「对缘固定 + minSize」
2422
+ * 语义,吸附结果只改坐标不改尺寸语义(auto 标志采纳是手势起点的轴选择,不随
2423
+ * 吸附翻转)。命中的吸附轴回写会话态供吸附线呈现(瞬时回显,endResize 清空)。
2424
+ * 写回(工单验收项 2 的语义收口):被拖轴写声明尺寸并清 auto 标志——autoWidth/
2425
+ * autoHeight 图层首个位移事务把解析尺寸落地为声明值(「改尺寸标志」语义,
2426
+ * 属性面板 auto 前缀输入框的可编辑化同门);未被拖的轴零接触——autoWidth
2427
+ * 文本层拖 s 柄后仍随文本自然宽、QR 拖 e 柄高随宽保持正方形(从动轴不采纳)。
2428
+ * 写后重断言(updateSpec 同门):autoHeight⟹声明高归零的解码不变量 + 表格
2429
+ * 强同步 canonicalize(根表宽联动行宽、格内容宽同步、行高取最高格——行高缩到
2430
+ * 最高格以下被不变量顶回属预期)。position 按目标盒反解(锚点偏移随新尺寸
2431
+ * 重算,任意锚点/任意父级下落点都精确):同步完成后以草稿内的解析尺寸与
2432
+ * 父级盒求锚点偏移,position = 目标盒坐标 − 偏移 − 父级原点(嵌套路径的
2433
+ * position 是父级相对量,父级原点经 gestureParentOrigin 在写前状态反解——
2434
+ * 根层原点恒 (0,0),行为不变)。
2435
+ *
2436
+ * 拖不动写不动的同门守卫:无会话、目标层被结构编辑移除(悬空路径)整体
2437
+ * 空转(不发事务、不发布命中轴)。
2438
+ */
2439
+ resizeTo(sceneX, sceneY) {
2440
+ const gesture = this.store.ui.resize;
2441
+ if (!gesture) return;
2442
+ const doc = this.store.doc;
2443
+ const current = doc ? resolveLayer(doc, gesture.path) : null;
2444
+ if (!doc || !current) return;
2445
+ const dx = sceneX - gesture.startScene.x;
2446
+ const dy = sceneY - gesture.startScene.y;
2447
+ const widthActive = handleResizesWidth(gesture.handle);
2448
+ const heightActive = handleResizesHeight(gesture.handle);
2449
+ const resolution = resolveSnapPoints(
2450
+ resizeSnapPoints(gesture.startBox, gesture.handle, dx, dy),
2451
+ this.snapAxesForGesture(gesture.path),
2452
+ snapThresholdScene(this.store.ui.viewport.zoom)
2453
+ );
2454
+ const target = resizeBox(
2455
+ gesture.startBox,
2456
+ gesture.handle,
2457
+ widthActive ? dx + resolution.dx : dx,
2458
+ heightActive ? dy + resolution.dy : dy
2459
+ );
2460
+ const origin = this.gestureParentOrigin(gesture.path, current);
2461
+ this.store.transact((draft) => {
2462
+ const layer = resolveLayer(draft, gesture.path);
2463
+ if (!layer) return;
2464
+ const shape = layer.shape;
2465
+ if (widthActive) {
2466
+ shape.width = target.width;
2467
+ shape.autoWidth = false;
2468
+ }
2469
+ if (heightActive) {
2470
+ shape.height = target.height;
2471
+ shape.autoHeight = false;
2472
+ }
2473
+ renormalizeAutoHeightInDraft(layer);
2474
+ canonicalizeTableSyncInDraft(
2475
+ draft,
2476
+ gesture.path,
2477
+ widthActive ? ["shape", "width"] : ["shape", "height"],
2478
+ this.textPolicies
2479
+ );
2480
+ const resolvedWidth = layerWidth(layer, this.textPolicies);
2481
+ const resolvedHeight = layerHeight2(layer, this.textPolicies);
2482
+ const parent = this.parentDimsInDraft(draft, gesture.path);
2483
+ const offset = anchorOffset2(
2484
+ layer.position.anchor,
2485
+ parent.width,
2486
+ parent.height,
2487
+ resolvedWidth,
2488
+ resolvedHeight
2489
+ );
2490
+ const position = layer.position;
2491
+ position.x = target.x - offset.x - origin.x;
2492
+ position.y = target.y - offset.y - origin.y;
2493
+ }, { mergeKey: RESIZE_MERGE_KEY });
2494
+ this.store.setSnapAxes(resolution.axes);
2495
+ }
2496
+ /**
2497
+ * 结束缩放:闭合合并事务(下一步历史定格),会话态与命中轴回显清空。
2498
+ * 无会话空转(幂等,pointercancel 与 pointerup 双路同达)。
2499
+ */
2500
+ endResize() {
2501
+ if (!this.store.ui.resize) return;
2502
+ this.store.setResize(null);
2503
+ this.store.setSnapAxes([]);
2504
+ this.store.closeMerge(RESIZE_MERGE_KEY);
2505
+ }
2506
+ // ---- 画拉建层(drag-create 工单 01):武装 + 橡皮筋纯视觉 + pointerup 落库 ----
2507
+ /**
2508
+ * 武装建层(面板新增项/层型快捷键的统一入口,CONTEXT「武装」词条):置 ui
2509
+ * 分支待命层型,下一次画布按下即开画拉;建层提交或 Esc 即解除——一次性
2510
+ * 待命态,无粘性工具(spec 决策 2)。重复武装同型值等短路零通知。
2511
+ */
2512
+ armLayerCreate(type) {
2513
+ this.store.setArmedCreate(type);
2514
+ }
2515
+ /** 解除建层武装(取消入口):仅清待命态,零副作用。 */
2516
+ cancelArmLayerCreate() {
2517
+ this.store.setArmedCreate(null);
2518
+ }
2519
+ /**
2520
+ * 开始画拉会话:须已武装(未武装空转防御——画布手势态机的分派前置,此守卫
2521
+ * 兜其余调用来源),与拖动/缩放会话互斥(手势起点一次性定死,beginDrag/
2522
+ * beginResize 同门),文档未打开空转。层型在起点一次性捕获进会话(spec
2523
+ * 决策 7:拖拽中改主意 = Esc 重武装,v1 不做拖拽中层型切换)。会话只进
2524
+ * ui 分支——拖拽期纯视觉、文档零变更(spec 决策 4)。
2525
+ */
2526
+ beginLayerCreate(sceneX, sceneY) {
2527
+ const armed = this.store.ui.armedCreate;
2528
+ if (armed === null || this.store.ui.drag || this.store.ui.resize || this.store.ui.create) return false;
2529
+ if (!this.store.doc) return false;
2530
+ const point = { x: sceneX, y: sceneY };
2531
+ this.store.setCreate({
2532
+ type: armed,
2533
+ startScene: point,
2534
+ currentScene: point,
2535
+ rect: { x: sceneX, y: sceneY, width: 0, height: 0 }
2536
+ });
2537
+ return true;
2538
+ }
2539
+ /**
2540
+ * 画拉进行中:橡皮筋求位(spatial/create.createRubberBandRect——两点正规化
2541
+ * + QR 钳 height := width + 左右/上下缘各两点进 resolveSnapPoints,吸附数学
2542
+ * 零新增)写回会话 rect 供 overlay 直读(虚线矩形 + W×H 气泡),命中轴回写
2543
+ * ui.snapAxes 走 GuidesOverlay 既有通道回显(dragTo 同门)。吸附排除面为空
2544
+ * (snapAxesForGesture(null)——新层尚未入库,全部可见根层供轴,copyMode
2545
+ * 首移同门)。零文档事务:修正只进会话几何,落库在 endCreate。
2546
+ */
2547
+ createTo(sceneX, sceneY) {
2548
+ const gesture = this.store.ui.create;
2549
+ const doc = this.store.doc;
2550
+ if (!gesture || !doc) return;
2551
+ const resolution = createRubberBandRect(
2552
+ gesture.type,
2553
+ gesture.startScene,
2554
+ { x: sceneX, y: sceneY },
2555
+ this.snapAxesForGesture(null),
2556
+ snapThresholdScene(this.store.ui.viewport.zoom)
2557
+ );
2558
+ this.store.setCreate({
2559
+ ...gesture,
2560
+ currentScene: { x: sceneX, y: sceneY },
2561
+ rect: resolution.rect
2562
+ });
2563
+ this.store.setSnapAxes(resolution.axes);
2564
+ }
2565
+ /**
2566
+ * 结束画拉:按死区分流落库(一次手势 = 一步历史,undo 整体回退落位 + 尺寸
2567
+ * + priority)。先清会话再落文档(commitTextEdit 同门——退出路径级联时幂等):
2568
+ * - 死区外:单事务写入「createDefaultLayer 缺省形态 + position 左上角对准
2569
+ * 橡皮筋矩形(含吸附修正)+ shape.width/height」并 insertRootLayerInDraft
2570
+ * 置顶(priority min−1、push 数组尾);
2571
+ * - 死区内(点击兜底,位移按 CREATE_DEAD_ZONE_SCREEN_PX 屏幕 px 随 zoom 折算,
2572
+ * ALT_DRAG_DEAD_ZONE_SCREEN_PX 同门):缺省尺寸、左上角对准点击点。
2573
+ * 落库后自动选中新层并解除武装;返回新层路径(绑定层消费:文本层据此自动
2574
+ * 进文本编辑,spec 决策 6)。无会话/无文档空转返回 null。点击兜底不产生
2575
+ * 零尺寸层(缺省形态自带可见尺寸)。
2576
+ */
2577
+ endCreate() {
2578
+ const gesture = this.store.ui.create;
2579
+ if (!gesture) return null;
2580
+ this.store.setCreate(null);
2581
+ this.store.setSnapAxes([]);
2582
+ this.store.setArmedCreate(null);
2583
+ if (!this.store.doc) return null;
2584
+ const dx = gesture.currentScene.x - gesture.startScene.x;
2585
+ const dy = gesture.currentScene.y - gesture.startScene.y;
2586
+ const deadZoneScene = CREATE_DEAD_ZONE_SCREEN_PX / this.store.ui.viewport.zoom;
2587
+ const clicked = Math.hypot(dx, dy) <= deadZoneScene;
2588
+ const index = { v: -1 };
2589
+ this.store.transact((draft) => {
2590
+ const layer = createDefaultLayer(gesture.type);
2591
+ if (clicked) {
2592
+ layer.position = { ...layer.position, x: gesture.startScene.x, y: gesture.startScene.y };
2593
+ } else {
2594
+ layer.position = { ...layer.position, x: gesture.rect.x, y: gesture.rect.y };
2595
+ layer.shape.width = gesture.rect.width;
2596
+ layer.shape.height = gesture.rect.height;
2597
+ }
2598
+ index.v = insertRootLayerInDraft(draft, layer);
2599
+ });
2600
+ if (index.v < 0) return null;
2601
+ const path = ["layers", index.v];
2602
+ this.store.setSelection(path);
2603
+ return path;
2604
+ }
2605
+ /**
2606
+ * 取消画拉(Esc 语义):清橡皮筋会话、命中轴回显与武装态——零文档写入、
2607
+ * 零历史步(拖拽期纯视觉因此取消零回退成本,spec 决策 4)。幂等:无会话时
2608
+ * 仅解除武装(待命态 Esc 同走此口)。
2609
+ */
2610
+ cancelCreate() {
2611
+ this.store.setCreate(null);
2612
+ this.store.setSnapAxes([]);
2613
+ this.store.setArmedCreate(null);
2614
+ }
2615
+ /**
2616
+ * 手势空间父级原点(嵌套路径 position 反解的坐标基准,resizeTo 专用):布局
2617
+ * 不变量 box = 父级原点 + 锚点偏移 + position(render.resolveLayerBox 同式)
2618
+ * 对被拖层成立,且父级原点在自身缩放中不动——兄弟/祖先几何不被自身尺寸写回
2619
+ * 牵动(格宽累加/行高累加只受前序兄弟影响)。故以手势同空间(模板子树经预览
2620
+ * 视图解析,与 startBox/startScene 同一坐标系)的当前盒线性反解:原点 = 当前
2621
+ * 盒 − 锚点偏移(手势空间父级盒尺寸入参)− 当前 position。根层恒 (0,0)——
2622
+ * 既有根层语义(父级 = 画布、原点即画布原点)不变;盒不可解析回落实地 (0,0)
2623
+ * (与根层同参,写回仍自洽)。须以写前状态调用:盒/position/解析尺寸三者
2624
+ * 同源才能精确反解。
2625
+ */
2626
+ gestureParentOrigin(path, layer) {
2627
+ if (isRootLayerPath(path)) return { x: 0, y: 0 };
2628
+ const doc = this.store.doc;
2629
+ if (!doc) return { x: 0, y: 0 };
2630
+ const box = this.boxByPath(path);
2631
+ if (!box) return { x: 0, y: 0 };
2632
+ const parentPath = path[path.length - 1] === "content" ? path.slice(0, -1) : path.slice(0, -2);
2633
+ const parentBox = this.boxByPath(parentPath);
2634
+ const offset = anchorOffset2(
2635
+ layer.position.anchor,
2636
+ parentBox?.width ?? doc.width,
2637
+ parentBox?.height ?? doc.height,
2638
+ layerWidth(layer, this.textPolicies),
2639
+ layerHeight2(layer, this.textPolicies)
2640
+ );
2641
+ return {
2642
+ x: box.x - offset.x - layer.position.x,
2643
+ y: box.y - offset.y - layer.position.y
2644
+ };
2645
+ }
2646
+ /**
2647
+ * 草稿内求缩放目标的父级盒尺寸(position 反解的锚点偏移入参):根层 = 画布
2648
+ * 尺寸(resolveLayerBox 根层语义同参);行/格取容器路径(rows/cells 段成对、
2649
+ * content 单段),经 layerBoxByPath 在草稿内解析——同步完成后的父级盒(行高
2650
+ * 增长等同步效应已含)。悬空回落画布尺寸(与根层同参,position 仍自洽)。
2651
+ */
2652
+ parentDimsInDraft(draft, path) {
2653
+ if (isRootLayerPath(path)) return { width: draft.width, height: draft.height };
2654
+ const parentPath = path[path.length - 1] === "content" ? path.slice(0, -1) : path.slice(0, -2);
2655
+ const box = layerBoxByPath(draft, parentPath, this.textPolicies);
2656
+ return box ?? { width: draft.width, height: draft.height };
2657
+ }
2658
+ /**
2659
+ * 方向键微调(kbd-nav 工单 02):选中层 position 按场景增量位移(注册表 8
2660
+ * 条目方向 × 步长归并到此,executeShortcut 传 ±1/±10)。作用面与拖动同门——
2661
+ * 选中什么微调什么,位置写法与 dragTo 同一语义(position 原位 += 增量);
2662
+ * 拖不动的空转(无选择/无文档/路径不可解析)。**不吸附**:吸附是拖动过程的
2663
+ * 连续行为(CONTEXT「微调」词条边界),微调是可预测的定量位移,不走
2664
+ * resolveSnap、不回显命中轴。
2665
+ * 连续微调合并一条历史:mergeKey 含选中层路径、方向无关——同层连按(跨方向)
2666
+ * 并成一步,undo 一次回连按前;换层即换步(mergeKey 随路径变);undo/redo
2667
+ * 走 store 既有断开规则(closeMerge)。断开是键控而非选择事件驱动:选择往返
2668
+ * (A→B→A 中途无文档变更)会并回 A 的原串,机制既有规则同款。拖动/缩放会话
2669
+ * 进行中空转(防插入事务拆分开放中的合并步,工单 07 缩放同门)。
2670
+ * 守卫(canvas-web-layer-lock 工单 01 挂账兑现):锁定子树空转(isLocked
2671
+ * 谓词同门——微调是变更类动作)。空转返回 false(executeShortcut 可用态口径)。
2672
+ */
2673
+ nudge(deltaX, deltaY) {
2674
+ const path = this.store.ui.selection;
2675
+ if (path === null || this.store.ui.drag !== null || this.store.ui.resize !== null) return false;
2676
+ if (this.isLocked(path)) return false;
2677
+ const doc = this.store.doc;
2678
+ if (!doc || resolveLayer(doc, path) === null) return false;
2679
+ this.store.transact((draft) => {
2680
+ const layer = resolveLayer(draft, path);
2681
+ if (!layer) return;
2682
+ const position = layer.position;
2683
+ position.x += deltaX;
2684
+ position.y += deltaY;
2685
+ }, { mergeKey: nudgeMergeKey(path) });
2686
+ return true;
2687
+ }
2688
+ // ---- 参考线与标尺(ruler-guides-snap 工单 01):会话态住 ui 分支,零契约面 ----
2689
+ /**
2690
+ * 新增参考线(标尺拖出落线的写入口,工单 03 消费):水平/垂直轴、场景坐标。
2691
+ * 住 ui 分支——不进历史、不写 graph(ADR 0012);id 会话内自增供删除寻址。
2692
+ * 非有限坐标空转返回 null(绑定层异常输入防御,不产生通知)。
2693
+ */
2694
+ addGuide(guide) {
2695
+ if (!Number.isFinite(guide.position)) return null;
2696
+ const stored = { id: ++this.guideIdSeq, orientation: guide.orientation, position: guide.position };
2697
+ this.store.setGuides([...this.store.ui.guides, stored]);
2698
+ return stored;
2699
+ }
2700
+ /** 删除参考线(拖回标尺语义):按 id 移除;未知 id 空转返回 false */
2701
+ removeGuide(id) {
2702
+ const guides = this.store.ui.guides;
2703
+ const next = guides.filter((guide) => guide.id !== id);
2704
+ if (next.length === guides.length) return false;
2705
+ this.store.setGuides(next);
2706
+ return true;
2707
+ }
2708
+ /**
2709
+ * 清空全部参考线(工具栏「清空参考线」写入口,工单 03 消费):ui 分支整体置空,
2710
+ * 与 removeGuide 同型广播 guides 分支通知(覆盖层失效重绘)。会话级语义同门——
2711
+ * 不进历史、不写 graph(ADR 0012),不可撤销;无文档或已空时空转(不产生通知)。
2712
+ */
2713
+ clearGuides() {
2714
+ if (!this.store.doc) return;
2715
+ if (this.store.ui.guides.length === 0) return;
2716
+ this.store.setGuides([]);
2717
+ }
2718
+ /** 参考线列表(只读查询,呈现与吸附供轴同源) */
2719
+ listGuides() {
2720
+ return this.store.ui.guides;
2721
+ }
2722
+ /** 标尺显隐开关(⇧R 分派口):翻转 ui 偏好,不进历史 */
2723
+ toggleRulers() {
2724
+ this.store.setRulersVisible(!this.store.ui.rulersVisible);
2725
+ }
2726
+ // ---- 对齐画布(layer-align-snap 工单 01):图层盒整体对齐画布几何 ----
2727
+ /**
2728
+ * 对齐画布 action 族(对齐画布/贴边/贴角,ADR 0011):把 path 处图层盒整体
2729
+ * 对齐到画布几何——mode 十种:贴边 left/right/top/bottom(零边距)、居中
2730
+ * h-center/v-center、贴角 corner-tl/tr/bl/br(opts.margin 统一边距,缺省
2731
+ * ALIGN_CORNER_MARGIN_PX)。与属性面板「盒内对齐」(写 align 字段移动盒内
2732
+ * 内容)是两类动作,并存不回退。
2733
+ * 几何以 layerBoxAt(path) 解析的图层盒为唯一来源、画布尺寸取 doc.canvas;
2734
+ * 盒坐标对 position 线性(box = origin + anchorOffset + position),「目标
2735
+ * 盒坐标 − 当前盒坐标」的位移原样写 position 偏移、anchor 不动;盒大于画布
2736
+ * 不钳位(偏移可为负的领域语义,溢出侧按公式确定)。一次调用 = 一步历史
2737
+ * (无 mergeKey,连续点击不合并);已对齐(零位移)经 store 空 patch 短路
2738
+ * 不进历史;文档未打开/路径不可解析/未知 mode 空转(无历史步)。
2739
+ */
2740
+ alignToCanvas(path, mode, options = {}) {
2741
+ const doc = this.store.doc;
2742
+ if (!doc) return;
2743
+ const box = this.boxByPath(path);
2744
+ if (!box) return;
2745
+ const target = alignToCanvasTarget(
2746
+ mode,
2747
+ box,
2748
+ doc.width,
2749
+ doc.height,
2750
+ options.margin ?? ALIGN_CORNER_MARGIN_PX
2751
+ );
2752
+ if (!target) return;
2753
+ const dx = target.x - box.x;
2754
+ const dy = target.y - box.y;
2755
+ this.store.transact((draft) => {
2756
+ const layer = resolveLayer(draft, path);
2757
+ if (!layer) return;
2758
+ const position = layer.position;
2759
+ position.x += dx;
2760
+ position.y += dy;
2761
+ });
2762
+ }
2763
+ // ---- 文本编辑会话(工单 11):会话住 ui 分支,提交经漏斗一次性落文档 ----
2764
+ /**
2765
+ * 进入文本编辑:路径必须解析到 TextLayer。会话只进 ui 分支(不进历史),
2766
+ * live 文本住在绑定层的 textarea,内容层随即跳绘该层文本(防重影)。
2767
+ *
2768
+ * 内容类型会话标志(canvas-web-expression-editing 工单 01,spec 决策 2)按
2769
+ * 进入瞬间 `layer.expression !== null` 锚定进会话快照——「进入时计、编辑中
2770
+ * 稳定」(verticalAnchorY 同门):编辑中面板改标记不回写标志,提交去向由
2771
+ * 快照裁决(打标是显式动作,内容判定制已否决)。同路径重复进入短路(快照
2772
+ * 不重锚);换层进入按新层现状重锚。
2773
+ */
2774
+ beginTextEdit(path) {
2775
+ const layer = this.textLayerAt(path);
2776
+ if (layer === null) return false;
2777
+ const current = this.store.ui.editing;
2778
+ if (current && pathsEqual(current.path, path)) return true;
2779
+ this.store.setEditing({ path, expression: layer.expression !== null });
2780
+ return true;
2781
+ }
2782
+ /**
2783
+ * 提交漏斗的统一出口:Esc / Ctrl+Enter / blur / 点画布他处四条退出路径都收拢
2784
+ * 到这里。先清会话再落文档——退出路径可能级联触发(画布点按的 pointerdown
2785
+ * 提交与随后 blur 竞态),会话清空后后续调用幂等,保证一次退出恰一步历史。
2786
+ *
2787
+ * 提交矩阵(canvas-web-expression-editing 工单 01,spec 决策 3,空串判定先于
2788
+ * 片段判定):
2789
+ * - 串与文档一致:不进历史(进出编辑零噪声),返回 false——对比对象随会话
2790
+ * 模式取:表达式会话对比 `layer.expression`、字面会话对比 `layer.text`(现行
2791
+ * 语义);
2792
+ * - 空串:删除该图层(复用 deleteLayer 的 splice/重映射语义),任何模式同语义;
2793
+ * - 表达式会话且串含 ≥1 语法有效闭合片段(hasValidExpressionFragment):
2794
+ * updateDataExpression 标记写(保持标记、text 自动重镜像);
2795
+ * - 其余(字面会话恒走此支;表达式会话无合法片段回落):updateData 字面写、
2796
+ * 字面接管解标——含 `{{...}}` 按字面保留,杜绝无片段标记层。
2797
+ */
2798
+ commitTextEdit(text) {
2799
+ const editing = this.store.ui.editing;
2800
+ if (!editing) return false;
2801
+ this.store.setEditing(null);
2802
+ const layer = this.textLayerAt(editing.path);
2803
+ if (!layer) return false;
2804
+ if (editing.expression ? text === layer.expression : text === layer.text) return false;
2805
+ if (text === "") {
2806
+ this.deleteLayer(editing.path);
2807
+ return true;
2808
+ }
2809
+ if (editing.expression && hasValidExpressionFragment(text)) {
2810
+ this.updateDataExpression(editing.path, text);
2811
+ return true;
2812
+ }
2813
+ this.updateData(editing.path, text);
2814
+ return true;
2815
+ }
2816
+ /**
2817
+ * 编辑 overlay 的布局描述:与绘制/命中同一套布局策略求出的盒几何与排版字段
2818
+ * (textarea 以盒左上对位、盒尺寸占位,字号/行高/内边距按场景像素交给 CSS
2819
+ * transform 缩放)。verticalAnchorY 按进入编辑时的文档文本计算,编辑中保持
2820
+ * 稳定不随键入跳动。非文本层/路径失效返回 null。
2821
+ */
2822
+ textEditLayout(path) {
2823
+ const layer = this.textLayerAt(path);
2824
+ const doc = this.store.doc;
2825
+ if (!layer || !doc) return null;
2826
+ const box = this.boxByPath(path);
2827
+ if (!box) return null;
2828
+ const editing = this.store.ui.editing;
2829
+ return {
2830
+ box,
2831
+ text: layer.text,
2832
+ font: layer.font,
2833
+ fontColor: layer.fontColor,
2834
+ fontSize: layer.fontSize,
2835
+ lineHeightPx: lineHeightPx2(layer),
2836
+ padding: layer.shape.padding,
2837
+ horizontalAlign: layer.align.horizontal,
2838
+ verticalAlign: layer.align.vertical,
2839
+ verticalAnchorY: textOrigin(layer, this.textPolicies).y,
2840
+ autowrap: layer.autowrap,
2841
+ expressionMode: editing !== null && pathsEqual(editing.path, path) ? editing.expression : layer.expression !== null
2842
+ };
2843
+ }
2844
+ /** 编辑会话的目标图层查询:路径须解析到 TextLayer,其余(含无文档)返回 null */
2845
+ textLayerAt(path) {
2846
+ const doc = this.store.doc;
2847
+ const layer = doc ? resolveLayer(doc, path) : null;
2848
+ return layer && layer.type === "TextLayer" ? layer : null;
2849
+ }
2850
+ // ---- 查找替换(canvas-web-find-replace 工单 01):会话态住 ui 分支,扫描/替换纯函数在 editing/findReplace ----
2851
+ /**
2852
+ * 打开查找会话(⌘F 分派口):无文档空转返回 false;已开时值等短路零通知
2853
+ * (重复 ⌘F 的重新聚焦归绑定层)。查询词/替换词/游标跨关开保留(closeFind
2854
+ * →beginFind 同文档连续改字),openDocument 才整体重置。
2855
+ */
2856
+ beginFind() {
2857
+ if (!this.store.doc) return false;
2858
+ this.store.setFind({ ...this.store.ui.find, open: true });
2859
+ return true;
2860
+ }
2861
+ /** 关闭查找会话(Esc 归面板组件调用):仅翻开合面,查询词与游标保留 */
2862
+ closeFind() {
2863
+ this.store.setFind({ ...this.store.ui.find, open: false });
2864
+ }
2865
+ /** 查找词写入(查找条输入即扫的源头):查询词变更即重置游标(旧下标对新命中集无意义) */
2866
+ setFindQuery(query) {
2867
+ const find = this.store.ui.find;
2868
+ this.store.setFind({ ...find, query, cursor: find.query === query ? find.cursor : 0 });
2869
+ }
2870
+ /** 替换词写入 */
2871
+ setFindReplacement(replacement) {
2872
+ this.store.setFind({ ...this.store.ui.find, replacement });
2873
+ }
2874
+ /** 游标写入(上一个/下一个导航口):存原值,越界在读侧钳位 */
2875
+ setFindCursor(cursor) {
2876
+ this.store.setFind({ ...this.store.ui.find, cursor });
2877
+ }
2878
+ /**
2879
+ * 派生命中列表(overlay 高亮与查找条计数同源消费):每次从 doc + query 现算、
2880
+ * 不驻留(树小无压力,免 pruneDanglingPaths 式失配维护);视觉序 = 数组尾→头
2881
+ * (hitTest 同门),命中 = path + 偏移对。
2882
+ */
2883
+ listFindMatches() {
2884
+ const doc = this.store.doc;
2885
+ if (!doc) return [];
2886
+ return scanTextMatches(doc, this.store.ui.find.query);
2887
+ }
2888
+ /** 当前游标(读侧钳位到派生命中列表:越界落末处/首处,空命中为 0) */
2889
+ get findCursor() {
2890
+ return clampFindCursor(this.store.ui.find.cursor, this.listFindMatches().length);
2891
+ }
2892
+ /**
2893
+ * 替换当前命中(查找条「替换」按钮):一次无 mergeKey 事务 = 一步历史;游标
2894
+ * 原地不动——事务后派生列表在被替换处缩一位,原下标即「下一处」(「替换后
2895
+ * 自动跳下一处」靠派生天然成立,末处替换则钳位停在尾)。写值经 replaceHitsInDraft
2896
+ * 与文本编辑提交同门(text 直写 + expression 保持 null)。无文档/空 query/
2897
+ * 无命中空转返回 false。
2898
+ */
2899
+ replaceOne() {
2900
+ const doc = this.store.doc;
2901
+ const query = this.store.ui.find.query;
2902
+ if (!doc || query === "") return false;
2903
+ const matches = scanTextMatches(doc, query);
2904
+ const hit = matches[clampFindCursor(this.store.ui.find.cursor, matches.length)];
2905
+ if (!hit) return false;
2906
+ this.store.transact((draft) => {
2907
+ replaceHitsInDraft(draft, [hit], this.store.ui.find.replacement);
2908
+ });
2909
+ return true;
2910
+ }
2911
+ /**
2912
+ * 全部替换(查找条「全部替换」按钮):命中(path + 偏移对)在事务前按原文
2913
+ * 快照一次算定,单事务内按同字段偏移降序应用、替换产物不重扫(replacement
2914
+ * 含查询串不死循环)——一次调用 = 一步历史,undo 一次全回。返回应用处数
2915
+ * (计数反馈「已替换 N 处」);无文档/空 query 空转返回 0。
2916
+ */
2917
+ replaceAll() {
2918
+ const doc = this.store.doc;
2919
+ const query = this.store.ui.find.query;
2920
+ if (!doc || query === "") return 0;
2921
+ const hits = scanTextMatches(doc, query);
2922
+ if (hits.length === 0) return 0;
2923
+ let applied = 0;
2924
+ this.store.transact((draft) => {
2925
+ applied = replaceHitsInDraft(draft, hits, this.store.ui.find.replacement);
2926
+ });
2927
+ return applied;
2928
+ }
2929
+ // ---- 属性写入(工单 09):面板零直改,一切文档字段编辑经这三个 action ----
2930
+ /**
2931
+ * 更新图层 spec 子树字段(属性面板的权威写入口):key 为图层内字段路径
2932
+ * (领域形态,如 ['shape','backgroundColor']、['position','x']、单段 ['fontSize']),
2933
+ * 拼上图层路径即 patch path。数值收整/钳位等取值策略由注册表层负责,内核透传。
2934
+ * 路径无法解析或字段悬空时静默空转;连续输入经 mergeKey 合并为一步历史。
2935
+ *
2936
+ * 表格子树的权威收口(工单 12):格 autoHeight 是带重算的切换(采纳内容动态高
2937
+ * 并固化,非裸标志写);其余写入后按解码强同步的不变量重断言(行宽=表宽、
2938
+ * 内容宽/高重同步、行高取最高格)——往返恒等在任何字段编辑后不破。
2939
+ */
2940
+ updateSpec(path, key, value, options = {}) {
2941
+ this.store.transact((draft) => {
2942
+ const layer = resolveLayer(draft, path);
2943
+ if (!layer) return;
2944
+ if (layer.type === "TableCellLayer" && !isTemplateSubtreePath(path) && key.length === 2 && key[0] === "shape" && key[1] === "autoHeight") {
2945
+ setCellAutoHeightInDraft(draft, path, Boolean(value), this.textPolicies);
2946
+ return;
2947
+ }
2948
+ writeSpecField(layer, key, value);
2949
+ renormalizeAutoHeightInDraft(layer);
2950
+ canonicalizeTableSyncInDraft(draft, path, key, this.textPolicies);
2951
+ }, options);
2952
+ }
2953
+ /**
2954
+ * 更新图层数据字段(wire data.value 的领域展开,按 type 分派):
2955
+ * TextLayer → text、ImageLayer → src、QrCodeLayer → value;
2956
+ * 空串/null 归空语义与解码逐条对齐(Image null、Text/Qr 空串)。
2957
+ * 字面写解除标记(工票 02,镜像 PHP 三内容层 setter):写值即置 expression =
2958
+ * null,值与标记的镜像关系随之解除——标记态文档经字面编辑退化为字面态,
2959
+ * 不可自动恢复属预期(spec §3.7 兼容面)。
2960
+ * 表/行/格无数据字段,空转。
2961
+ */
2962
+ updateData(path, value, options = {}) {
2963
+ this.store.transact((draft) => {
2964
+ const layer = resolveLayer(draft, path);
2965
+ if (!layer) return;
2966
+ switch (layer.type) {
2967
+ case "TextLayer":
2968
+ layer.text = value == null ? "" : String(value);
2969
+ layer.expression = null;
2970
+ break;
2971
+ case "ImageLayer":
2972
+ layer.src = value == null || value === "" ? null : String(value);
2973
+ layer.expression = null;
2974
+ break;
2975
+ case "QrCodeLayer":
2976
+ layer.value = value == null ? "" : String(value);
2977
+ layer.expression = null;
2978
+ break;
2979
+ default:
2980
+ break;
2981
+ }
2982
+ }, options);
2983
+ }
2984
+ /**
2985
+ * 数据字段表达式标记写入口(打标/解标,spec:canvas-web-expression-marking §1;
2986
+ * 镜像 PHP 三内容层 setExpression 与 decode 标记/未标记分支):
2987
+ * - expression 非 null = 打标:expression 原文 + 值字段恒镜像原文(Text.text /
2988
+ * Image.src / QrCode.value);标记态不做空串 →null 归一(decodeImageLayer
2989
+ * 标记分支同门)。
2990
+ * - expression = null = 解标:值字段保持现值退字面,`expression = null`;
2991
+ * 字面态空值归一交给 updateData/decode 分支(这里不清值,字面接管语义)。
2992
+ * - 仅三内容层生效,表/行/格/行模板空转(与 updateData 同门)。
2993
+ * - updateData 语义不变:字面写解除标记仍成立(spec §1 明示两者并排)。
2994
+ */
2995
+ updateDataExpression(path, expression, options = {}) {
2996
+ this.store.transact((draft) => {
2997
+ const layer = resolveLayer(draft, path);
2998
+ if (!layer) return;
2999
+ switch (layer.type) {
3000
+ case "TextLayer":
3001
+ layer.expression = expression == null ? null : String(expression);
3002
+ if (layer.expression !== null) layer.text = layer.expression;
3003
+ break;
3004
+ case "ImageLayer":
3005
+ layer.expression = expression == null ? null : String(expression);
3006
+ if (layer.expression !== null) layer.src = layer.expression;
3007
+ break;
3008
+ case "QrCodeLayer":
3009
+ layer.expression = expression == null ? null : String(expression);
3010
+ if (layer.expression !== null) layer.value = layer.expression;
3011
+ break;
3012
+ default:
3013
+ break;
3014
+ }
3015
+ }, options);
3016
+ }
3017
+ /** 更新画布级字段(未选中图层时面板的宽/高写入口);键由类型收窄为 width/height */
3018
+ updateCanvasProp(key, value, options = {}) {
3019
+ this.store.transact((draft) => {
3020
+ draft[key] = value;
3021
+ }, options);
3022
+ }
3023
+ // ---- 数据源 schema 声明(content-completion 工单 03/08):D2 宿主随会话注入 ----
3024
+ /**
3025
+ * 注入数据源 schema 声明(载荷形态,D1 钉定:声明即 compile(canvas, dataset)
3026
+ * 收到的 data 载荷形状,根上下文候选 = 载荷顶层键)。声明经
3027
+ * normalizeExpressionSchemaSource 一次性编译收口——根级结构性问题(保留键
3028
+ * row/$ 前缀,前移填充期 reserved_root_key;根非对象/缺 properties 层级)降级
3029
+ * 为无候选 + console 警告,局部故障($ref 断链/外部指针/环引用/目标形态不符)
3030
+ * 不拒绝:该节点降叶子 + 注入期汇总告警一次,树其余部分照常服务,不抛错不
3031
+ * 弹错(辅助声明不作权威);null/undefined = 清除声明。声明只住 store ui 分支:
3032
+ * 不进 graph、不落 localStorage、不进 wire;openDocument 换文档不重置,
3033
+ * 重注入/清除走同一入口。签名与「注入即整体替换」语义不变(D6)。
3034
+ */
3035
+ setDataSourceSchema(raw) {
3036
+ this.store.setDataSourceSchema(
3037
+ raw === null || raw === void 0 ? null : normalizeExpressionSchemaSource(raw)
3038
+ );
3039
+ }
3040
+ // ---- 上传(工单 13):本机资源 → 可物化引用,实现经 uploadHandler 注入 ----
3041
+ /** 上传可用性:未注入 uploadHandler 时为 false(宿主据此隐藏/禁用上传入口) */
3042
+ get canUpload() {
3043
+ return this.uploadHandler !== null;
3044
+ }
3045
+ /** 字体清单(内置可配置 + 自定义上传追加;清单 ≠ 物化,加载走渲染端物化管线) */
3046
+ get fontCatalog() {
3047
+ return this.fontCatalogValue;
3048
+ }
3049
+ /**
3050
+ * 本机图片 → 新增图片图层并写入可物化引用(全流程 = 一步历史):上传经注入
3051
+ * 实现,引用与 addRootLayerInDraft 落同一事务(撤销一次即回退整个上传建层),
3052
+ * 完成后自动选中新层。文档未打开直接返回 null(不产生上传副作用——字节不离开
3053
+ * 本机);上传失败异常上抛、文档不动。placement(kbd-nav 工单 05)缺省 =
3054
+ * 编辑器缺省盒;`at` 图层盒左上角对准场景点、`size` 1:1 落盒不缩放(拖文件
3055
+ * 入画布语义,自然尺寸由绑定层解码传入——内核无 DOM)。
3056
+ */
3057
+ async uploadImageAsLayer(file, placement = {}) {
3058
+ if (!this.store.doc) return null;
3059
+ const ref = await this.requireUpload(file);
3060
+ const index = { v: -1 };
3061
+ this.store.transact((draft) => {
3062
+ index.v = addRootLayerInDraft(draft, "ImageLayer");
3063
+ if (index.v < 0) return;
3064
+ const layer = draft.layers[index.v];
3065
+ layer.src = ref;
3066
+ if (placement.at) layer.position = { ...layer.position, x: placement.at.x, y: placement.at.y };
3067
+ if (placement.size) {
3068
+ layer.shape.width = placement.size.width;
3069
+ layer.shape.height = placement.size.height;
3070
+ }
3071
+ });
3072
+ if (index.v < 0) return null;
3073
+ const path = ["layers", index.v];
3074
+ this.store.setSelection(path);
3075
+ return path;
3076
+ }
3077
+ /**
3078
+ * 本机图片 → 可物化引用(裸上传,不建层不写文档):引用落到哪个图片层由
3079
+ * 消费方决定(属性面板图片控件),与 uploadFont 同门的 bare 形态。文档未打开
3080
+ * 直接返回 null(不产生上传副作用——字节不离开本机);未注入 uploadHandler
3081
+ * 抛 UploadHandlerMissingError;handler 失败异常上抛。
3082
+ */
3083
+ async uploadImage(file) {
3084
+ if (!this.store.doc) return null;
3085
+ return this.requireUpload(file);
3086
+ }
3087
+ /**
3088
+ * 本机字体 → 可物化引用并加入字体清单(自定义条目,上传后即可选可用)。
3089
+ * 不写文档:引用落到哪个文本层由宿主/绑定层决定(属性面板字体控件)。
3090
+ */
3091
+ async uploadFont(file) {
3092
+ const ref = await this.requireUpload(file);
3093
+ this.fontCatalogValue.addCustom({ label: uploadDisplayName(file), ref });
3094
+ return ref;
3095
+ }
3096
+ requireUpload(file) {
3097
+ if (this.uploadHandler === null) return Promise.reject(new UploadHandlerMissingError());
3098
+ return this.uploadHandler(file);
3099
+ }
3100
+ // ---- 图层面板结构编辑(工单 10):重排/增删走单一 action,重排两套语义分立 ----
3101
+ /**
3102
+ * 根层重排(图层面板拖动):面板坐标(0 = 视觉最上层),to 为 insert-before
3103
+ * 落点。数组序 splice + 移动层 priority 中点插值(保持「数组按 priority 降序」
3104
+ * 的契约语义,保存再打开顺序不变),一次调用 = 一步历史。语义细节见
3105
+ * layerPanel.moveRootLayerInDraft。
3106
+ */
3107
+ moveRootLayer(fromPanel, toPanel) {
3108
+ if (!this.store.doc) return;
3109
+ const moved = { v: null };
3110
+ this.store.transact((draft) => {
3111
+ moved.v = moveRootLayerInDraft(draft, fromPanel, toPanel);
3112
+ });
3113
+ if (moved.v === null) return;
3114
+ this.remapPathSlices([], "layers", moved.v.from, moved.v.to);
3115
+ }
3116
+ /**
3117
+ * 表格行重排(图层面板行拖动):直接改 rows 数组序(容器内嵌套数组序语义,
3118
+ * priority 不参与,与根层语义分立),一次调用 = 一步历史。行内后代的选择/
3119
+ * 悬停路径随行重映射。
3120
+ */
3121
+ moveTableRow(tablePath, fromRow, toRow) {
3122
+ if (!this.store.doc) return;
3123
+ const moved = { v: null };
3124
+ this.store.transact((draft) => {
3125
+ moved.v = moveTableRowInDraft(draft, tablePath, fromRow, toRow);
3126
+ });
3127
+ if (moved.v === null) return;
3128
+ this.remapPathSlices(tablePath, "rows", moved.v.from, moved.v.to);
3129
+ }
3130
+ /**
3131
+ * 新增根层(图层面板「新增」入口):工厂缺省形态 + 置顶 priority = min − 1
3132
+ * (空画布 0),push 到数组尾(视觉最上层)并自动选中新层;一次调用 = 一步历史。
3133
+ */
3134
+ addRootLayer(type) {
3135
+ if (!this.store.doc) return;
3136
+ const index = { v: -1 };
3137
+ this.store.transact((draft) => {
3138
+ index.v = addRootLayerInDraft(draft, type);
3139
+ });
3140
+ if (index.v >= 0) this.store.setSelection(["layers", index.v]);
3141
+ }
3142
+ /**
3143
+ * 删除图层(含子树):根层整删、行/格 splice、格内容置 null(语义见
3144
+ * layerPanel.deleteLayerInDraft);选中/悬停落在被删子树内即清空,其余路径
3145
+ * 平移重映射。一次调用 = 一步历史。
3146
+ * 模板子树删除面(spec §3.2):cells/content 两级放行(内核两型 union),
3147
+ * 仅行模板替身(template 收尾路径)拦截——删行模板 = 表退出模板态,归转换
3148
+ * 入口(convertTableToRows),不混入删除。
3149
+ * 锁定 guard(canvas-web-layer-lock 工单 01):路径落在锁定子树(锁定的根层
3150
+ * 及其行/格/内容)即静默空转——Delete/⌫ 与面板删除按钮的权威挡点,无历史步。
3151
+ */
3152
+ deleteLayer(path) {
3153
+ if (!this.store.doc || path[path.length - 1] === "template") return;
3154
+ if (this.isLocked(path)) return;
3155
+ const removed = { v: null };
3156
+ this.store.transact((draft) => {
3157
+ removed.v = deleteLayerInDraft(draft, path);
3158
+ });
3159
+ if (removed.v === null) return;
3160
+ if (removed.v.kind === "list") {
3161
+ this.remapPathSlices(removed.v.containerPath, removed.v.key, removed.v.index, removed.v.index);
3162
+ return;
3163
+ }
3164
+ if (pathsEqual(this.store.ui.selection, removed.v.path)) this.store.setSelection(null);
3165
+ if (pathsEqual(this.store.ui.hovered, removed.v.path)) this.store.setHovered(null);
3166
+ }
3167
+ /**
3168
+ * 重命名根图层(layer-panel-ux 工单 09):一次调用 = 一步历史(可撤销)。
3169
+ * 仅根图层——行/格是容器内结构(显示层「行 N / 格 N」派生标签),路径非根
3170
+ * 或不可解析一律空转(无历史步);空名 = 回退缺省(文档 name 归空串,
3171
+ * wire 键随之省略,显示层回落派生标签)。不改结构与 priority,选择/悬停
3172
+ * 路径不受影响。
3173
+ */
3174
+ renameLayer(path, name) {
3175
+ if (!this.store.doc || !isRootLayerPath(path)) return;
3176
+ this.store.transact((draft) => {
3177
+ const layer = resolveLayer(draft, path);
3178
+ if (!layer) return;
3179
+ layer.name = name;
3180
+ });
3181
+ }
3182
+ /**
3183
+ * 开始重命名会话(图层面板双击/铅笔/F2 的统一入口):路径必须解析到根图层。
3184
+ * 会话只进 ui 分支(不进历史),输入框的草稿文本住绑定层,提交经
3185
+ * commitRename 漏斗一次性落文档。
3186
+ */
3187
+ beginRename(path) {
3188
+ if (path === null || !this.store.doc || !isRootLayerPath(path)) return false;
3189
+ if (resolveLayer(this.store.doc, path) === null) return false;
3190
+ this.store.setRenaming(path);
3191
+ return true;
3192
+ }
3193
+ /**
3194
+ * 重命名提交漏斗的统一出口:Enter / blur 两条退出路径都收拢到这里。先清
3195
+ * 会话再落文档——退出路径可能级联触发(Enter 提交后 input 卸载的 blur),
3196
+ * 会话清空后后续调用幂等,保证一次退出恰一步历史。空名直传 renameLayer
3197
+ * (回退缺省语义在内核);与现名相同不产生历史步(进出编辑零噪声)。
3198
+ */
3199
+ commitRename(name) {
3200
+ const renaming = this.store.ui.renaming;
3201
+ if (renaming === null) return false;
3202
+ this.store.setRenaming(null);
3203
+ const layer = this.store.doc ? resolveLayer(this.store.doc, renaming) : null;
3204
+ if (!layer || layer.name === name) return false;
3205
+ this.renameLayer(renaming, name);
3206
+ return true;
3207
+ }
3208
+ /** 取消重命名会话:清 ui 态不落文档(Esc 语义) */
3209
+ cancelRename() {
3210
+ this.store.setRenaming(null);
3211
+ }
3212
+ /**
3213
+ * 切换根图层显示/隐藏(layer-panel-ux 工单 10):一次调用 = 一步历史(可撤销)。
3214
+ * 仅根图层——visible 在 LayerBase 面(渲染端整层跳过,行/格是容器内结构无独立
3215
+ * 可见性),路径非根或不可解析一律空转(无历史步),与 renameLayer 同门。
3216
+ * 隐藏的下游语义:渲染跳过(工单 01 契约)+ 命中/gizmo 过滤(spatial/hitTest、
3217
+ * 绑定层 gizmo),编辑器 UI 只消费这里的写入口。
3218
+ */
3219
+ toggleLayerVisibility(path) {
3220
+ if (!this.store.doc || !isRootLayerPath(path)) return;
3221
+ this.store.transact((draft) => {
3222
+ const layer = resolveLayer(draft, path);
3223
+ if (!layer) return;
3224
+ layer.visible = !layer.visible;
3225
+ });
3226
+ }
3227
+ /**
3228
+ * 切换根图层锁定(canvas-web-layer-lock 工单 01):会话级锁定态,ui 分支
3229
+ * 变更——不进历史、不写 graph、wire 零键(红线 3 同门),openDocument 重置。
3230
+ * 仅根图层——行/格是容器内结构(锁定语义只在 LayerBase 面),路径非根或
3231
+ * 不可解析一律空转(renameLayer/toggleLayerVisibility 同门)。
3232
+ * 下游语义:锁定层整子树退出画布命中面(hitTest 过滤)、拖动起点/删除在
3233
+ * 内核空转(方向键微调已随 kbd-nav 工单 02 接入同一谓词);属性编辑/duplicate/
3234
+ * 显隐/改名/z 序等刻意通道不受限;⌘D 副本新路径天然无锁。
3235
+ */
3236
+ toggleLayerLock(path) {
3237
+ if (!this.store.doc || !isRootLayerPath(path)) return;
3238
+ if (resolveLayer(this.store.doc, path) === null) return;
3239
+ const current = this.store.ui.lockedPaths;
3240
+ const next = current.some((locked) => pathsEqual(locked, path)) ? current.filter((locked) => !pathsEqual(locked, path)) : [...current, path];
3241
+ this.store.setLockedPaths(next);
3242
+ }
3243
+ /**
3244
+ * 锁定谓词(canvas-web-layer-lock 工单 01,ui 分支读取口):路径落在锁定
3245
+ * 子树即 true,判定收口 isLockedPath——hitTest 过滤、动作 guard、gizmo、
3246
+ * 面板投影共用,绑定层不得自行手写锁定路径比对。
3247
+ */
3248
+ isLocked(path) {
3249
+ return path !== null && isLockedPath(path, this.store.ui.lockedPaths);
3250
+ }
3251
+ // ---- 剪贴板与置顶/置底(工单 14):子树深拷贝语义见 clipboard.ts ----
3252
+ /**
3253
+ * 复制当前选中层:子树深拷贝快照进会话剪贴板,并捕获复制时点的绝对盒(粘贴
3254
+ * 落位基准)与源路径(sourcePath——粘贴时紧邻插入的解析凭据,alt-drag-paste
3255
+ * 工单 02)。源须为可落根层的类型(行/格是容器内结构,v1 不可复制);无选择/
3256
+ * 不可复制为 false。
3257
+ */
3258
+ copySelection() {
3259
+ if (!this.canCopySelection) return false;
3260
+ const path = this.store.ui.selection;
3261
+ const doc = this.store.doc;
3262
+ const box = this.boxByPath(path);
3263
+ if (!box) return false;
3264
+ this.clipboardEntry = {
3265
+ layer: cloneLayerSubtree(resolveLayer(doc, path)),
3266
+ sourceBox: box,
3267
+ sourcePath: path
3268
+ };
3269
+ return true;
3270
+ }
3271
+ /** 复制可用态(右键菜单「副本」的可用判定与 copySelection 同一语义) */
3272
+ get canCopySelection() {
3273
+ const path = this.store.ui.selection;
3274
+ return path !== null && this.canCopyLayerAt(path);
3275
+ }
3276
+ /**
3277
+ * 路径处图层可复制(alt-drag-paste 工单 01,Alt+拖 copyMode 的绑定层判定面):
3278
+ * 可解析且类型可落根层(canCopyLayerAt 同门——行/格是容器内结构不可复制,
3279
+ * 格内容可复制)。无文档为 false。
3280
+ */
3281
+ canCopyLayerAt(path) {
3282
+ const doc = this.store.doc;
3283
+ return doc !== null && canCopyLayerAt(doc, path);
3284
+ }
3285
+ /**
3286
+ * 粘贴剪贴板(alt-drag-paste 工单 02 真原位):子树深拷贝插入根层——落点 =
3287
+ * 复制时点绝对盒、零偏移(重复粘贴同位叠放;锚点补偿保留:dx=0 时根层源
3288
+ * position 恒等原位、格内容源落其视觉位置);z 序紧邻源层(sourcePath 解析
3289
+ * 与退化置顶见 insertRootCopy);自动选中新层;一次调用 = 一步历史。空剪贴板
3290
+ * 为 null。每次粘贴从剪贴板快照重新克隆:粘贴产物之间以及与源文档都不共享
3291
+ * 引用。
3292
+ */
3293
+ pasteFromClipboard() {
3294
+ const entry = this.clipboardEntry;
3295
+ const doc = this.store.doc;
3296
+ if (!entry || !doc) return null;
3297
+ const prepared = prepareRootPaste(entry.layer, entry.sourceBox, doc.width, doc.height, 0, 0);
3298
+ return this.insertRootCopy(prepared, entry.sourcePath);
3299
+ }
3300
+ /**
3301
+ * 创建副本 = 复制当前选中层 + 固定一格偏移(+20)+ 置顶 + 自动选中,一步历史。
3302
+ * 落位以当前绝对盒为基准(连续副本基于选中的副本链式偏移,天然不重叠)。
3303
+ * **不覆盖剪贴板**(先复制 A 再副本 B,粘贴仍出 A)。无选择/不可复制为 null。
3304
+ */
3305
+ duplicateSelection() {
3306
+ const path = this.store.ui.selection;
3307
+ const doc = this.store.doc;
3308
+ if (!path || !doc || !canCopyLayerAt(doc, path)) return null;
3309
+ const box = this.boxByPath(path);
3310
+ if (!box) return null;
3311
+ const prepared = prepareRootPaste(
3312
+ resolveLayer(doc, path),
3313
+ box,
3314
+ doc.width,
3315
+ doc.height,
3316
+ PASTE_OFFSET_PX,
3317
+ PASTE_OFFSET_PX
3318
+ );
3319
+ return this.insertRootCopy(prepared);
3320
+ }
3321
+ /**
3322
+ * 粘贴/副本的公共落库尾:根层插入 + 自动选中新层;一次调用 = 一步历史。
3323
+ * source 给定时先试紧邻源层(源仍可解析且为根层——alt-drag-paste 工单 02 的
3324
+ * ⌘V 紧邻消费;路径寻址尽力而为:删源后同下标兄弟层/他文档同下标层会顶替
3325
+ * 解析,紧邻彼层是可接受的近似,工单 Comments 记档)——原语返回 null 或条件
3326
+ * 不满足回落置顶;source 缺省恒置顶(⌘D 语义不动)。
3327
+ */
3328
+ insertRootCopy(prepared, source) {
3329
+ const index = { v: -1 };
3330
+ this.store.transact((draft) => {
3331
+ index.v = source !== void 0 && resolveLayer(draft, source) !== null && isRootLayerPath(source) ? insertRootLayerAdjacentInDraft(draft, prepared, source) ?? -1 : -1;
3332
+ if (index.v < 0) index.v = insertRootLayerInDraft(draft, prepared);
3333
+ });
3334
+ if (index.v < 0) return null;
3335
+ const path = ["layers", index.v];
3336
+ this.store.setSelection(path);
3337
+ return path;
3338
+ }
3339
+ // ---- 样式粘贴(canvas-web-style-paste 工单 01):样式集/适用面常量与快照/落地纯函数在 editing/styleClipboard ----
3340
+ /**
3341
+ * 复制样式(⌥⌘C / 右键菜单):按源类型适用面抓逐字段深拷贝快照进会话私有
3342
+ * 槽({sourceType, values})。源为**任意可解析路径**(根/行/格/格内容/模板
3343
+ * 子树皆可);无文档/路径不可解析/源无样式面(行模板替身,适用面为空)空转
3344
+ * 返回 false——空快照不占槽,已拷贝的样式不被无样式源清掉。拷贝不进历史
3345
+ * (剪贴板态不是文档态)。
3346
+ */
3347
+ copyStyle(path) {
3348
+ const doc = this.store.doc;
3349
+ const layer = doc ? resolveLayer(doc, path) : null;
3350
+ if (!layer) return false;
3351
+ const snapshot = captureStyleSnapshot(layer);
3352
+ if (!snapshot) return false;
3353
+ this.styleClipboardEntry = snapshot;
3354
+ return true;
3355
+ }
3356
+ /** 复制可用态(右键菜单):有可解析选择且源类型有样式面 */
3357
+ get canCopyStyle() {
3358
+ const path = this.store.ui.selection;
3359
+ const doc = this.store.doc;
3360
+ if (path === null || doc === null) return false;
3361
+ const layer = resolveLayer(doc, path);
3362
+ return layer !== null && STYLE_FIELD_KEYS_BY_TYPE[layer.type].length > 0;
3363
+ }
3364
+ /**
3365
+ * 粘贴样式(⌥⌘V / 右键菜单):按目标类型适用面 ∩ 快照逐字段 verbatim 覆盖
3366
+ * (null 即值、Border 整对象;快照没有的字段 = 源不适型,静默跳过)。目标为
3367
+ * **任意可解析路径**——模板子树与格内容放行(尺寸字段不在样式集,行/格/
3368
+ * 内容宽高强同步天然不冲突);锁定语义天然放行(属性写通道,CONTEXT「锁定」
3369
+ * 词条)。单事务内循环写入 + autoHeight⟹height=0 断言 + canonicalizeTableSync
3370
+ * 一次收口(updateSpec 同门)——一次粘贴 = 一步历史,连续粘贴不合并(无
3371
+ * mergeKey);全等字段跳写、整事务零变化经 store 空 patch 短路不进历史;
3372
+ * 无文档/空槽/路径不可解析空转返回 false。
3373
+ */
3374
+ pasteStyle(path) {
3375
+ const entry = this.styleClipboardEntry;
3376
+ if (!entry || !this.store.doc) return false;
3377
+ const written = { v: [] };
3378
+ this.store.transact((draft) => {
3379
+ const layer = resolveLayer(draft, path);
3380
+ if (!layer) return;
3381
+ const keys = applyStyleFieldsInDraft(layer, entry);
3382
+ if (keys.length === 0) return;
3383
+ renormalizeAutoHeightInDraft(layer);
3384
+ canonicalizeTableSyncInDraft(draft, path, keys[keys.length - 1], this.textPolicies);
3385
+ written.v = keys;
3386
+ });
3387
+ return written.v.length > 0;
3388
+ }
3389
+ /** 粘贴可用态(右键菜单):样式槽非空且有可解析选择;交集为零变化的空转在动作面兜底 */
3390
+ get canPasteStyle() {
3391
+ const path = this.store.ui.selection;
3392
+ const doc = this.store.doc;
3393
+ return this.styleClipboardEntry !== null && path !== null && doc !== null && resolveLayer(doc, path) !== null;
3394
+ }
3395
+ /**
3396
+ * z 序四件套(右键菜单语义 + kbd-nav 工单 01 键位):根层重排,v1 只作用根层
3397
+ * (行/格是数组序语义)。全部复用 moveRootLayer(priority 中点插值同款——
3398
+ * 置顶 = min−1、置底 = max+1、无整数间隙全表归一化兜底),一次调用 = 一步
3399
+ * 历史;已在目标端为无操作(不产生历史步)。返回是否发生移动(executeShortcut
3400
+ * 的可用态口径;右键菜单直调忽略返回值)。锁定层 z 序放行(锁定谓词的刻意
3401
+ * 通道,canvas-web-layer-lock 工单 01 同口径)。
3402
+ */
3403
+ bringToFront() {
3404
+ const panel = this.selectedRootPanelIndex();
3405
+ if (panel === null || panel === 0) return false;
3406
+ this.moveRootLayer(panel, 0);
3407
+ return true;
3408
+ }
3409
+ sendToBack() {
3410
+ const panel = this.selectedRootPanelIndex();
3411
+ const count = this.store.doc?.layers.length ?? 0;
3412
+ if (panel === null || panel === count - 1) return false;
3413
+ this.moveRootLayer(panel, count);
3414
+ return true;
3415
+ }
3416
+ /**
3417
+ * 前移/后移一格(kbd-nav 工单 01):面板序号 ±1 的单格重排——与置顶/置底、
3418
+ * 面板拖动同一 moveRootLayer 语义(中点插值优先、无间隙全表归一化兜底),仅
3419
+ * 根层(isRoot 同门),已最前/最后空转。连按各成一步历史(无 mergeKey,
3420
+ * shortcut action 不合步的先例)。落点按「insert-before 原始序号」折算:
3421
+ * 前移 = 插到原 panel−1 层之前(to = panel−1),后移 = 插到原 panel+1 层之后
3422
+ * (to = panel+2)。
3423
+ */
3424
+ bringForward() {
3425
+ const panel = this.selectedRootPanelIndex();
3426
+ if (panel === null || panel <= 0) return false;
3427
+ this.moveRootLayer(panel, panel - 1);
3428
+ return true;
3429
+ }
3430
+ sendBackward() {
3431
+ const panel = this.selectedRootPanelIndex();
3432
+ const count = this.store.doc?.layers.length ?? 0;
3433
+ if (panel === null || panel >= count - 1) return false;
3434
+ this.moveRootLayer(panel, panel + 2);
3435
+ return true;
3436
+ }
3437
+ /** 选中根层的面板序号(0 = 视觉最上层);非根层选择/无文档为 null */
3438
+ selectedRootPanelIndex() {
3439
+ const path = this.store.ui.selection;
3440
+ const doc = this.store.doc;
3441
+ if (!path || !doc || !isRootLayerPath(path)) return null;
3442
+ return doc.layers.length - 1 - path[1];
3443
+ }
3444
+ /**
3445
+ * 可分派的选中根层路径(⇧⌘L/⇧⌘H 分派口守卫,工单 02):无选择/无文档/非根
3446
+ * 形状/悬空(不可解析)一律 null——分派口「空转返回 false」与内核 action 的
3447
+ * 静默空转同口径(悬空窗口来自 prune 之外的结构步,兜底裁定可用态)。
3448
+ */
3449
+ selectableRootPath() {
3450
+ const path = this.store.ui.selection;
3451
+ const doc = this.store.doc;
3452
+ if (path === null || doc === null || !isRootLayerPath(path)) return null;
3453
+ return resolveLayer(doc, path) === null ? null : path;
3454
+ }
3455
+ // ---- 快捷键分派(工单 14):注册表分类(shortcuts.ts)→ 这里执行 ----
3456
+ /**
3457
+ * 执行快捷键动作(注册表分类的出口):undo/redo/delete/copy/paste/duplicate/
3458
+ * copyStyle/pasteStyle(⌥⌘C/⌥⌘V,canvas-web-style-paste 工单 01)/
3459
+ * rename/findReplace(⌘F,canvas-web-find-replace 工单 01)/
3460
+ * toggleRulers/toggleLayerLock/toggleLayerVisibility 的统一分派面,以及
3461
+ * z 序四件套与缩放三件(kbd-nav 工单 01:bringForward/sendBackward/
3462
+ * bringToFront/sendToBack/zoomReset/fitToSurface/fitToSelection)、微调八动作
3463
+ * (kbd-nav 工单 02:nudgeUp/Down/Left/Right + Coarse 变体,归并到同一 nudge
3464
+ * 实现)与循环选层两动作(kbd-nav 工单 03:selectNextLayer/selectPrevLayer);
3465
+ * helpShortcuts(kbd-nav 工单 04)是 UI 面动作,由绑定层桥拦截路由
3466
+ * 帮助面板、不经此处。让路规则在
3467
+ * 分类器(classifyEditorShortcut)裁决,到达这里的动作不再重复判态;动作为
3468
+ * 空转(无选择/空剪贴板/非根层/已在端点)返回 false,其余 true。rename 开
3469
+ * 选中根层的重命名会话(F2,工单 09),不直接写文档;锁定/显隐作用于选中根层
3470
+ * (canvas-web-layer-lock 工单 02 + feature-status 显隐键位挂账补位),非根/
3471
+ * 无选择按可用态裁剪返回 false;相机三件不进历史、恒 true(surface 未知时
3472
+ * 内核静默空转,同 toggleRulers 的无条件口径)。
3473
+ */
3474
+ executeShortcut(action) {
3475
+ switch (action) {
3476
+ case "undo":
3477
+ this.undo();
3478
+ return true;
3479
+ case "redo":
3480
+ this.redo();
3481
+ return true;
3482
+ case "copy":
3483
+ return this.copySelection();
3484
+ case "paste":
3485
+ return this.pasteFromClipboard() !== null;
3486
+ case "duplicate":
3487
+ return this.duplicateSelection() !== null;
3488
+ case "copyStyle": {
3489
+ const path = this.store.ui.selection;
3490
+ return path !== null && this.copyStyle(path);
3491
+ }
3492
+ case "pasteStyle": {
3493
+ const path = this.store.ui.selection;
3494
+ return path !== null && this.pasteStyle(path);
3495
+ }
3496
+ case "rename":
3497
+ return this.beginRename(this.store.ui.selection);
3498
+ case "findReplace":
3499
+ return this.beginFind();
3500
+ case "armCreateText":
3501
+ case "armCreateTable":
3502
+ case "armCreateQrCode":
3503
+ case "armCreateImage":
3504
+ this.armLayerCreate(ARM_CREATE_LAYER_TYPES[action]);
3505
+ return true;
3506
+ case "toggleRulers":
3507
+ this.toggleRulers();
3508
+ return true;
3509
+ case "toggleLayerLock": {
3510
+ const path = this.selectableRootPath();
3511
+ if (path === null) return false;
3512
+ this.toggleLayerLock(path);
3513
+ return true;
3514
+ }
3515
+ case "toggleLayerVisibility": {
3516
+ const path = this.selectableRootPath();
3517
+ if (path === null) return false;
3518
+ this.toggleLayerVisibility(path);
3519
+ return true;
3520
+ }
3521
+ case "bringForward":
3522
+ return this.bringForward();
3523
+ case "sendBackward":
3524
+ return this.sendBackward();
3525
+ case "bringToFront":
3526
+ return this.bringToFront();
3527
+ case "sendToBack":
3528
+ return this.sendToBack();
3529
+ case "nudgeUp":
3530
+ return this.nudge(0, -NUDGE_STEP_PX);
3531
+ case "nudgeDown":
3532
+ return this.nudge(0, NUDGE_STEP_PX);
3533
+ case "nudgeLeft":
3534
+ return this.nudge(-NUDGE_STEP_PX, 0);
3535
+ case "nudgeRight":
3536
+ return this.nudge(NUDGE_STEP_PX, 0);
3537
+ case "nudgeUpCoarse":
3538
+ return this.nudge(0, -NUDGE_COARSE_STEP_PX);
3539
+ case "nudgeDownCoarse":
3540
+ return this.nudge(0, NUDGE_COARSE_STEP_PX);
3541
+ case "nudgeLeftCoarse":
3542
+ return this.nudge(-NUDGE_COARSE_STEP_PX, 0);
3543
+ case "nudgeRightCoarse":
3544
+ return this.nudge(NUDGE_COARSE_STEP_PX, 0);
3545
+ case "selectNextLayer":
3546
+ return this.selectNextLayer();
3547
+ case "selectPrevLayer":
3548
+ return this.selectPrevLayer();
3549
+ case "zoomReset":
3550
+ this.resetZoom();
3551
+ return true;
3552
+ case "fitToSurface":
3553
+ this.fitToSurface();
3554
+ return true;
3555
+ case "fitToSelection":
3556
+ this.fitToSelection();
3557
+ return true;
3558
+ case "helpShortcuts":
3559
+ return false;
3560
+ case "delete": {
3561
+ const path = this.store.ui.selection;
3562
+ if (!path) return false;
3563
+ this.deleteLayer(path);
3564
+ return true;
3565
+ }
3566
+ }
3567
+ }
3568
+ // ---- 表格容器结构编辑(工单 12):重建路径与 graph 解码同一套 add 同步语义 ----
3569
+ /**
3570
+ * 新增表格行(面板「加行」入口):缺省行 + 缺省格(带文本内容)走重建路径
3571
+ * (行宽=表宽、内容宽=格宽、固定格压平内容高、行高取最高格——语义见
3572
+ * tableEditing.addTableRowInDraft),并自动选中新行;一次调用 = 一步历史。
3573
+ */
3574
+ addTableRow(tablePath) {
3575
+ if (!this.store.doc) return;
3576
+ const index = { v: -1 };
3577
+ this.store.transact((draft) => {
3578
+ index.v = addTableRowInDraft(draft, tablePath) ?? -1;
3579
+ });
3580
+ if (index.v >= 0) this.store.setSelection([...tablePath, "rows", index.v]);
3581
+ }
3582
+ /**
3583
+ * 新增表格单元格(面板「加格」入口):缺省格 + 缺省文本内容走重建路径
3584
+ * (内容宽=格宽、固定格压平内容高、行高取最高格),并自动选中新格;
3585
+ * 一次调用 = 一步历史。
3586
+ */
3587
+ addTableCell(rowPath) {
3588
+ if (!this.store.doc) return;
3589
+ const index = { v: -1 };
3590
+ this.store.transact((draft) => {
3591
+ index.v = addTableCellInDraft(draft, rowPath) ?? -1;
3592
+ });
3593
+ if (index.v >= 0) this.store.setSelection([...rowPath, "cells", index.v]);
3594
+ }
3595
+ /**
3596
+ * 新增模板表(「+」选择器入口,spec §2.1):createTemplateTable 缺省形态 +
3597
+ * 置顶 + 自动选中;一次调用 = 一步历史。rowsPath 必填(解码 rows_path_missing
3598
+ * 硬约束的对齐兜底,表单校验前置),空串 no-op。
3599
+ */
3600
+ addTemplateTable(rowsPath) {
3601
+ if (!this.store.doc || rowsPath === "") return;
3602
+ const index = { v: -1 };
3603
+ this.store.transact((draft) => {
3604
+ index.v = insertRootLayerInDraft(draft, createTemplateTable({ rowsPath }));
3605
+ });
3606
+ if (index.v >= 0) this.store.setSelection(["layers", index.v]);
3607
+ }
3608
+ /**
3609
+ * 模板行加格(spec §3.1):addTemplateCellInDraft 重建路径(末格宽/首格行宽、
3610
+ * 零高度耦合),自动选中新格;一次调用 = 一步历史。非模板态 no-op。
3611
+ */
3612
+ addTemplateCell(tablePath) {
3613
+ if (!this.store.doc) return;
3614
+ const index = { v: -1 };
3615
+ this.store.transact((draft) => {
3616
+ index.v = addTemplateCellInDraft(draft, tablePath) ?? -1;
3617
+ });
3618
+ if (index.v >= 0) this.store.setSelection([...tablePath, "template", "cells", index.v]);
3619
+ }
3620
+ /**
3621
+ * V1→V2 转换(spec §2.3):rowsPath 必填(空串 no-op,表单校验前置);rows
3622
+ * 子树内的选择/悬停回落表路径(rows 已清空不悬空)。一次调用 = 一步历史。
3623
+ */
3624
+ convertTableToTemplate(tablePath, rowsPath) {
3625
+ if (!this.store.doc || rowsPath === "") return;
3626
+ const done = { v: false };
3627
+ this.store.transact((draft) => {
3628
+ done.v = convertTableToTemplateInDraft(draft, tablePath, rowsPath) === true;
3629
+ });
3630
+ if (done.v) this.clampSubtreeSelection(tablePath);
3631
+ }
3632
+ /**
3633
+ * V2→V1 逆向转换(spec §2.3):模板实例化为单行(V1 归一重断言——auto 格
3634
+ * 采纳/固化、行高取最高格);模板子树内的选择/悬停回落表路径。一次调用 =
3635
+ * 一步历史。
3636
+ */
3637
+ convertTableToRows(tablePath) {
3638
+ if (!this.store.doc) return;
3639
+ const done = { v: false };
3640
+ this.store.transact((draft) => {
3641
+ done.v = convertTableToRowsInDraft(draft, tablePath, this.textPolicies) === true;
3642
+ });
3643
+ if (done.v) this.clampSubtreeSelection(tablePath);
3644
+ }
3645
+ /** 子树内选择/悬停回落到子树根(容器清空后不悬空);子树外原样不动 */
3646
+ clampSubtreeSelection(rootPath) {
3647
+ const selection = this.store.ui.selection;
3648
+ if (selection !== null && selection.length > rootPath.length && pathStartsWith(selection, rootPath)) {
3649
+ this.store.setSelection(rootPath);
3650
+ }
3651
+ const hovered = this.store.ui.hovered;
3652
+ if (hovered !== null && hovered.length > rootPath.length && pathStartsWith(hovered, rootPath)) {
3653
+ this.store.setHovered(rootPath);
3654
+ }
3655
+ }
3656
+ /**
3657
+ * 单元格移动:落点为「insert-before 原始序号」的目标行 + 序号。同行退化为
3658
+ * 数组序重排(与行重排同款 moveGuard 语义),跨行走重建路径(目标行高取
3659
+ * 最高格、内容层归属随格重同步)。一次调用 = 一步历史。
3660
+ */
3661
+ moveTableCellToRow(cellPath, targetRowPath, toCell) {
3662
+ if (!this.store.doc) return;
3663
+ const sourceRowPath = cellPath.slice(0, -2);
3664
+ if (pathsEqual(sourceRowPath, targetRowPath)) {
3665
+ const from = cellPath[cellPath.length - 1];
3666
+ if (typeof from === "number") this.moveTableCell(sourceRowPath, from, toCell);
3667
+ return;
3668
+ }
3669
+ const moved = { v: null };
3670
+ this.store.transact((draft) => {
3671
+ moved.v = moveTableCellToRowInDraft(draft, cellPath, targetRowPath, toCell, this.textPolicies);
3672
+ });
3673
+ if (moved.v !== null) this.remapMovedSubtree(moved.v);
3674
+ }
3675
+ /** 同行单元格重排(直接改 cells 数组序,容器内嵌套数组序语义) */
3676
+ moveTableCell(rowPath, fromCell, toCell) {
3677
+ if (!this.store.doc) return;
3678
+ const moved = { v: null };
3679
+ this.store.transact((draft) => {
3680
+ moved.v = moveTableCellInDraft(draft, rowPath, fromCell, toCell);
3681
+ });
3682
+ if (moved.v === null) return;
3683
+ this.remapPathSlices(rowPath, "cells", moved.v.from, moved.v.to);
3684
+ }
3685
+ /**
3686
+ * 行移动:落点为「insert-before 原始序号」的目标表 + 序号。同表退化为数组序
3687
+ * 重排(moveTableRow),跨表走重建路径(行宽同步目标表宽——addRow 副作用)。
3688
+ * 一次调用 = 一步历史。
3689
+ */
3690
+ moveTableRowToTable(rowPath, targetTablePath, toRow) {
3691
+ if (!this.store.doc) return;
3692
+ const sourceTablePath = rowPath.slice(0, -2);
3693
+ if (pathsEqual(sourceTablePath, targetTablePath)) {
3694
+ const from = rowPath[rowPath.length - 1];
3695
+ if (typeof from === "number") this.moveTableRow(sourceTablePath, from, toRow);
3696
+ return;
3697
+ }
3698
+ const moved = { v: null };
3699
+ this.store.transact((draft) => {
3700
+ moved.v = moveTableRowToTableInDraft(draft, rowPath, targetTablePath, toRow);
3701
+ });
3702
+ if (moved.v !== null) this.remapMovedSubtree(moved.v);
3703
+ }
3704
+ /**
3705
+ * 格 autoHeight 切换:开且带内容 = 采纳内容动态高(autowrap 文本即
3706
+ * 「行数×行高+padding」)并固化(解码对带内容的格恒归一为固定高);开且空格 =
3707
+ * 置标志;关 = 清标志。采纳可能增高,行高随之取最高格。一次调用 = 一步历史。
3708
+ */
3709
+ setCellAutoHeight(cellPath, on) {
3710
+ if (!this.store.doc) return;
3711
+ const applied = { v: false };
3712
+ this.store.transact((draft) => {
3713
+ applied.v = setCellAutoHeightInDraft(draft, cellPath, on, this.textPolicies);
3714
+ });
3715
+ if (applied.v) this.pruneDanglingPaths();
3716
+ }
3717
+ // ---- 撤销/重做(工单 08):双栈语义全在 store,会话只透传查询与动作 ----
3718
+ get canUndo() {
3719
+ return this.store.canUndo;
3720
+ }
3721
+ get canRedo() {
3722
+ return this.store.canRedo;
3723
+ }
3724
+ undo() {
3725
+ this.store.undo();
3726
+ this.pruneDanglingPaths();
3727
+ }
3728
+ redo() {
3729
+ this.store.redo();
3730
+ this.pruneDanglingPaths();
3731
+ }
3732
+ // ---- 订阅与失效 ----
3733
+ subscribe(listener) {
3734
+ return this.store.subscribe(listener);
3735
+ }
3736
+ /** 外部脏源入口(如资源物化完成 → 'content') */
3737
+ invalidate(target = "both") {
3738
+ if (target === "content") this.dirty.content = true;
3739
+ else if (target === "overlay") this.dirty.overlay = true;
3740
+ else {
3741
+ this.dirty.content = true;
3742
+ this.dirty.overlay = true;
3743
+ }
3744
+ this.requestFrame();
3745
+ }
3746
+ /**
3747
+ * 挂起帧同步排空(canvas-web-render-perf 工单 01):绑定层在「物理缓冲已被清空、
3748
+ * 需要背靠背补绘」的时机调用(CanvasSurface 的 resizeBuffers 赋 canvas.width 清屏
3749
+ * 后——ResizeObserver 回调在本帧 rAF 之后,等下帧重绘必现一帧空白)。有挂起帧即
3750
+ * 取消排定并按当前脏标立即 flush;无挂起帧空转幂等。内核保持无 DOM:只操作既有
3751
+ * cancelFrame/flush,不感知帧源(rAF 语义归绑定层注入的调度器)。
3752
+ */
3753
+ flushPendingFrames() {
3754
+ if (!this.frameQueued) return;
3755
+ this.cancelFrame?.();
3756
+ this.flush();
3757
+ }
3758
+ /** 取消挂起帧、退订 store、解绑绘制面 */
3759
+ dispose() {
3760
+ if (this.cancelFrame) this.cancelFrame();
3761
+ this.cancelFrame = null;
3762
+ this.frameQueued = false;
3763
+ this.unsubscribeStore();
3764
+ this.detachSurfaces();
3765
+ }
3766
+ // ---- 内部:合帧与重绘 ----
3767
+ /**
3768
+ * 结构变更后重映射 ui 分支的路径态(选择/悬停/重命名会话/锁定集合):路径是
3769
+ * 数组下标身份,容器 splice 后按 remapPathAfterSplice 平移,被移出子树的路径
3770
+ * 落地为清除。ui 整体替换、不进历史;值未变的路径 set* 内部值等短路。
3771
+ * 锁定集合(canvas-web-layer-lock 工单 01)作为集合平移——锁「胶在层上」走过
3772
+ * splice(兄弟增删/根层重排后锁仍指原层),被移出容器的锁定路径落地解锁。
3773
+ */
3774
+ remapPathSlices(containerPath, key, from, to) {
3775
+ const remap = (path) => path === null ? null : remapPathAfterSplice(path, containerPath, key, from, to);
3776
+ const selection = remap(this.store.ui.selection);
3777
+ if (selection !== this.store.ui.selection) this.store.setSelection(selection);
3778
+ const hovered = remap(this.store.ui.hovered);
3779
+ if (hovered !== this.store.ui.hovered) this.store.setHovered(hovered);
3780
+ const renaming = remap(this.store.ui.renaming);
3781
+ if (renaming !== this.store.ui.renaming) this.store.setRenaming(renaming);
3782
+ this.store.setLockedPaths(this.remappedLocks(this.store.ui.lockedPaths, remap));
3783
+ }
3784
+ /**
3785
+ * 锁定集合经映射平移(canvas-web-layer-lock 工单 01):逐路径过 map、滤除
3786
+ * 落地 null 的悬空路径;集合未变时 setLockedPaths 内容等短路(无锁/未变
3787
+ * 不通知)。remapPathSlices 的 splice 平移与 remapMovedSubtree 的前缀重挂
3788
+ * 共用同一形状——后者必须传移动前捕获的集合(源 splice 步已丢掉移动子树
3789
+ * 内的锁)。
3790
+ */
3791
+ remappedLocks(paths, map) {
3792
+ return paths.reduce((kept, locked) => {
3793
+ const mapped = map(locked);
3794
+ if (mapped !== null) kept.push(mapped);
3795
+ return kept;
3796
+ }, []);
3797
+ }
3798
+ /**
3799
+ * 跨容器移动后的选择/悬停/锁定集合重映射(工单 12 + canvas-web-layer-lock
3800
+ * 工单 01):先做源容器纯删除与目标容器纯插入的索引平移(移动子树内的路径
3801
+ * 在源删除步被置 null,不参与平移),再把移动前落在移动子树内的路径按前缀
3802
+ * 重挂到新位置(子树内部相对结构不变,仅容器段整体替换)。锁定集合的重挂
3803
+ * 当前是不变量兜底——锁定恒为根层路径、跨容器移动的子树恒为行/格子树,两集
3804
+ * 天然不相交;将来锁面扩展或根层跨容器移动接入时语义已在。
3805
+ */
3806
+ remapMovedSubtree(moved) {
3807
+ const selectionBefore = this.store.ui.selection;
3808
+ const hoveredBefore = this.store.ui.hovered;
3809
+ const lockedBefore = this.store.ui.lockedPaths;
3810
+ this.remapPathSlices(moved.source.containerPath, moved.source.key, moved.source.from, moved.source.to);
3811
+ this.remapPathSlices(moved.target.containerPath, moved.target.key, moved.target.from, moved.target.to);
3812
+ const rebase = (before) => {
3813
+ if (before === null || !pathStartsWith(before, moved.fromPath)) return null;
3814
+ return [...moved.toPath, ...before.slice(moved.fromPath.length)];
3815
+ };
3816
+ const selection = rebase(selectionBefore);
3817
+ if (selection !== null && !pathsEqual(this.store.ui.selection, selection)) {
3818
+ this.store.setSelection(selection);
3819
+ }
3820
+ const hovered = rebase(hoveredBefore);
3821
+ if (hovered !== null && !pathsEqual(this.store.ui.hovered, hovered)) {
3822
+ this.store.setHovered(hovered);
3823
+ }
3824
+ const rebasedLocks = this.remappedLocks(lockedBefore, rebase);
3825
+ if (rebasedLocks.length > 0) {
3826
+ this.store.setLockedPaths([...this.store.ui.lockedPaths, ...rebasedLocks]);
3827
+ }
3828
+ }
3829
+ /**
3830
+ * 悬空路径清理:选择/悬停/重命名会话指向已不存在的图层即清空(撤销/重做与
3831
+ * 采纳类收口共用)。锁定集合同门(canvas-web-layer-lock 工单 01):锁定路径
3832
+ * 悬空即解锁。已知限制(spec §3 记档不修):undo/redo 只有 prune 无平移——
3833
+ * 跨结构步撤销后锁可能错位(路径仍可解析、指到别的层)或清失,锁定钮常显
3834
+ * 可目视纠正;wire 持久化(锁长在层对象上)根治。
3835
+ */
3836
+ pruneDanglingPaths() {
3837
+ const doc = this.store.doc;
3838
+ if (!doc) return;
3839
+ const { selection, hovered, renaming } = this.store.ui;
3840
+ if (selection !== null && resolveLayer(doc, selection) === null) this.store.setSelection(null);
3841
+ if (hovered !== null && resolveLayer(doc, hovered) === null) this.store.setHovered(null);
3842
+ if (renaming !== null && resolveLayer(doc, renaming) === null) this.store.setRenaming(null);
3843
+ this.store.setLockedPaths(
3844
+ this.store.ui.lockedPaths.filter((locked) => resolveLayer(doc, locked) !== null)
3845
+ );
3846
+ }
3847
+ onStoreChange(change) {
3848
+ if (change.scope === "doc") this.invalidate("both");
3849
+ else if (change.branch === "viewport") this.invalidate("both");
3850
+ else if (change.branch === "editing") this.invalidate("both");
3851
+ else if (change.branch === "resourceStatuses") this.invalidate("both");
3852
+ else if (change.branch === "dataSourceSchema") return;
3853
+ else this.invalidate("overlay");
3854
+ }
3855
+ requestFrame() {
3856
+ if (this.frameQueued) return;
3857
+ this.frameQueued = true;
3858
+ this.cancelFrame = this.scheduleFrame(() => this.flush());
3859
+ }
3860
+ flush() {
3861
+ this.frameQueued = false;
3862
+ this.cancelFrame = null;
3863
+ const { content, overlay } = this.dirty;
3864
+ this.dirty = { content: false, overlay: false };
3865
+ if (content) this.repaintContent();
3866
+ if (overlay) this.repaintOverlay();
3867
+ }
3868
+ presentedViewport() {
3869
+ return snapViewportToPhysicalPixels(this.store.ui.viewport, this.dpr);
3870
+ }
3871
+ repaintContent() {
3872
+ const doc = this.store.doc;
3873
+ const view = this.previewCanvas;
3874
+ if (!doc || !view || !this.backend) return;
3875
+ const viewport = this.presentedViewport();
3876
+ const transform = {
3877
+ dpr: this.dpr,
3878
+ zoom: viewport.zoom,
3879
+ x: viewport.x,
3880
+ y: viewport.y
3881
+ };
3882
+ this.backend.setViewportTransform?.(transform);
3883
+ const editing = this.store.ui.editing;
3884
+ let editingLayer = null;
3885
+ if (editing) {
3886
+ const viewPath = docPathToViewPath(doc, editing.path);
3887
+ editingLayer = viewPath ? resolveLayer(view, viewPath) : null;
3888
+ }
3889
+ renderCanvas(view, this.backend, this.textPolicies, editingLayer ? { skipContent: (layer) => layer === editingLayer } : void 0);
3890
+ }
3891
+ repaintOverlay() {
3892
+ if (!this.overlayPainter) return;
3893
+ this.overlayPainter({
3894
+ viewport: this.presentedViewport(),
3895
+ dpr: this.dpr,
3896
+ doc: this.store.doc
3897
+ });
3898
+ }
3899
+ };
3900
+
3901
+ // src/spatial/wheel.ts
3902
+ function classifyWheel(input) {
3903
+ if (input.ctrlKey || input.metaKey) return "zoom";
3904
+ if (input.shiftKey) return "pan-x";
3905
+ return "pan";
3906
+ }
3907
+
3908
+ // src/shared/expressionCandidates.ts
3909
+ function headName(head) {
3910
+ if (head.kind === "row") return "row";
3911
+ if (head.kind === "root") return "$root";
3912
+ return head.name;
3913
+ }
3914
+ function candidateMeta(node) {
3915
+ const meta = {};
3916
+ if (node.description !== void 0) meta.description = node.description;
3917
+ if (node.title !== void 0) meta.title = node.title;
3918
+ if (node.type !== void 0) meta.type = node.type;
3919
+ return meta;
3920
+ }
3921
+ function childCandidate(entry, prefix) {
3922
+ const path = prefix === "" ? entry.key : `${prefix}.${entry.key}`;
3923
+ return { path, segment: entry.key, ...candidateMeta(entry.node) };
3924
+ }
3925
+ function headCandidates(schema, context, rowSchema) {
3926
+ const view = schemaChildEntries(schema);
3927
+ const candidates = view.entries.map((entry) => childCandidate(entry, ""));
3928
+ candidates.push({ path: "$root", segment: "$root" });
3929
+ if (context === "row") {
3930
+ candidates.push(rowSchema ? { path: "row", segment: "row", ...candidateMeta(rowSchema) } : { path: "row", segment: "row" });
3931
+ candidates.push({ path: "$index", segment: "$index" });
3932
+ }
3933
+ return view.open === true ? { candidates, open: true } : { candidates };
3934
+ }
3935
+ function enumerateExpressionCandidates(schema, query) {
3936
+ if (schema === null) {
3937
+ return { ok: true, prefix: "", partial: "", candidates: [] };
3938
+ }
3939
+ const hadTrailingDot = query.expr.endsWith(".");
3940
+ const stripped = hadTrailingDot ? query.expr.slice(0, -1) : query.expr;
3941
+ if (stripped === "") {
3942
+ if (query.expr === "") {
3943
+ return { ok: true, prefix: "", partial: "", ...headCandidates(schema, query.context, query.rowSchema) };
3944
+ }
3945
+ return { ok: false, reason: "expression_syntax_error" };
3946
+ }
3947
+ const parsed = parseExpressionPath(stripped);
3948
+ if (!parsed.ok) {
3949
+ return { ok: false, reason: "expression_syntax_error" };
3950
+ }
3951
+ const { head, rest } = parsed;
3952
+ let prefixPath;
3953
+ let partial;
3954
+ if (hadTrailingDot) {
3955
+ prefixPath = [headName(head), ...rest];
3956
+ partial = "";
3957
+ } else if (rest.length > 0) {
3958
+ prefixPath = [headName(head), ...rest.slice(0, -1)];
3959
+ partial = rest[rest.length - 1];
3960
+ } else {
3961
+ prefixPath = [];
3962
+ partial = headName(head);
3963
+ }
3964
+ if (prefixPath.length === 0) {
3965
+ return { ok: true, prefix: "", partial, ...headCandidates(schema, query.context, query.rowSchema) };
3966
+ }
3967
+ let base;
3968
+ let segments;
3969
+ if (head.kind === "row") {
3970
+ if (query.context !== "row") {
3971
+ return { ok: true, prefix: prefixPath.join("."), partial, candidates: [] };
3972
+ }
3973
+ base = query.rowSchema ?? null;
3974
+ segments = prefixPath.slice(1);
3975
+ } else if (head.kind === "root") {
3976
+ base = schema;
3977
+ segments = prefixPath.slice(1);
3978
+ } else if (head.name === "$index") {
3979
+ return { ok: true, prefix: prefixPath.join("."), partial, candidates: [] };
3980
+ } else {
3981
+ base = schema;
3982
+ segments = prefixPath;
3983
+ }
3984
+ const node = base === null ? null : schemaNodeAtPath(base, segments);
3985
+ const prefix = prefixPath.join(".");
3986
+ if (node === null) {
3987
+ return { ok: true, prefix, partial, candidates: [] };
3988
+ }
3989
+ const view = schemaChildEntries(node);
3990
+ return {
3991
+ ok: true,
3992
+ prefix,
3993
+ partial,
3994
+ candidates: view.entries.map((entry) => childCandidate(entry, prefix)),
3995
+ ...view.open === true ? { open: true } : {}
3996
+ };
3997
+ }
3998
+
3999
+ // src/shared/rowsPathCandidates.ts
4000
+ function isArrayNode(node) {
4001
+ return node.type === "array" || node.items !== void 0;
4002
+ }
4003
+ function isScalarNode(node) {
4004
+ return !isArrayNode(node) && node.properties === void 0 && node.open !== true;
4005
+ }
4006
+ function childCandidate2(key, node, prefix) {
4007
+ const candidate = {
4008
+ path: prefix === "" ? key : `${prefix}.${key}`,
4009
+ segment: key
4010
+ };
4011
+ if (node.description !== void 0) candidate.description = node.description;
4012
+ if (node.title !== void 0) candidate.title = node.title;
4013
+ if (isArrayNode(node)) candidate.type = "array";
4014
+ return candidate;
4015
+ }
4016
+ function levelCandidates(base, prefix) {
4017
+ const candidates = [];
4018
+ if (base.properties !== void 0) {
4019
+ for (const [key, child] of base.properties) {
4020
+ if (isScalarNode(child)) continue;
4021
+ candidates.push(childCandidate2(key, child, prefix));
4022
+ }
4023
+ }
4024
+ return base.open === true ? { candidates, open: true } : { candidates };
4025
+ }
4026
+ function enumerateRowsPathCandidates(startSchema, input) {
4027
+ if (startSchema === null) return null;
4028
+ const segments = input.split(".");
4029
+ const partial = input.endsWith(".") ? "" : segments[segments.length - 1];
4030
+ const completed = segments.slice(0, -1);
4031
+ let base = startSchema;
4032
+ for (const segment of completed) {
4033
+ if (isArrayNode(base)) return null;
4034
+ const child = schemaNodeAtPath(base, [segment]);
4035
+ if (child === null) return null;
4036
+ base = child;
4037
+ }
4038
+ if (isArrayNode(base) || isScalarNode(base)) return null;
4039
+ const view = levelCandidates(base, completed.join("."));
4040
+ return {
4041
+ partial,
4042
+ candidates: view.candidates,
4043
+ ...view.open === true ? { open: true } : {}
4044
+ };
4045
+ }
4046
+ function resolveRowsPathStartSchema(doc, path, rootSchema) {
4047
+ if (rootSchema === null) return null;
4048
+ if (doc === null || path === null) return rootSchema;
4049
+ let current = null;
4050
+ for (let i = 0; i < path.length; i += 1) {
4051
+ if (path[i] !== "template") continue;
4052
+ const table = resolveLayer(doc, path.slice(0, i));
4053
+ if (table === null || table.type !== "TableLayer") return null;
4054
+ current = resolveRowSchema(current ?? rootSchema, table.rowsPath);
4055
+ if (current === null) return null;
4056
+ }
4057
+ return current ?? rootSchema;
4058
+ }
4059
+
4060
+ // src/shared/textPoliciesMemo.ts
4061
+ import {
4062
+ createUax14LineBreaker,
4063
+ heuristicMeasurerFactory
4064
+ } from "@hankchen/canvas";
4065
+ var DEFAULT_LINE_ENTRIES = 512;
4066
+ var DEFAULT_MEASURE_ENTRIES = 2048;
4067
+ var LruCache = class {
4068
+ constructor(capacity) {
4069
+ this.capacity = capacity;
4070
+ }
4071
+ capacity;
4072
+ entries = /* @__PURE__ */ new Map();
4073
+ get(key) {
4074
+ const hit = this.entries.get(key);
4075
+ if (hit !== void 0) {
4076
+ this.entries.delete(key);
4077
+ this.entries.set(key, hit);
4078
+ }
4079
+ return hit;
4080
+ }
4081
+ put(key, value) {
4082
+ if (this.entries.size >= this.capacity) {
4083
+ const oldest = this.entries.keys().next().value;
4084
+ if (oldest !== void 0) this.entries.delete(oldest);
4085
+ }
4086
+ this.entries.set(key, value);
4087
+ }
4088
+ };
4089
+ function memoizeTextPolicies(policies = {}, options = {}) {
4090
+ const linesCache = new LruCache(options.maxLineEntries ?? DEFAULT_LINE_ENTRIES);
4091
+ const measureCache = new LruCache(options.maxMeasureEntries ?? DEFAULT_MEASURE_ENTRIES);
4092
+ let context = { font: "", fontSize: 0 };
4093
+ const measureMemoFactory = (font, fontSize) => {
4094
+ context = { font, fontSize };
4095
+ const base = (policies.measurerFactory ?? heuristicMeasurerFactory)(font, fontSize);
4096
+ return {
4097
+ measure(text) {
4098
+ const key = `${font}\0${fontSize}\0${text}`;
4099
+ const hit = measureCache.get(key);
4100
+ if (hit !== void 0) return hit;
4101
+ const width = base.measure(text);
4102
+ measureCache.put(key, width);
4103
+ return width;
4104
+ }
4105
+ };
4106
+ };
4107
+ const lineBreakerMemo = (text, width, measurer) => {
4108
+ const key = `${context.font}\0${context.fontSize}\0${width}\0${text}`;
4109
+ const hit = linesCache.get(key);
4110
+ if (hit !== void 0) return hit;
4111
+ const lines = (policies.lineBreaker ?? createUax14LineBreaker())(text, width, measurer);
4112
+ linesCache.put(key, lines);
4113
+ return lines;
4114
+ };
4115
+ return { lineBreaker: lineBreakerMemo, measurerFactory: measureMemoFactory };
4116
+ }
4117
+
4118
+ // src/shared/resourceStatus.ts
4119
+ import { contentHeight, contentWidth } from "@hankchen/canvas";
4120
+ function isImageContentUndrawn(layer, statuses) {
4121
+ if (layer.type !== "ImageLayer") return false;
4122
+ if (layer.expression !== null) return true;
4123
+ if (layer.src === null) return true;
4124
+ if (contentWidth(layer) <= 0 || contentHeight(layer) <= 0) return true;
4125
+ if (statuses !== void 0) {
4126
+ const status = statuses[layer.src];
4127
+ if (status !== void 0 && status !== "done") return true;
4128
+ }
4129
+ return false;
4130
+ }
4131
+
4132
+ // src/index.ts
4133
+ import {
4134
+ ANCHORS,
4135
+ HORIZONTAL_ALIGNS,
4136
+ LAYER_TYPES,
4137
+ VERTICAL_ALIGNS
4138
+ } from "@hankchen/canvas";
4139
+ import { canvasFontCssFamily } from "@hankchen/canvas-browser-renderer";
4140
+ var PACKAGE_NAME = "@hankchen/canvas-editor";
4141
+ export {
4142
+ ALIGN_CORNER_MARGIN_PX,
4143
+ ALT_DRAG_DEAD_ZONE_SCREEN_PX,
4144
+ ANCHORS,
4145
+ ARM_CREATE_LAYER_TYPES,
4146
+ CREATE_DEAD_ZONE_SCREEN_PX,
4147
+ DEFAULT_EDITOR_SHORTCUTS,
4148
+ DEFAULT_ZOOM_BOUNDS,
4149
+ EditorSession,
4150
+ EditorStore,
4151
+ FontCatalog,
4152
+ HORIZONTAL_ALIGNS,
4153
+ LAYER_TYPES,
4154
+ MAX_HISTORY_STEPS,
4155
+ PACKAGE_NAME,
4156
+ PAN_TO_BOX_MARGIN_PX,
4157
+ PASTE_OFFSET_PX,
4158
+ PHP_TRIM_CHARS,
4159
+ RESIZE_HANDLES,
4160
+ RESIZE_HANDLE_HIT_PX,
4161
+ RESIZE_MIN_SIZE_PX,
4162
+ SNAP_THRESHOLD_SCREEN_PX,
4163
+ STYLE_FIELD_KEYS_BY_TYPE,
4164
+ UploadHandlerMissingError,
4165
+ VERTICAL_ALIGNS,
4166
+ WHEEL_ZOOM_STEP,
4167
+ addRootLayerInDraft,
4168
+ addTableCellInDraft,
4169
+ addTableRowInDraft,
4170
+ addTemplateCellInDraft,
4171
+ alignToCanvasTarget,
4172
+ applyStyleFieldsInDraft,
4173
+ buildLayerOutline,
4174
+ canCopyLayerAt,
4175
+ canonicalizeTableSyncInDraft,
4176
+ canvasFontCssFamily,
4177
+ captureStyleSnapshot,
4178
+ clampZoom,
4179
+ classifyEditorShortcut,
4180
+ classifyWheel,
4181
+ cloneLayerSubtree,
4182
+ convertTableToRowsInDraft,
4183
+ convertTableToTemplateInDraft,
4184
+ createDefaultLayer,
4185
+ createRubberBandRect,
4186
+ createTemplateTable,
4187
+ deleteLayerInDraft,
4188
+ enumerateExpressionCandidates,
4189
+ enumerateRowsPathCandidates,
4190
+ expressionFragmentAtCursor,
4191
+ fitRect,
4192
+ fitViewport,
4193
+ growRowToCellInDraft,
4194
+ handleResizesHeight,
4195
+ handleResizesWidth,
4196
+ hitTest,
4197
+ insertRootLayerAdjacentInDraft,
4198
+ insertRootLayerInDraft,
4199
+ isImageContentUndrawn,
4200
+ isLayerPath,
4201
+ isLockedPath,
4202
+ isRootLayerPath,
4203
+ isRootPasteableType,
4204
+ isTemplateSubtreePath,
4205
+ layerBoxByPath,
4206
+ memoizeTextPolicies,
4207
+ moveGuard,
4208
+ moveRootLayerInDraft,
4209
+ moveTableCellInDraft,
4210
+ moveTableCellToRowInDraft,
4211
+ moveTableRowInDraft,
4212
+ moveTableRowToTableInDraft,
4213
+ nextZoomByWheel,
4214
+ normalizeExpressionSchemaSource,
4215
+ panBy,
4216
+ panToBox,
4217
+ parseExpressionPath,
4218
+ parseExpressionSchema,
4219
+ pastePosition,
4220
+ pathStartsWith,
4221
+ pathsEqual,
4222
+ prepareRootPaste,
4223
+ readSpecField,
4224
+ remapPathAfterSplice,
4225
+ replaceHitsInDraft,
4226
+ resizableAxesAt,
4227
+ resizeBox,
4228
+ resizeHandleAt,
4229
+ resizeHandlePoint,
4230
+ resizeHandlesAt,
4231
+ resizeSnapPoints,
4232
+ resolveLayer,
4233
+ resolveRowSchema,
4234
+ resolveRowsPathStartSchema,
4235
+ resolveSnap,
4236
+ resolveSnapPoints,
4237
+ rootLayerOf,
4238
+ scanExpressionFragments,
4239
+ scanTextMatches,
4240
+ sceneToScreen,
4241
+ schemaChildEntries,
4242
+ schemaNodeAtPath,
4243
+ screenToScene,
4244
+ selectionParentPath,
4245
+ setCellAutoHeightInDraft,
4246
+ snapAxesFromBoxes,
4247
+ snapThresholdScene,
4248
+ snapViewportToPhysicalPixels,
4249
+ syncContentIntoCellInDraft,
4250
+ syncRowWidthInDraft,
4251
+ uploadDisplayName,
4252
+ visibleRootBoxes,
4253
+ writeSpecField,
4254
+ zoomAtPoint
4255
+ };
4256
+ //# sourceMappingURL=index.js.map