@pooder/kit 5.4.0 → 6.0.1

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.
Files changed (69) hide show
  1. package/.test-dist/src/coordinate.js +74 -0
  2. package/.test-dist/src/extensions/background.js +547 -0
  3. package/.test-dist/src/extensions/bridgeSelection.js +20 -0
  4. package/.test-dist/src/extensions/constraints.js +237 -0
  5. package/.test-dist/src/extensions/dieline.js +935 -0
  6. package/.test-dist/src/extensions/dielineShape.js +66 -0
  7. package/.test-dist/src/extensions/edgeScale.js +12 -0
  8. package/.test-dist/src/extensions/feature.js +910 -0
  9. package/.test-dist/src/extensions/featureComplete.js +32 -0
  10. package/.test-dist/src/extensions/film.js +226 -0
  11. package/.test-dist/src/extensions/geometry.js +609 -0
  12. package/.test-dist/src/extensions/image.js +1788 -0
  13. package/.test-dist/src/extensions/index.js +28 -0
  14. package/.test-dist/src/extensions/maskOps.js +334 -0
  15. package/.test-dist/src/extensions/mirror.js +104 -0
  16. package/.test-dist/src/extensions/ruler.js +442 -0
  17. package/.test-dist/src/extensions/sceneLayout.js +96 -0
  18. package/.test-dist/src/extensions/sceneLayoutModel.js +202 -0
  19. package/.test-dist/src/extensions/sceneVisibility.js +55 -0
  20. package/.test-dist/src/extensions/size.js +331 -0
  21. package/.test-dist/src/extensions/tracer.js +709 -0
  22. package/.test-dist/src/extensions/white-ink.js +1200 -0
  23. package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
  24. package/.test-dist/src/index.js +18 -0
  25. package/.test-dist/src/services/CanvasService.js +1032 -0
  26. package/.test-dist/src/services/ViewportSystem.js +76 -0
  27. package/.test-dist/src/services/index.js +25 -0
  28. package/.test-dist/src/services/renderSpec.js +2 -0
  29. package/.test-dist/src/services/visibility.js +57 -0
  30. package/.test-dist/src/units.js +30 -0
  31. package/.test-dist/tests/run.js +150 -0
  32. package/CHANGELOG.md +12 -0
  33. package/dist/index.d.mts +164 -62
  34. package/dist/index.d.ts +164 -62
  35. package/dist/index.js +2433 -1719
  36. package/dist/index.mjs +2442 -1723
  37. package/package.json +1 -1
  38. package/src/coordinate.ts +106 -106
  39. package/src/extensions/background.ts +716 -323
  40. package/src/extensions/bridgeSelection.ts +17 -17
  41. package/src/extensions/constraints.ts +322 -322
  42. package/src/extensions/dieline.ts +1173 -1149
  43. package/src/extensions/dielineShape.ts +109 -109
  44. package/src/extensions/edgeScale.ts +19 -19
  45. package/src/extensions/feature.ts +1140 -1137
  46. package/src/extensions/featureComplete.ts +46 -46
  47. package/src/extensions/film.ts +270 -266
  48. package/src/extensions/geometry.ts +851 -885
  49. package/src/extensions/image.ts +2240 -2054
  50. package/src/extensions/index.ts +10 -11
  51. package/src/extensions/maskOps.ts +283 -283
  52. package/src/extensions/mirror.ts +128 -128
  53. package/src/extensions/ruler.ts +664 -654
  54. package/src/extensions/sceneLayout.ts +140 -140
  55. package/src/extensions/sceneLayoutModel.ts +364 -364
  56. package/src/extensions/size.ts +389 -389
  57. package/src/extensions/tracer.ts +1019 -1019
  58. package/src/extensions/white-ink.ts +1508 -1575
  59. package/src/extensions/wrappedOffsets.ts +33 -33
  60. package/src/index.ts +2 -2
  61. package/src/services/CanvasService.ts +1317 -832
  62. package/src/services/ViewportSystem.ts +95 -95
  63. package/src/services/index.ts +4 -3
  64. package/src/services/renderSpec.ts +85 -53
  65. package/src/services/visibility.ts +83 -0
  66. package/src/units.ts +27 -27
  67. package/tests/run.ts +258 -118
  68. package/tsconfig.test.json +15 -15
  69. package/src/extensions/sceneVisibility.ts +0 -64
@@ -1,832 +1,1317 @@
1
- import { Canvas, Group, FabricObject, Rect, Path, Image, Text } from "fabric";
2
- import { Service, EventBus } from "@pooder/core";
3
- import { ViewportSystem } from "./ViewportSystem";
4
- import type {
5
- RenderCoordinateSpace,
6
- RenderLayerSpec,
7
- RenderLayoutInsets,
8
- RenderLayoutLength,
9
- RenderObjectLayoutSpec,
10
- RenderObjectSpec,
11
- } from "./renderSpec";
12
-
13
- export interface RenderProducerResult {
14
- layerSpecs?: Record<string, RenderObjectSpec[]>;
15
- rootLayerSpecs?: Record<string, RenderObjectSpec[]>;
16
- replaceLayerIds?: string[];
17
- replaceRootLayerIds?: string[];
18
- }
19
-
20
- export type RenderProducer = () =>
21
- | RenderProducerResult
22
- | undefined
23
- | Promise<RenderProducerResult | undefined>;
24
-
25
- export interface RegisterRenderProducerOptions {
26
- priority?: number;
27
- }
28
-
29
- interface RenderProducerEntry {
30
- toolId: string;
31
- producer: RenderProducer;
32
- priority: number;
33
- order: number;
34
- }
35
-
36
- interface RectLike {
37
- left: number;
38
- top: number;
39
- width: number;
40
- height: number;
41
- }
42
-
43
- export default class CanvasService implements Service {
44
- public canvas: Canvas;
45
- public viewport: ViewportSystem;
46
- private eventBus?: EventBus;
47
- private renderProducers: Map<string, RenderProducerEntry> = new Map();
48
- private producerOrder = 0;
49
- private producerFlushRequested = false;
50
- private producerLoopPending = false;
51
- private producerLoopPromise: Promise<void> | null = null;
52
- private managedProducerLayerIds: Set<string> = new Set();
53
- private managedProducerRootLayerIds: Set<string> = new Set();
54
-
55
- constructor(el: HTMLCanvasElement | string | Canvas, options?: any) {
56
- if (el instanceof Canvas) {
57
- this.canvas = el;
58
- } else {
59
- this.canvas = new Canvas(el, {
60
- preserveObjectStacking: true,
61
- ...options,
62
- });
63
- }
64
-
65
- this.viewport = new ViewportSystem();
66
- if (this.canvas.width !== undefined && this.canvas.height !== undefined) {
67
- this.viewport.updateContainer(this.canvas.width, this.canvas.height);
68
- }
69
-
70
- if (options?.eventBus) {
71
- this.setEventBus(options.eventBus);
72
- }
73
- }
74
-
75
- setEventBus(eventBus: EventBus) {
76
- this.eventBus = eventBus;
77
- this.setupEvents();
78
- }
79
-
80
- private setupEvents() {
81
- if (!this.eventBus) return;
82
- const bus = this.eventBus;
83
-
84
- const forward = (name: string) => (e: any) => bus.emit(name, e);
85
-
86
- this.canvas.on("selection:created", forward("selection:created"));
87
- this.canvas.on("selection:updated", forward("selection:updated"));
88
- this.canvas.on("selection:cleared", forward("selection:cleared"));
89
- this.canvas.on("object:modified", forward("object:modified"));
90
- this.canvas.on("object:added", forward("object:added"));
91
- this.canvas.on("object:removed", forward("object:removed"));
92
- }
93
-
94
- dispose() {
95
- this.renderProducers.clear();
96
- this.managedProducerLayerIds.clear();
97
- this.managedProducerRootLayerIds.clear();
98
- this.producerFlushRequested = false;
99
- this.canvas.dispose();
100
- }
101
-
102
- registerRenderProducer(
103
- toolId: string,
104
- producer: RenderProducer,
105
- options: RegisterRenderProducerOptions = {},
106
- ): { dispose: () => void } {
107
- const normalizedToolId = String(toolId || "").trim();
108
- if (!normalizedToolId) {
109
- throw new Error(
110
- "[CanvasService] registerRenderProducer requires a toolId.",
111
- );
112
- }
113
- if (typeof producer !== "function") {
114
- throw new Error(
115
- `[CanvasService] registerRenderProducer("${normalizedToolId}") requires a producer function.`,
116
- );
117
- }
118
- const entry: RenderProducerEntry = {
119
- toolId: normalizedToolId,
120
- producer,
121
- priority: Number.isFinite(options.priority)
122
- ? Number(options.priority)
123
- : 0,
124
- order: this.producerOrder++,
125
- };
126
- this.renderProducers.set(normalizedToolId, entry);
127
- this.requestRenderFromProducers();
128
- return {
129
- dispose: () => {
130
- this.unregisterRenderProducer(normalizedToolId);
131
- },
132
- };
133
- }
134
-
135
- unregisterRenderProducer(toolId: string): boolean {
136
- const normalizedToolId = String(toolId || "").trim();
137
- if (!normalizedToolId) return false;
138
- const removed = this.renderProducers.delete(normalizedToolId);
139
- if (removed) {
140
- this.requestRenderFromProducers();
141
- }
142
- return removed;
143
- }
144
-
145
- requestRenderFromProducers() {
146
- this.producerFlushRequested = true;
147
- this.scheduleProducerLoop();
148
- }
149
-
150
- async flushRenderFromProducers(): Promise<void> {
151
- this.requestRenderFromProducers();
152
- if (this.producerLoopPromise) {
153
- await this.producerLoopPromise;
154
- }
155
- }
156
-
157
- private scheduleProducerLoop() {
158
- if (this.producerLoopPending) return;
159
- this.producerLoopPending = true;
160
- this.producerLoopPromise = Promise.resolve()
161
- .then(() => this.runProducerLoop())
162
- .catch((error) => {
163
- console.error("[CanvasService] render producer loop failed.", error);
164
- })
165
- .finally(() => {
166
- this.producerLoopPending = false;
167
- if (this.producerFlushRequested) {
168
- this.scheduleProducerLoop();
169
- }
170
- });
171
- }
172
-
173
- private async runProducerLoop(): Promise<void> {
174
- while (this.producerFlushRequested) {
175
- this.producerFlushRequested = false;
176
- await this.collectAndApplyProducerSpecs();
177
- }
178
- }
179
-
180
- private sortedRenderProducerEntries(): RenderProducerEntry[] {
181
- return Array.from(this.renderProducers.values()).sort((a, b) => {
182
- if (a.priority !== b.priority) {
183
- return a.priority - b.priority;
184
- }
185
- if (a.order !== b.order) {
186
- return a.order - b.order;
187
- }
188
- return a.toolId.localeCompare(b.toolId);
189
- });
190
- }
191
-
192
- private appendLayerSpecMap(
193
- map: Map<string, RenderObjectSpec[]>,
194
- source?: Record<string, RenderObjectSpec[]>,
195
- ) {
196
- if (!source) return;
197
- Object.entries(source).forEach(([layerId, specs]) => {
198
- if (!Array.isArray(specs)) return;
199
- const list = map.get(layerId) || [];
200
- list.push(...specs);
201
- map.set(layerId, list);
202
- });
203
- }
204
-
205
- private async collectAndApplyProducerSpecs(): Promise<void> {
206
- const groupLayerSpecs = new Map<string, RenderObjectSpec[]>();
207
- const rootLayerSpecs = new Map<string, RenderObjectSpec[]>();
208
- const replaceLayerIds = new Set<string>();
209
- const replaceRootLayerIds = new Set<string>();
210
- const entries = this.sortedRenderProducerEntries();
211
-
212
- for (const entry of entries) {
213
- try {
214
- const result = await entry.producer();
215
- if (!result) continue;
216
- this.appendLayerSpecMap(groupLayerSpecs, result.layerSpecs);
217
- this.appendLayerSpecMap(rootLayerSpecs, result.rootLayerSpecs);
218
- if (Array.isArray(result.replaceLayerIds)) {
219
- result.replaceLayerIds.forEach((layerId) => {
220
- if (layerId) replaceLayerIds.add(layerId);
221
- });
222
- }
223
- if (Array.isArray(result.replaceRootLayerIds)) {
224
- result.replaceRootLayerIds.forEach((layerId) => {
225
- if (layerId) replaceRootLayerIds.add(layerId);
226
- });
227
- }
228
- } catch (error) {
229
- console.error(
230
- `[CanvasService] render producer "${entry.toolId}" failed.`,
231
- error,
232
- );
233
- }
234
- }
235
-
236
- const nextLayerIds = new Set(groupLayerSpecs.keys());
237
- const nextRootLayerIds = new Set(rootLayerSpecs.keys());
238
-
239
- for (const [layerId, specs] of groupLayerSpecs.entries()) {
240
- if (replaceLayerIds.has(layerId)) {
241
- const layer = this.getLayer(layerId);
242
- if (layer) {
243
- (layer.getObjects() as any[]).forEach((obj) => layer.remove(obj));
244
- }
245
- }
246
- await this.applyObjectSpecsToLayer(layerId, specs, { render: false });
247
- }
248
-
249
- for (const layerId of this.managedProducerLayerIds) {
250
- if (nextLayerIds.has(layerId)) continue;
251
- const layer = this.getLayer(layerId);
252
- if (!layer) continue;
253
- await this.applyObjectSpecsToContainer(layer, [], { render: false });
254
- }
255
-
256
- for (const [layerId, specs] of rootLayerSpecs.entries()) {
257
- if (replaceRootLayerIds.has(layerId)) {
258
- const existing = this.getRootLayerObjects(layerId) as any[];
259
- existing.forEach((obj) => this.canvas.remove(obj));
260
- }
261
- await this.applyObjectSpecsToRootLayer(layerId, specs, { render: false });
262
- }
263
-
264
- for (const layerId of this.managedProducerRootLayerIds) {
265
- if (nextRootLayerIds.has(layerId)) continue;
266
- await this.applyObjectSpecsToRootLayer(layerId, [], { render: false });
267
- }
268
-
269
- this.managedProducerLayerIds = nextLayerIds;
270
- this.managedProducerRootLayerIds = nextRootLayerIds;
271
- this.requestRenderAll();
272
- }
273
-
274
- /**
275
- * Get a layer (Group) by its ID.
276
- * We assume layers are Groups directly on the canvas with a data.id property.
277
- */
278
- getLayer(id: string): Group | undefined {
279
- return this.canvas.getObjects().find((obj: any) => obj.data?.id === id) as
280
- | Group
281
- | undefined;
282
- }
283
-
284
- /**
285
- * Create a layer (Group) with the given ID if it doesn't exist.
286
- */
287
- createLayer(id: string, options: any = {}): Group {
288
- let layer = this.getLayer(id);
289
- if (!layer) {
290
- const defaultOptions = {
291
- selectable: false,
292
- evented: false,
293
- ...options,
294
- data: { ...options.data, id },
295
- };
296
- layer = new Group([], defaultOptions);
297
- this.canvas.add(layer);
298
- }
299
- return layer;
300
- }
301
-
302
- /**
303
- * Find an object by ID, optionally within a specific layer.
304
- */
305
- getObject(id: string, layerId?: string): FabricObject | undefined {
306
- if (layerId) {
307
- const layer = this.getLayer(layerId);
308
- if (!layer) return undefined;
309
- return layer.getObjects().find((obj: any) => obj.data?.id === id);
310
- }
311
- return this.canvas.getObjects().find((obj: any) => obj.data?.id === id);
312
- }
313
-
314
- requestRenderAll() {
315
- this.canvas.requestRenderAll();
316
- }
317
-
318
- resize(width: number, height: number) {
319
- this.canvas.setDimensions({ width, height });
320
- this.viewport.updateContainer(width, height);
321
- this.eventBus?.emit("canvas:resized", { width, height });
322
- this.requestRenderAll();
323
- }
324
-
325
- getSceneScale(): number {
326
- const scale = Number(this.viewport.scale);
327
- return Number.isFinite(scale) && scale > 0 ? scale : 1;
328
- }
329
-
330
- getSceneOffset(): { x: number; y: number } {
331
- const offset = this.viewport.offset;
332
- const x = Number(offset.x);
333
- const y = Number(offset.y);
334
- return {
335
- x: Number.isFinite(x) ? x : 0,
336
- y: Number.isFinite(y) ? y : 0,
337
- };
338
- }
339
-
340
- toScreenPoint(point: { x: number; y: number }): { x: number; y: number } {
341
- const scale = this.getSceneScale();
342
- const offset = this.getSceneOffset();
343
- return {
344
- x: point.x * scale + offset.x,
345
- y: point.y * scale + offset.y,
346
- };
347
- }
348
-
349
- toScenePoint(point: { x: number; y: number }): { x: number; y: number } {
350
- const scale = this.getSceneScale();
351
- const offset = this.getSceneOffset();
352
- return {
353
- x: (point.x - offset.x) / scale,
354
- y: (point.y - offset.y) / scale,
355
- };
356
- }
357
-
358
- toScreenLength(value: number): number {
359
- return value * this.getSceneScale();
360
- }
361
-
362
- toSceneLength(value: number): number {
363
- return value / this.getSceneScale();
364
- }
365
-
366
- toScreenRect(rect: {
367
- left: number;
368
- top: number;
369
- width: number;
370
- height: number;
371
- }): { left: number; top: number; width: number; height: number } {
372
- const start = this.toScreenPoint({ x: rect.left, y: rect.top });
373
- return {
374
- left: start.x,
375
- top: start.y,
376
- width: this.toScreenLength(rect.width),
377
- height: this.toScreenLength(rect.height),
378
- };
379
- }
380
-
381
- toSceneRect(rect: {
382
- left: number;
383
- top: number;
384
- width: number;
385
- height: number;
386
- }): { left: number; top: number; width: number; height: number } {
387
- const start = this.toScenePoint({ x: rect.left, y: rect.top });
388
- return {
389
- left: start.x,
390
- top: start.y,
391
- width: this.toSceneLength(rect.width),
392
- height: this.toSceneLength(rect.height),
393
- };
394
- }
395
-
396
- getSceneViewportRect(): {
397
- left: number;
398
- top: number;
399
- width: number;
400
- height: number;
401
- } {
402
- const width = Number(this.canvas.width || 0);
403
- const height = Number(this.canvas.height || 0);
404
- return this.toSceneRect({ left: 0, top: 0, width, height });
405
- }
406
-
407
- getScreenViewportRect(): RectLike {
408
- return {
409
- left: 0,
410
- top: 0,
411
- width: Number(this.canvas.width || 0),
412
- height: Number(this.canvas.height || 0),
413
- };
414
- }
415
-
416
- private toSpaceRect(
417
- rect: RectLike,
418
- from: RenderCoordinateSpace,
419
- to: RenderCoordinateSpace,
420
- ): RectLike {
421
- if (from === to) return { ...rect };
422
- if (from === "scene") {
423
- return this.toScreenRect(rect);
424
- }
425
- return this.toSceneRect(rect);
426
- }
427
-
428
- private resolveLayoutLength(
429
- value: RenderLayoutLength | undefined,
430
- base: number,
431
- ): number | undefined {
432
- if (typeof value === "number") {
433
- return Number.isFinite(value) ? value : undefined;
434
- }
435
- if (typeof value !== "string") {
436
- return undefined;
437
- }
438
- const raw = value.trim();
439
- if (!raw) return undefined;
440
- if (raw.endsWith("%")) {
441
- const percent = parseFloat(raw.slice(0, -1));
442
- if (!Number.isFinite(percent)) return undefined;
443
- return (base * percent) / 100;
444
- }
445
- const parsed = parseFloat(raw);
446
- return Number.isFinite(parsed) ? parsed : undefined;
447
- }
448
-
449
- private resolveLayoutInsets(
450
- inset: RenderLayoutLength | RenderLayoutInsets | undefined,
451
- reference: RectLike,
452
- ): { top: number; right: number; bottom: number; left: number } {
453
- if (typeof inset === "number" || typeof inset === "string") {
454
- const all =
455
- this.resolveLayoutLength(
456
- inset,
457
- Math.min(reference.width, reference.height),
458
- ) ?? 0;
459
- return { top: all, right: all, bottom: all, left: all };
460
- }
461
-
462
- const source = inset || {};
463
- const top = this.resolveLayoutLength(source.top, reference.height) ?? 0;
464
- const right = this.resolveLayoutLength(source.right, reference.width) ?? 0;
465
- const bottom =
466
- this.resolveLayoutLength(source.bottom, reference.height) ?? 0;
467
- const left = this.resolveLayoutLength(source.left, reference.width) ?? 0;
468
- return { top, right, bottom, left };
469
- }
470
-
471
- private resolveLayoutReferenceRect(
472
- layout: RenderObjectLayoutSpec,
473
- space: RenderCoordinateSpace,
474
- ): RectLike {
475
- if (layout.referenceRect) {
476
- const sourceSpace: RenderCoordinateSpace =
477
- layout.referenceRect.space || space;
478
- return this.toSpaceRect(layout.referenceRect, sourceSpace, space);
479
- }
480
-
481
- const reference = layout.reference || "sceneViewport";
482
- if (reference === "screenViewport") {
483
- const screenRect = this.getScreenViewportRect();
484
- return space === "screen" ? screenRect : this.toSceneRect(screenRect);
485
- }
486
-
487
- const sceneRect = this.getSceneViewportRect();
488
- return space === "scene" ? sceneRect : this.toScreenRect(sceneRect);
489
- }
490
-
491
- private alignFactor(value: unknown): number {
492
- if (value === "end") return 1;
493
- if (value === "center") return 0.5;
494
- return 0;
495
- }
496
-
497
- private normalizeOriginX(value: unknown): "left" | "center" | "right" {
498
- if (value === "center") return "center";
499
- if (value === "right") return "right";
500
- return "left";
501
- }
502
-
503
- private normalizeOriginY(value: unknown): "top" | "center" | "bottom" {
504
- if (value === "center") return "center";
505
- if (value === "bottom") return "bottom";
506
- return "top";
507
- }
508
-
509
- private originFactor(
510
- value: "left" | "center" | "right" | "top" | "bottom",
511
- ): number {
512
- if (value === "center") return 0.5;
513
- if (value === "right" || value === "bottom") return 1;
514
- return 0;
515
- }
516
-
517
- private resolveLayoutProps(
518
- spec: RenderObjectSpec,
519
- props: Record<string, any>,
520
- ): Record<string, any> {
521
- const layout = spec.layout;
522
- if (!layout) {
523
- return { ...props };
524
- }
525
-
526
- const space: RenderCoordinateSpace = spec.space || "scene";
527
- const reference = this.resolveLayoutReferenceRect(layout, space);
528
- const inset = this.resolveLayoutInsets(layout.inset, reference);
529
- const area: RectLike = {
530
- left: reference.left + inset.left,
531
- top: reference.top + inset.top,
532
- width: Math.max(0, reference.width - inset.left - inset.right),
533
- height: Math.max(0, reference.height - inset.top - inset.bottom),
534
- };
535
-
536
- const next = { ...props };
537
- const width =
538
- this.resolveLayoutLength(layout.width, area.width) ??
539
- (Number.isFinite(next.width) ? Number(next.width) : undefined);
540
- const height =
541
- this.resolveLayoutLength(layout.height, area.height) ??
542
- (Number.isFinite(next.height) ? Number(next.height) : undefined);
543
-
544
- if (width !== undefined) next.width = width;
545
- if (height !== undefined) next.height = height;
546
-
547
- const alignX = this.alignFactor(layout.alignX);
548
- const alignY = this.alignFactor(layout.alignY);
549
- const offsetX = this.resolveLayoutLength(layout.offsetX, area.width) ?? 0;
550
- const offsetY = this.resolveLayoutLength(layout.offsetY, area.height) ?? 0;
551
- const objectWidth = Number.isFinite(next.width) ? Number(next.width) : 0;
552
- const objectHeight = Number.isFinite(next.height) ? Number(next.height) : 0;
553
-
554
- const objectLeft =
555
- area.left + (area.width - objectWidth) * alignX + offsetX;
556
- const objectTop =
557
- area.top + (area.height - objectHeight) * alignY + offsetY;
558
-
559
- const originX = this.normalizeOriginX(next.originX);
560
- const originY = this.normalizeOriginY(next.originY);
561
- next.left = objectLeft + objectWidth * this.originFactor(originX);
562
- next.top = objectTop + objectHeight * this.originFactor(originY);
563
- return next;
564
- }
565
-
566
- setLayerVisibility(layerId: string, visible: boolean) {
567
- const layer = this.getLayer(layerId);
568
- if (layer) {
569
- layer.set({ visible });
570
- }
571
- const objects = this.getRootLayerObjects(layerId) as any[];
572
- objects.forEach((obj) => {
573
- obj.set?.({ visible });
574
- obj.setCoords?.();
575
- });
576
- }
577
-
578
- bringLayerToFront(layerId: string) {
579
- const layer = this.getLayer(layerId);
580
- if (layer) {
581
- this.canvas.bringObjectToFront(layer);
582
- }
583
- const objects = this.getRootLayerObjects(layerId) as any[];
584
- objects.forEach((obj) => this.canvas.bringObjectToFront(obj as any));
585
- }
586
-
587
- async applyLayerSpec(spec: RenderLayerSpec): Promise<void> {
588
- const layer = this.createLayer(spec.id, spec.props || {});
589
- await this.applyObjectSpecsToContainer(layer, spec.objects);
590
- }
591
-
592
- async applyObjectSpecsToLayer(
593
- layerId: string,
594
- objects: RenderObjectSpec[],
595
- options: { render?: boolean } = {},
596
- ): Promise<void> {
597
- const layer = this.createLayer(layerId, {});
598
- await this.applyObjectSpecsToContainer(layer, objects, options);
599
- }
600
-
601
- getRootLayerObjects(layerId: string): FabricObject[] {
602
- return this.canvas
603
- .getObjects()
604
- .filter((obj: any) => obj?.data?.layerId === layerId);
605
- }
606
-
607
- async applyObjectSpecsToRootLayer(
608
- layerId: string,
609
- specs: RenderObjectSpec[],
610
- options: { render?: boolean } = {},
611
- ): Promise<void> {
612
- const desiredIds = new Set(specs.map((s) => s.id));
613
- const existing = this.getRootLayerObjects(layerId) as any[];
614
- existing.forEach((obj) => {
615
- const id = obj?.data?.id;
616
- if (typeof id === "string" && !desiredIds.has(id)) {
617
- this.canvas.remove(obj);
618
- }
619
- });
620
-
621
- const byId = new Map<string, any>();
622
- this.getRootLayerObjects(layerId).forEach((obj: any) => {
623
- const id = obj?.data?.id;
624
- if (typeof id === "string") byId.set(id, obj);
625
- });
626
-
627
- for (let index = 0; index < specs.length; index += 1) {
628
- const spec = specs[index];
629
- let current = byId.get(spec.id);
630
- if (
631
- current &&
632
- spec.type === "image" &&
633
- spec.src &&
634
- current.getSrc &&
635
- current.getSrc() !== spec.src
636
- ) {
637
- this.canvas.remove(current);
638
- byId.delete(spec.id);
639
- current = undefined;
640
- }
641
-
642
- if (!current) {
643
- const created = await this.createFabricObject(spec);
644
- if (!created) continue;
645
- this.patchFabricObject(created as any, spec, { layerId });
646
- this.canvas.add(created as any);
647
- byId.set(spec.id, created);
648
- continue;
649
- }
650
-
651
- this.patchFabricObject(current, spec, { layerId });
652
- }
653
-
654
- if (options.render !== false) {
655
- this.requestRenderAll();
656
- }
657
- }
658
-
659
- private async applyObjectSpecsToContainer(
660
- container: Group,
661
- specs: RenderObjectSpec[],
662
- options: { render?: boolean } = {},
663
- ): Promise<void> {
664
- const desiredIds = new Set(specs.map((s) => s.id));
665
- const existing = container.getObjects() as any[];
666
- existing.forEach((obj) => {
667
- const id = obj?.data?.id;
668
- if (typeof id === "string" && !desiredIds.has(id)) {
669
- container.remove(obj);
670
- }
671
- });
672
-
673
- const byId = new Map<string, any>();
674
- (container.getObjects() as any[]).forEach((obj) => {
675
- const id = obj?.data?.id;
676
- if (typeof id === "string") byId.set(id, obj);
677
- });
678
-
679
- for (let index = 0; index < specs.length; index += 1) {
680
- const spec = specs[index];
681
- let current = byId.get(spec.id);
682
- if (
683
- current &&
684
- spec.type === "image" &&
685
- spec.src &&
686
- current.getSrc &&
687
- current.getSrc() !== spec.src
688
- ) {
689
- container.remove(current);
690
- byId.delete(spec.id);
691
- current = undefined;
692
- }
693
-
694
- if (!current) {
695
- const created = await this.createFabricObject(spec);
696
- if (!created) continue;
697
- container.add(created as any);
698
- current = created as any;
699
- byId.set(spec.id, current);
700
- } else {
701
- this.patchFabricObject(current, spec);
702
- }
703
-
704
- this.moveObjectInContainer(container, current, index);
705
- }
706
-
707
- container.dirty = true;
708
- if (options.render !== false) {
709
- this.requestRenderAll();
710
- }
711
- }
712
-
713
- private patchFabricObject(
714
- obj: any,
715
- spec: RenderObjectSpec,
716
- extraData?: Record<string, any>,
717
- ) {
718
- const nextData = {
719
- ...(obj.data || {}),
720
- ...(spec.data || {}),
721
- ...(extraData || {}),
722
- id: spec.id,
723
- };
724
- const props = this.resolveFabricProps(spec, spec.props || {});
725
- obj.set({ ...props, data: nextData });
726
- obj.setCoords();
727
- }
728
-
729
- private resolveFabricProps(
730
- spec: RenderObjectSpec,
731
- props: Record<string, any>,
732
- ): Record<string, any> {
733
- const space: RenderCoordinateSpace = spec.space || "scene";
734
- const next = this.resolveLayoutProps(spec, props);
735
- if (space === "screen") {
736
- return next;
737
- }
738
- const hasLeft = Number.isFinite(next.left);
739
- const hasTop = Number.isFinite(next.top);
740
- if (hasLeft || hasTop) {
741
- const mapped = this.toScreenPoint({
742
- x: hasLeft ? Number(next.left) : 0,
743
- y: hasTop ? Number(next.top) : 0,
744
- });
745
- if (hasLeft) next.left = mapped.x;
746
- if (hasTop) next.top = mapped.y;
747
- }
748
- const rawScaleX = Number.isFinite(next.scaleX) ? Number(next.scaleX) : 1;
749
- const rawScaleY = Number.isFinite(next.scaleY) ? Number(next.scaleY) : 1;
750
- const sceneScale = this.getSceneScale();
751
- next.scaleX = rawScaleX * sceneScale;
752
- next.scaleY = rawScaleY * sceneScale;
753
- return next;
754
- }
755
-
756
- private moveObjectInContainer(
757
- container: Group | Canvas,
758
- obj: any,
759
- index: number,
760
- ) {
761
- if (!obj) return;
762
-
763
- const moveObjectTo = (container as any).moveObjectTo;
764
- if (typeof moveObjectTo === "function") {
765
- moveObjectTo.call(container, obj, index);
766
- return;
767
- }
768
-
769
- const list = (container as any)._objects as any[] | undefined;
770
- if (!Array.isArray(list)) return;
771
- const from = list.indexOf(obj);
772
- if (from < 0 || from === index) return;
773
- list.splice(from, 1);
774
- const target = Math.max(0, Math.min(index, list.length));
775
- list.splice(target, 0, obj);
776
- if (typeof (container as any)._onStackOrderChanged === "function") {
777
- (container as any)._onStackOrderChanged();
778
- }
779
- }
780
-
781
- private async createFabricObject(
782
- spec: RenderObjectSpec,
783
- ): Promise<FabricObject | undefined> {
784
- if (spec.type === "rect") {
785
- const props = this.resolveFabricProps(spec, spec.props || {});
786
- const rect = new Rect({
787
- ...props,
788
- data: { ...(spec.data || {}), id: spec.id },
789
- } as any);
790
- rect.setCoords();
791
- return rect;
792
- }
793
-
794
- if (spec.type === "path") {
795
- const pathData =
796
- (spec.props as any)?.path || (spec.props as any)?.pathData;
797
- if (!pathData) return undefined;
798
- const props = this.resolveFabricProps(spec, spec.props || {});
799
- const path = new Path(pathData, {
800
- ...props,
801
- data: { ...(spec.data || {}), id: spec.id },
802
- } as any);
803
- path.setCoords();
804
- return path;
805
- }
806
-
807
- if (spec.type === "image") {
808
- if (!spec.src) return undefined;
809
- const image = await Image.fromURL(spec.src, { crossOrigin: "anonymous" });
810
- const props = this.resolveFabricProps(spec, spec.props || {});
811
- image.set({
812
- ...props,
813
- data: { ...(spec.data || {}), id: spec.id },
814
- } as any);
815
- image.setCoords();
816
- return image as any;
817
- }
818
-
819
- if (spec.type === "text") {
820
- const content = String((spec.props as any)?.text ?? "");
821
- const props = this.resolveFabricProps(spec, spec.props || {});
822
- const text = new Text(content, {
823
- ...props,
824
- data: { ...(spec.data || {}), id: spec.id },
825
- } as any);
826
- text.setCoords();
827
- return text as any;
828
- }
829
-
830
- return undefined;
831
- }
832
- }
1
+ import { Canvas, FabricObject, Rect, Path, Image, Text } from "fabric";
2
+ import {
3
+ Service,
4
+ EventBus,
5
+ ServiceContext,
6
+ TOOL_SESSION_SERVICE,
7
+ ToolSessionService,
8
+ WORKBENCH_SERVICE,
9
+ WorkbenchService,
10
+ } from "@pooder/core";
11
+ import { ViewportSystem } from "./ViewportSystem";
12
+ import type {
13
+ RenderCoordinateSpace,
14
+ RenderEffectSpec,
15
+ RenderLayoutInsets,
16
+ RenderLayoutLength,
17
+ RenderObjectLayoutSpec,
18
+ RenderObjectSpec,
19
+ RenderPassSpec,
20
+ } from "./renderSpec";
21
+ import { evaluateVisibilityExpr, type VisibilityLayerState } from "./visibility";
22
+
23
+ export interface RenderProducerResult {
24
+ passes?: RenderPassSpec[];
25
+ }
26
+
27
+ export type RenderProducer = () =>
28
+ | RenderProducerResult
29
+ | undefined
30
+ | Promise<RenderProducerResult | undefined>;
31
+
32
+ export interface RegisterRenderProducerOptions {
33
+ priority?: number;
34
+ }
35
+
36
+ interface RenderProducerEntry {
37
+ toolId: string;
38
+ producer: RenderProducer;
39
+ priority: number;
40
+ order: number;
41
+ }
42
+
43
+ interface RectLike {
44
+ left: number;
45
+ top: number;
46
+ width: number;
47
+ height: number;
48
+ }
49
+
50
+ interface ResolvedRenderPassSpec {
51
+ id: string;
52
+ stack: number;
53
+ order: number;
54
+ replace: boolean;
55
+ visibility?: RenderPassSpec["visibility"];
56
+ effects: RenderEffectSpec[];
57
+ objects: RenderObjectSpec[];
58
+ }
59
+
60
+ interface ResolvedClipPathEffectSpec {
61
+ type: "clipPath";
62
+ key: string;
63
+ visibility?: RenderPassSpec["visibility"];
64
+ source: RenderObjectSpec;
65
+ targetPassIds: string[];
66
+ }
67
+
68
+ interface ManagedPassMeta {
69
+ id: string;
70
+ stack: number;
71
+ order: number;
72
+ visibility?: RenderPassSpec["visibility"];
73
+ }
74
+
75
+ export default class CanvasService implements Service {
76
+ public canvas: Canvas;
77
+ public viewport: ViewportSystem;
78
+ private context?: ServiceContext;
79
+ private eventBus?: EventBus;
80
+ private workbenchService?: WorkbenchService;
81
+ private toolSessionService?: ToolSessionService;
82
+
83
+ private renderProducers: Map<string, RenderProducerEntry> = new Map();
84
+ private producerOrder = 0;
85
+ private producerFlushRequested = false;
86
+ private producerLoopPending = false;
87
+ private producerLoopPromise: Promise<void> | null = null;
88
+ private producerApplyInProgress = false;
89
+ private visibilityRefreshScheduled = false;
90
+
91
+ private managedProducerPassIds: Set<string> = new Set();
92
+ private managedPassMetas: Map<string, ManagedPassMeta> = new Map();
93
+ private managedPassEffects: ResolvedClipPathEffectSpec[] = [];
94
+
95
+ private canvasForwardersBound = false;
96
+ private readonly forwardSelectionCreated = (e: any) => {
97
+ this.eventBus?.emit("selection:created", e);
98
+ };
99
+ private readonly forwardSelectionUpdated = (e: any) => {
100
+ this.eventBus?.emit("selection:updated", e);
101
+ };
102
+ private readonly forwardSelectionCleared = (e: any) => {
103
+ this.eventBus?.emit("selection:cleared", e);
104
+ };
105
+ private readonly forwardObjectModified = (e: any) => {
106
+ this.eventBus?.emit("object:modified", e);
107
+ };
108
+ private readonly forwardObjectAdded = (e: any) => {
109
+ this.eventBus?.emit("object:added", e);
110
+ };
111
+ private readonly forwardObjectRemoved = (e: any) => {
112
+ this.eventBus?.emit("object:removed", e);
113
+ };
114
+
115
+ private readonly onToolActivated = () => {
116
+ this.applyManagedPassVisibility();
117
+ void this.applyManagedPassEffects(undefined, { render: true });
118
+ };
119
+ private readonly onToolSessionChanged = () => {
120
+ this.applyManagedPassVisibility();
121
+ void this.applyManagedPassEffects(undefined, { render: true });
122
+ };
123
+ private readonly onCanvasObjectChanged = () => {
124
+ if (this.producerApplyInProgress) return;
125
+ this.scheduleManagedPassVisibilityRefresh();
126
+ };
127
+
128
+ constructor(el: HTMLCanvasElement | string | Canvas, options?: any) {
129
+ if (el instanceof Canvas) {
130
+ this.canvas = el;
131
+ } else {
132
+ this.canvas = new Canvas(el, {
133
+ preserveObjectStacking: true,
134
+ ...options,
135
+ });
136
+ }
137
+
138
+ this.viewport = new ViewportSystem();
139
+ if (this.canvas.width !== undefined && this.canvas.height !== undefined) {
140
+ this.viewport.updateContainer(this.canvas.width, this.canvas.height);
141
+ }
142
+
143
+ if (options?.eventBus) {
144
+ this.setEventBus(options.eventBus);
145
+ }
146
+ }
147
+
148
+ init(context: ServiceContext) {
149
+ if (this.context) {
150
+ this.detachContextEvents(this.context.eventBus);
151
+ }
152
+
153
+ this.context = context;
154
+ this.workbenchService = context.get(WORKBENCH_SERVICE);
155
+ this.toolSessionService = context.get(TOOL_SESSION_SERVICE);
156
+ this.setEventBus(context.eventBus);
157
+ this.attachContextEvents(context.eventBus);
158
+ }
159
+
160
+ private attachContextEvents(eventBus: EventBus) {
161
+ eventBus.on("tool:activated", this.onToolActivated);
162
+ eventBus.on("tool:session:change", this.onToolSessionChanged);
163
+ eventBus.on("object:added", this.onCanvasObjectChanged);
164
+ eventBus.on("object:removed", this.onCanvasObjectChanged);
165
+ }
166
+
167
+ private detachContextEvents(eventBus: EventBus) {
168
+ eventBus.off("tool:activated", this.onToolActivated);
169
+ eventBus.off("tool:session:change", this.onToolSessionChanged);
170
+ eventBus.off("object:added", this.onCanvasObjectChanged);
171
+ eventBus.off("object:removed", this.onCanvasObjectChanged);
172
+ }
173
+
174
+ setEventBus(eventBus: EventBus) {
175
+ this.eventBus = eventBus;
176
+ this.setupEvents();
177
+ }
178
+
179
+ private setupEvents() {
180
+ if (this.canvasForwardersBound) return;
181
+ this.canvas.on("selection:created", this.forwardSelectionCreated);
182
+ this.canvas.on("selection:updated", this.forwardSelectionUpdated);
183
+ this.canvas.on("selection:cleared", this.forwardSelectionCleared);
184
+ this.canvas.on("object:modified", this.forwardObjectModified);
185
+ this.canvas.on("object:added", this.forwardObjectAdded);
186
+ this.canvas.on("object:removed", this.forwardObjectRemoved);
187
+ this.canvasForwardersBound = true;
188
+ }
189
+
190
+ dispose() {
191
+ if (this.context) {
192
+ this.detachContextEvents(this.context.eventBus);
193
+ }
194
+ this.renderProducers.clear();
195
+ this.managedProducerPassIds.clear();
196
+ this.managedPassMetas.clear();
197
+ this.managedPassEffects = [];
198
+ this.context = undefined;
199
+ this.workbenchService = undefined;
200
+ this.toolSessionService = undefined;
201
+ this.producerFlushRequested = false;
202
+ this.canvas.dispose();
203
+ }
204
+
205
+ registerRenderProducer(
206
+ toolId: string,
207
+ producer: RenderProducer,
208
+ options: RegisterRenderProducerOptions = {},
209
+ ): { dispose: () => void } {
210
+ const normalizedToolId = String(toolId || "").trim();
211
+ if (!normalizedToolId) {
212
+ throw new Error(
213
+ "[CanvasService] registerRenderProducer requires a toolId.",
214
+ );
215
+ }
216
+ if (typeof producer !== "function") {
217
+ throw new Error(
218
+ `[CanvasService] registerRenderProducer("${normalizedToolId}") requires a producer function.`,
219
+ );
220
+ }
221
+ const entry: RenderProducerEntry = {
222
+ toolId: normalizedToolId,
223
+ producer,
224
+ priority: Number.isFinite(options.priority)
225
+ ? Number(options.priority)
226
+ : 0,
227
+ order: this.producerOrder++,
228
+ };
229
+ this.renderProducers.set(normalizedToolId, entry);
230
+ this.requestRenderFromProducers();
231
+ return {
232
+ dispose: () => {
233
+ this.unregisterRenderProducer(normalizedToolId);
234
+ },
235
+ };
236
+ }
237
+
238
+ unregisterRenderProducer(toolId: string): boolean {
239
+ const normalizedToolId = String(toolId || "").trim();
240
+ if (!normalizedToolId) return false;
241
+ const removed = this.renderProducers.delete(normalizedToolId);
242
+ if (removed) {
243
+ this.requestRenderFromProducers();
244
+ }
245
+ return removed;
246
+ }
247
+
248
+ requestRenderFromProducers() {
249
+ this.producerFlushRequested = true;
250
+ this.scheduleProducerLoop();
251
+ }
252
+
253
+ async flushRenderFromProducers(): Promise<void> {
254
+ this.requestRenderFromProducers();
255
+ if (this.producerLoopPromise) {
256
+ await this.producerLoopPromise;
257
+ }
258
+ }
259
+
260
+ private scheduleProducerLoop() {
261
+ if (this.producerLoopPending) return;
262
+ this.producerLoopPending = true;
263
+ this.producerLoopPromise = Promise.resolve()
264
+ .then(() => this.runProducerLoop())
265
+ .catch((error) => {
266
+ console.error("[CanvasService] render producer loop failed.", error);
267
+ })
268
+ .finally(() => {
269
+ this.producerLoopPending = false;
270
+ if (this.producerFlushRequested) {
271
+ this.scheduleProducerLoop();
272
+ }
273
+ });
274
+ }
275
+
276
+ private async runProducerLoop(): Promise<void> {
277
+ while (this.producerFlushRequested) {
278
+ this.producerFlushRequested = false;
279
+ await this.collectAndApplyProducerSpecs();
280
+ }
281
+ }
282
+
283
+ private sortedRenderProducerEntries(): RenderProducerEntry[] {
284
+ return Array.from(this.renderProducers.values()).sort((a, b) => {
285
+ if (a.priority !== b.priority) {
286
+ return a.priority - b.priority;
287
+ }
288
+ if (a.order !== b.order) {
289
+ return a.order - b.order;
290
+ }
291
+ return a.toolId.localeCompare(b.toolId);
292
+ });
293
+ }
294
+
295
+ private normalizePassSpecValue(
296
+ spec: RenderPassSpec,
297
+ ): ResolvedRenderPassSpec | null {
298
+ const id = String(spec.id || "").trim();
299
+ if (!id) return null;
300
+
301
+ return {
302
+ id,
303
+ stack: Number.isFinite(spec.stack) ? Number(spec.stack) : 0,
304
+ order: Number.isFinite(spec.order) ? Number(spec.order) : 0,
305
+ replace: spec.replace !== false,
306
+ visibility: spec.visibility,
307
+ effects: Array.isArray(spec.effects)
308
+ ? [...spec.effects]
309
+ : [],
310
+ objects: Array.isArray(spec.objects) ? [...spec.objects] : [],
311
+ };
312
+ }
313
+
314
+ private normalizeClipPathEffectSpec(
315
+ effect: RenderEffectSpec,
316
+ passId: string,
317
+ index: number,
318
+ ): ResolvedClipPathEffectSpec | null {
319
+ if (!effect || effect.type !== "clipPath") return null;
320
+
321
+ const source = effect.source;
322
+ if (!source || typeof source !== "object") return null;
323
+
324
+ const sourceId = String(source.id || "").trim();
325
+ if (!sourceId) return null;
326
+
327
+ const targetPassIds = Array.isArray(effect.targetPassIds)
328
+ ? effect.targetPassIds
329
+ .map((item) => String(item || "").trim())
330
+ .filter((item) => item.length > 0)
331
+ : [];
332
+ if (!targetPassIds.length) return null;
333
+
334
+ const customId = String((effect as any).id || "").trim();
335
+ const key = customId || `${passId}.effect.clipPath.${index}`;
336
+
337
+ return {
338
+ type: "clipPath",
339
+ key,
340
+ visibility: effect.visibility,
341
+ source: {
342
+ ...source,
343
+ id: sourceId,
344
+ },
345
+ targetPassIds,
346
+ };
347
+ }
348
+
349
+ private mergePassSpec(
350
+ map: Map<string, ResolvedRenderPassSpec>,
351
+ rawSpec: RenderPassSpec,
352
+ producerId: string,
353
+ ) {
354
+ const normalized = this.normalizePassSpecValue(rawSpec);
355
+ if (!normalized) return;
356
+
357
+ const existing = map.get(normalized.id);
358
+ if (!existing) {
359
+ map.set(normalized.id, normalized);
360
+ return;
361
+ }
362
+
363
+ existing.objects.push(...normalized.objects);
364
+ existing.replace = existing.replace || normalized.replace;
365
+ existing.stack = normalized.stack;
366
+ existing.order = normalized.order;
367
+ if (normalized.visibility !== undefined) {
368
+ existing.visibility = normalized.visibility;
369
+ }
370
+ existing.effects.push(...normalized.effects);
371
+
372
+ if (normalized.objects.length === 0 && normalized.effects.length === 0) {
373
+ console.debug(
374
+ `[CanvasService] pass "${normalized.id}" from producer "${producerId}" updated ordering/visibility only.`,
375
+ );
376
+ }
377
+ }
378
+
379
+ private comparePassMeta(a: ManagedPassMeta, b: ManagedPassMeta): number {
380
+ if (a.stack !== b.stack) return a.stack - b.stack;
381
+ if (a.order !== b.order) return a.order - b.order;
382
+ return a.id.localeCompare(b.id);
383
+ }
384
+
385
+ private getPassObjectOrder(obj: FabricObject): number {
386
+ const raw = Number((obj as any)?.data?.passOrder);
387
+ return Number.isFinite(raw) ? raw : Number.MAX_SAFE_INTEGER;
388
+ }
389
+
390
+ private getPassCanvasObjects(passId: string): FabricObject[] {
391
+ const all = this.canvas.getObjects();
392
+ return all
393
+ .filter((obj: any) => obj?.data?.passId === passId)
394
+ .sort((a, b) => {
395
+ const orderA = this.getPassObjectOrder(a as FabricObject);
396
+ const orderB = this.getPassObjectOrder(b as FabricObject);
397
+ if (orderA !== orderB) return orderA - orderB;
398
+ return all.indexOf(a) - all.indexOf(b);
399
+ });
400
+ }
401
+
402
+ getPassObjects(passId: string): FabricObject[] {
403
+ return this.getPassCanvasObjects(passId);
404
+ }
405
+
406
+ getRootLayerObjects(layerId: string): FabricObject[] {
407
+ return this.getPassCanvasObjects(layerId);
408
+ }
409
+
410
+ private isManagedPassObject(obj: FabricObject): boolean {
411
+ const passId = (obj as any)?.data?.passId;
412
+ return typeof passId === "string" && this.managedPassMetas.has(passId);
413
+ }
414
+
415
+ private syncManagedPassStacking(passes: ManagedPassMeta[]) {
416
+ const orderedPasses = [...passes].sort((a, b) => this.comparePassMeta(a, b));
417
+ if (!orderedPasses.length) return;
418
+
419
+ const canvasObjects = this.canvas.getObjects();
420
+ const managedObjects = canvasObjects.filter((obj: any) =>
421
+ this.isManagedPassObject(obj as FabricObject),
422
+ );
423
+
424
+ if (!managedObjects.length) return;
425
+
426
+ const firstManagedIndex = managedObjects
427
+ .map((obj) => canvasObjects.indexOf(obj as any))
428
+ .filter((index) => index >= 0)
429
+ .reduce((min, value) => Math.min(min, value), Number.MAX_SAFE_INTEGER);
430
+
431
+ let targetIndex = Number.isFinite(firstManagedIndex)
432
+ ? firstManagedIndex
433
+ : 0;
434
+
435
+ orderedPasses.forEach((meta) => {
436
+ const objects = this.getPassCanvasObjects(meta.id);
437
+ objects.forEach((obj) => {
438
+ this.moveObjectInCanvas(obj, targetIndex);
439
+ targetIndex += 1;
440
+ });
441
+ });
442
+ }
443
+
444
+ private getPassRuntimeState(): Map<string, VisibilityLayerState> {
445
+ const state = new Map<string, VisibilityLayerState>();
446
+
447
+ const ensure = (passId: string): VisibilityLayerState => {
448
+ const id = String(passId || "").trim();
449
+ if (!id) return { exists: false, objectCount: 0 };
450
+ let item = state.get(id);
451
+ if (!item) {
452
+ item = { exists: false, objectCount: 0 };
453
+ state.set(id, item);
454
+ }
455
+ return item;
456
+ };
457
+
458
+ this.canvas.getObjects().forEach((obj: any) => {
459
+ const passId = obj?.data?.passId;
460
+ if (typeof passId === "string") {
461
+ const item = ensure(passId);
462
+ item.exists = true;
463
+ item.objectCount += 1;
464
+ }
465
+ });
466
+
467
+ this.managedPassMetas.forEach((meta) => {
468
+ const item = ensure(meta.id);
469
+ item.exists = true;
470
+ });
471
+
472
+ return state;
473
+ }
474
+
475
+ private isSessionActive(toolId: string): boolean {
476
+ if (!this.toolSessionService) return false;
477
+ return this.toolSessionService.getState(toolId).status === "active";
478
+ }
479
+
480
+ private hasAnyActiveSession(): boolean {
481
+ return this.toolSessionService?.hasAnyActiveSession() ?? false;
482
+ }
483
+
484
+ private buildVisibilityEvalContext(
485
+ layers: Map<string, VisibilityLayerState>,
486
+ ) {
487
+ return {
488
+ activeToolId: this.workbenchService?.activeToolId ?? null,
489
+ isSessionActive: (toolId: string) => this.isSessionActive(toolId),
490
+ hasAnyActiveSession: () => this.hasAnyActiveSession(),
491
+ layers,
492
+ };
493
+ }
494
+
495
+ private applyManagedPassVisibility(options: { render?: boolean } = {}): boolean {
496
+ if (!this.managedPassMetas.size) return false;
497
+ const layers = this.getPassRuntimeState();
498
+ const context = this.buildVisibilityEvalContext(layers);
499
+
500
+ let changed = false;
501
+
502
+ this.managedPassMetas.forEach((meta) => {
503
+ const visible = evaluateVisibilityExpr(meta.visibility, context);
504
+ changed = this.setPassVisibility(meta.id, visible) || changed;
505
+ });
506
+
507
+ if (changed && options.render !== false) {
508
+ this.requestRenderAll();
509
+ }
510
+ return changed;
511
+ }
512
+
513
+ private scheduleManagedPassVisibilityRefresh() {
514
+ if (this.visibilityRefreshScheduled) return;
515
+ this.visibilityRefreshScheduled = true;
516
+ void Promise.resolve().then(() => {
517
+ this.visibilityRefreshScheduled = false;
518
+ this.applyManagedPassVisibility();
519
+ });
520
+ }
521
+
522
+ private async collectAndApplyProducerSpecs(): Promise<void> {
523
+ const passes = new Map<string, ResolvedRenderPassSpec>();
524
+ const entries = this.sortedRenderProducerEntries();
525
+
526
+ this.producerApplyInProgress = true;
527
+ try {
528
+ for (const entry of entries) {
529
+ try {
530
+ const result = await entry.producer();
531
+ if (!result) continue;
532
+ const specs = Array.isArray(result.passes) ? result.passes : [];
533
+ specs.forEach((spec) => this.mergePassSpec(passes, spec, entry.toolId));
534
+ } catch (error) {
535
+ console.error(
536
+ `[CanvasService] render producer "${entry.toolId}" failed.`,
537
+ error,
538
+ );
539
+ }
540
+ }
541
+
542
+ const nextPassIds = new Set<string>();
543
+ const nextManagedPassMetas = new Map<string, ManagedPassMeta>();
544
+ const nextEffects: ResolvedClipPathEffectSpec[] = [];
545
+
546
+ for (const pass of passes.values()) {
547
+ nextPassIds.add(pass.id);
548
+ nextManagedPassMetas.set(pass.id, {
549
+ id: pass.id,
550
+ stack: pass.stack,
551
+ order: pass.order,
552
+ visibility: pass.visibility,
553
+ });
554
+
555
+ await this.applyObjectSpecsToPass(pass.id, pass.objects, {
556
+ render: false,
557
+ replace: pass.replace,
558
+ });
559
+
560
+ pass.effects.forEach((effect, index) => {
561
+ const normalized = this.normalizeClipPathEffectSpec(
562
+ effect,
563
+ pass.id,
564
+ index,
565
+ );
566
+ if (!normalized) return;
567
+ nextEffects.push(normalized);
568
+ });
569
+ }
570
+
571
+ for (const passId of this.managedProducerPassIds) {
572
+ if (nextPassIds.has(passId)) continue;
573
+ await this.applyObjectSpecsToPass(passId, [], {
574
+ render: false,
575
+ replace: true,
576
+ });
577
+ }
578
+
579
+ this.managedProducerPassIds = nextPassIds;
580
+ this.managedPassMetas = nextManagedPassMetas;
581
+ this.managedPassEffects = nextEffects;
582
+
583
+ this.syncManagedPassStacking(Array.from(nextManagedPassMetas.values()));
584
+ await this.applyManagedPassEffects(nextEffects, { render: false });
585
+ this.applyManagedPassVisibility({ render: false });
586
+ } finally {
587
+ this.producerApplyInProgress = false;
588
+ }
589
+
590
+ this.requestRenderAll();
591
+ }
592
+
593
+ private async applyManagedPassEffects(
594
+ effects: ResolvedClipPathEffectSpec[] = this.managedPassEffects,
595
+ options: { render?: boolean } = {},
596
+ ) {
597
+ const effectTargetMap = new Map<FabricObject, ResolvedClipPathEffectSpec>();
598
+ const layers = this.getPassRuntimeState();
599
+ const visibilityContext = this.buildVisibilityEvalContext(layers);
600
+
601
+ for (const effect of effects) {
602
+ if (effect.type !== "clipPath") continue;
603
+ if (!evaluateVisibilityExpr(effect.visibility, visibilityContext)) {
604
+ continue;
605
+ }
606
+ effect.targetPassIds.forEach((targetPassId) => {
607
+ this.getPassCanvasObjects(targetPassId).forEach((obj) => {
608
+ effectTargetMap.set(obj, effect);
609
+ });
610
+ });
611
+ }
612
+
613
+ const managedObjects = this.canvas.getObjects().filter((obj: any) =>
614
+ this.isManagedPassObject(obj as FabricObject),
615
+ ) as FabricObject[];
616
+
617
+ const effectTemplateCache = new Map<string, FabricObject | null>();
618
+
619
+ for (const obj of managedObjects) {
620
+ const targetEffect = effectTargetMap.get(obj);
621
+ if (!targetEffect) {
622
+ this.clearClipPathEffectFromObject(obj as any);
623
+ continue;
624
+ }
625
+
626
+ let template = effectTemplateCache.get(targetEffect.key);
627
+ if (template === undefined) {
628
+ template = await this.createClipPathTemplate(targetEffect);
629
+ effectTemplateCache.set(targetEffect.key, template);
630
+ }
631
+
632
+ if (!template) {
633
+ this.clearClipPathEffectFromObject(obj as any);
634
+ continue;
635
+ }
636
+
637
+ await this.applyClipPathEffectToObject(
638
+ obj as any,
639
+ template,
640
+ targetEffect.key,
641
+ );
642
+ }
643
+
644
+ if (options.render !== false) {
645
+ this.requestRenderAll();
646
+ }
647
+ }
648
+
649
+ getObject(id: string, passId?: string): FabricObject | undefined {
650
+ const normalizedId = String(id || "").trim();
651
+ if (!normalizedId) return undefined;
652
+
653
+ return this.canvas.getObjects().find((obj: any) => {
654
+ if (obj?.data?.id !== normalizedId) return false;
655
+ if (!passId) return true;
656
+ return obj?.data?.passId === passId;
657
+ }) as FabricObject | undefined;
658
+ }
659
+
660
+ requestRenderAll() {
661
+ this.canvas.requestRenderAll();
662
+ }
663
+
664
+ resize(width: number, height: number) {
665
+ this.canvas.setDimensions({ width, height });
666
+ this.viewport.updateContainer(width, height);
667
+ this.eventBus?.emit("canvas:resized", { width, height });
668
+ this.requestRenderAll();
669
+ }
670
+
671
+ getSceneScale(): number {
672
+ const scale = Number(this.viewport.scale);
673
+ return Number.isFinite(scale) && scale > 0 ? scale : 1;
674
+ }
675
+
676
+ getSceneOffset(): { x: number; y: number } {
677
+ const offset = this.viewport.offset;
678
+ const x = Number(offset.x);
679
+ const y = Number(offset.y);
680
+ return {
681
+ x: Number.isFinite(x) ? x : 0,
682
+ y: Number.isFinite(y) ? y : 0,
683
+ };
684
+ }
685
+
686
+ toScreenPoint(point: { x: number; y: number }): { x: number; y: number } {
687
+ const scale = this.getSceneScale();
688
+ const offset = this.getSceneOffset();
689
+ return {
690
+ x: point.x * scale + offset.x,
691
+ y: point.y * scale + offset.y,
692
+ };
693
+ }
694
+
695
+ toScenePoint(point: { x: number; y: number }): { x: number; y: number } {
696
+ const scale = this.getSceneScale();
697
+ const offset = this.getSceneOffset();
698
+ return {
699
+ x: (point.x - offset.x) / scale,
700
+ y: (point.y - offset.y) / scale,
701
+ };
702
+ }
703
+
704
+ toScreenLength(value: number): number {
705
+ return value * this.getSceneScale();
706
+ }
707
+
708
+ toSceneLength(value: number): number {
709
+ return value / this.getSceneScale();
710
+ }
711
+
712
+ toScreenRect(rect: {
713
+ left: number;
714
+ top: number;
715
+ width: number;
716
+ height: number;
717
+ }): { left: number; top: number; width: number; height: number } {
718
+ const start = this.toScreenPoint({ x: rect.left, y: rect.top });
719
+ return {
720
+ left: start.x,
721
+ top: start.y,
722
+ width: this.toScreenLength(rect.width),
723
+ height: this.toScreenLength(rect.height),
724
+ };
725
+ }
726
+
727
+ toSceneRect(rect: {
728
+ left: number;
729
+ top: number;
730
+ width: number;
731
+ height: number;
732
+ }): { left: number; top: number; width: number; height: number } {
733
+ const start = this.toScenePoint({ x: rect.left, y: rect.top });
734
+ return {
735
+ left: start.x,
736
+ top: start.y,
737
+ width: this.toSceneLength(rect.width),
738
+ height: this.toSceneLength(rect.height),
739
+ };
740
+ }
741
+
742
+ getSceneViewportRect(): {
743
+ left: number;
744
+ top: number;
745
+ width: number;
746
+ height: number;
747
+ } {
748
+ const width = Number(this.canvas.width || 0);
749
+ const height = Number(this.canvas.height || 0);
750
+ return this.toSceneRect({ left: 0, top: 0, width, height });
751
+ }
752
+
753
+ getScreenViewportRect(): RectLike {
754
+ return {
755
+ left: 0,
756
+ top: 0,
757
+ width: Number(this.canvas.width || 0),
758
+ height: Number(this.canvas.height || 0),
759
+ };
760
+ }
761
+
762
+ private toSpaceRect(
763
+ rect: RectLike,
764
+ from: RenderCoordinateSpace,
765
+ to: RenderCoordinateSpace,
766
+ ): RectLike {
767
+ if (from === to) return { ...rect };
768
+ if (from === "scene") {
769
+ return this.toScreenRect(rect);
770
+ }
771
+ return this.toSceneRect(rect);
772
+ }
773
+
774
+ private resolveLayoutLength(
775
+ value: RenderLayoutLength | undefined,
776
+ base: number,
777
+ ): number | undefined {
778
+ if (typeof value === "number") {
779
+ return Number.isFinite(value) ? value : undefined;
780
+ }
781
+ if (typeof value !== "string") {
782
+ return undefined;
783
+ }
784
+ const raw = value.trim();
785
+ if (!raw) return undefined;
786
+ if (raw.endsWith("%")) {
787
+ const percent = parseFloat(raw.slice(0, -1));
788
+ if (!Number.isFinite(percent)) return undefined;
789
+ return (base * percent) / 100;
790
+ }
791
+ const parsed = parseFloat(raw);
792
+ return Number.isFinite(parsed) ? parsed : undefined;
793
+ }
794
+
795
+ private resolveLayoutInsets(
796
+ inset: RenderLayoutLength | RenderLayoutInsets | undefined,
797
+ reference: RectLike,
798
+ ): { top: number; right: number; bottom: number; left: number } {
799
+ if (typeof inset === "number" || typeof inset === "string") {
800
+ const all =
801
+ this.resolveLayoutLength(
802
+ inset,
803
+ Math.min(reference.width, reference.height),
804
+ ) ?? 0;
805
+ return { top: all, right: all, bottom: all, left: all };
806
+ }
807
+
808
+ const source = inset || {};
809
+ const top = this.resolveLayoutLength(source.top, reference.height) ?? 0;
810
+ const right = this.resolveLayoutLength(source.right, reference.width) ?? 0;
811
+ const bottom =
812
+ this.resolveLayoutLength(source.bottom, reference.height) ?? 0;
813
+ const left = this.resolveLayoutLength(source.left, reference.width) ?? 0;
814
+ return { top, right, bottom, left };
815
+ }
816
+
817
+ private resolveLayoutReferenceRect(
818
+ layout: RenderObjectLayoutSpec,
819
+ space: RenderCoordinateSpace,
820
+ ): RectLike {
821
+ if (layout.referenceRect) {
822
+ const sourceSpace: RenderCoordinateSpace =
823
+ layout.referenceRect.space || space;
824
+ return this.toSpaceRect(layout.referenceRect, sourceSpace, space);
825
+ }
826
+
827
+ const reference = layout.reference || "sceneViewport";
828
+ if (reference === "screenViewport") {
829
+ const screenRect = this.getScreenViewportRect();
830
+ return space === "screen" ? screenRect : this.toSceneRect(screenRect);
831
+ }
832
+
833
+ const sceneRect = this.getSceneViewportRect();
834
+ return space === "scene" ? sceneRect : this.toScreenRect(sceneRect);
835
+ }
836
+
837
+ private alignFactor(value: unknown): number {
838
+ if (value === "end") return 1;
839
+ if (value === "center") return 0.5;
840
+ return 0;
841
+ }
842
+
843
+ private normalizeOriginX(value: unknown): "left" | "center" | "right" {
844
+ if (value === "center") return "center";
845
+ if (value === "right") return "right";
846
+ return "left";
847
+ }
848
+
849
+ private normalizeOriginY(value: unknown): "top" | "center" | "bottom" {
850
+ if (value === "center") return "center";
851
+ if (value === "bottom") return "bottom";
852
+ return "top";
853
+ }
854
+
855
+ private originFactor(
856
+ value: "left" | "center" | "right" | "top" | "bottom",
857
+ ): number {
858
+ if (value === "center") return 0.5;
859
+ if (value === "right" || value === "bottom") return 1;
860
+ return 0;
861
+ }
862
+
863
+ private resolveLayoutProps(
864
+ spec: RenderObjectSpec,
865
+ props: Record<string, any>,
866
+ ): Record<string, any> {
867
+ const layout = spec.layout;
868
+ if (!layout) {
869
+ return { ...props };
870
+ }
871
+
872
+ const space: RenderCoordinateSpace = spec.space || "scene";
873
+ const reference = this.resolveLayoutReferenceRect(layout, space);
874
+ const inset = this.resolveLayoutInsets(layout.inset, reference);
875
+ const area: RectLike = {
876
+ left: reference.left + inset.left,
877
+ top: reference.top + inset.top,
878
+ width: Math.max(0, reference.width - inset.left - inset.right),
879
+ height: Math.max(0, reference.height - inset.top - inset.bottom),
880
+ };
881
+
882
+ const next = { ...props };
883
+ const width =
884
+ this.resolveLayoutLength(layout.width, area.width) ??
885
+ (Number.isFinite(next.width) ? Number(next.width) : undefined);
886
+ const height =
887
+ this.resolveLayoutLength(layout.height, area.height) ??
888
+ (Number.isFinite(next.height) ? Number(next.height) : undefined);
889
+
890
+ if (width !== undefined) next.width = width;
891
+ if (height !== undefined) next.height = height;
892
+
893
+ const alignX = this.alignFactor(layout.alignX);
894
+ const alignY = this.alignFactor(layout.alignY);
895
+ const offsetX = this.resolveLayoutLength(layout.offsetX, area.width) ?? 0;
896
+ const offsetY = this.resolveLayoutLength(layout.offsetY, area.height) ?? 0;
897
+ const objectWidth = Number.isFinite(next.width) ? Number(next.width) : 0;
898
+ const objectHeight = Number.isFinite(next.height) ? Number(next.height) : 0;
899
+
900
+ const objectLeft =
901
+ area.left + (area.width - objectWidth) * alignX + offsetX;
902
+ const objectTop =
903
+ area.top + (area.height - objectHeight) * alignY + offsetY;
904
+
905
+ const originX = this.normalizeOriginX(next.originX);
906
+ const originY = this.normalizeOriginY(next.originY);
907
+ next.left = objectLeft + objectWidth * this.originFactor(originX);
908
+ next.top = objectTop + objectHeight * this.originFactor(originY);
909
+ return next;
910
+ }
911
+
912
+ setPassVisibility(passId: string, visible: boolean): boolean {
913
+ const objects = this.getPassCanvasObjects(passId) as any[];
914
+ let changed = false;
915
+
916
+ objects.forEach((obj) => {
917
+ if (obj.visible === visible) return;
918
+ obj.set?.({ visible });
919
+ obj.setCoords?.();
920
+ changed = true;
921
+ });
922
+
923
+ return changed;
924
+ }
925
+
926
+ setLayerVisibility(layerId: string, visible: boolean): boolean {
927
+ return this.setPassVisibility(layerId, visible);
928
+ }
929
+
930
+ bringPassToFront(passId: string) {
931
+ const objects = this.getPassCanvasObjects(passId) as any[];
932
+ objects.forEach((obj) => this.canvas.bringObjectToFront(obj as any));
933
+ }
934
+
935
+ bringLayerToFront(layerId: string) {
936
+ this.bringPassToFront(layerId);
937
+ }
938
+
939
+ async applyPassSpec(
940
+ spec: RenderPassSpec,
941
+ options: { render?: boolean } = {},
942
+ ): Promise<void> {
943
+ await this.applyObjectSpecsToPass(spec.id, spec.objects, {
944
+ render: options.render,
945
+ replace: spec.replace !== false,
946
+ });
947
+ }
948
+
949
+ async applyObjectSpecsToRootLayer(
950
+ passId: string,
951
+ specs: RenderObjectSpec[],
952
+ options: { render?: boolean } = {},
953
+ ): Promise<void> {
954
+ await this.applyObjectSpecsToPass(passId, specs, {
955
+ render: options.render,
956
+ replace: true,
957
+ });
958
+ }
959
+
960
+ private normalizeObjectSpecs(specs: RenderObjectSpec[]): RenderObjectSpec[] {
961
+ const seen = new Set<string>();
962
+ const normalized: RenderObjectSpec[] = [];
963
+
964
+ (specs || []).forEach((spec) => {
965
+ const id = String(spec?.id || "").trim();
966
+ if (!id || seen.has(id)) return;
967
+ seen.add(id);
968
+ normalized.push({
969
+ ...spec,
970
+ id,
971
+ });
972
+ });
973
+
974
+ return normalized;
975
+ }
976
+
977
+ private async cloneFabricObject(
978
+ source: FabricObject,
979
+ ): Promise<FabricObject | undefined> {
980
+ const clone = (source as any).clone;
981
+ if (typeof clone !== "function") return undefined;
982
+
983
+ const result = clone.call(source);
984
+ if (!result || typeof result.then !== "function") {
985
+ return undefined;
986
+ }
987
+
988
+ try {
989
+ const copied = (await result) as FabricObject;
990
+ return copied;
991
+ } catch {
992
+ return undefined;
993
+ }
994
+ }
995
+
996
+ private async createClipPathTemplate(
997
+ effect: ResolvedClipPathEffectSpec,
998
+ ): Promise<FabricObject | null> {
999
+ const source = effect.source;
1000
+ const sourceId = String(source.id || "").trim();
1001
+ if (!sourceId) return null;
1002
+
1003
+ const template = await this.createFabricObject({
1004
+ ...source,
1005
+ id: sourceId,
1006
+ data: {
1007
+ ...(source.data || {}),
1008
+ id: sourceId,
1009
+ type: "clip-path-effect-template",
1010
+ effectKey: effect.key,
1011
+ },
1012
+ props: {
1013
+ ...(source.props || {}),
1014
+ selectable: false,
1015
+ evented: false,
1016
+ excludeFromExport: true,
1017
+ },
1018
+ });
1019
+ if (!template) return null;
1020
+
1021
+ (template as any).set?.({
1022
+ selectable: false,
1023
+ evented: false,
1024
+ excludeFromExport: true,
1025
+ absolutePositioned: true,
1026
+ });
1027
+ (template as any).setCoords?.();
1028
+ return template;
1029
+ }
1030
+
1031
+ private isClipPathEffectManaged(target: any): boolean {
1032
+ return typeof target?.__pooderEffectClipKey === "string";
1033
+ }
1034
+
1035
+ private clearClipPathEffectFromObject(target: any) {
1036
+ if (!target) return;
1037
+ if (!this.isClipPathEffectManaged(target)) return;
1038
+ target.set?.({ clipPath: undefined });
1039
+ target.setCoords?.();
1040
+ delete target.__pooderEffectClipKey;
1041
+ }
1042
+
1043
+ private async applyClipPathEffectToObject(
1044
+ target: any,
1045
+ clipTemplate: FabricObject,
1046
+ effectKey: string,
1047
+ ) {
1048
+ if (!target) return;
1049
+
1050
+ const clipPath = await this.cloneFabricObject(clipTemplate);
1051
+ if (!clipPath) {
1052
+ this.clearClipPathEffectFromObject(target);
1053
+ return;
1054
+ }
1055
+
1056
+ (clipPath as any).set?.({
1057
+ selectable: false,
1058
+ evented: false,
1059
+ excludeFromExport: true,
1060
+ absolutePositioned: true,
1061
+ });
1062
+ (clipPath as any).setCoords?.();
1063
+
1064
+ target.set?.({ clipPath });
1065
+ target.setCoords?.();
1066
+ target.__pooderEffectClipKey = effectKey;
1067
+ }
1068
+
1069
+ async applyObjectSpecsToPass(
1070
+ passId: string,
1071
+ specs: RenderObjectSpec[],
1072
+ options: {
1073
+ render?: boolean;
1074
+ replace?: boolean;
1075
+ } = {},
1076
+ ): Promise<void> {
1077
+ const normalizedPassId = String(passId || "").trim();
1078
+ if (!normalizedPassId) return;
1079
+
1080
+ const replace = options.replace !== false;
1081
+ const normalizedSpecs = this.normalizeObjectSpecs(specs);
1082
+ const desiredIds = new Set(normalizedSpecs.map((s) => s.id));
1083
+
1084
+ const existing = this.getPassCanvasObjects(normalizedPassId) as any[];
1085
+ if (replace) {
1086
+ existing.forEach((obj) => {
1087
+ const id = obj?.data?.id;
1088
+ if (typeof id === "string" && !desiredIds.has(id)) {
1089
+ this.canvas.remove(obj);
1090
+ }
1091
+ });
1092
+ }
1093
+
1094
+ const byId = new Map<string, any>();
1095
+ this.getPassCanvasObjects(normalizedPassId).forEach((obj: any) => {
1096
+ const id = obj?.data?.id;
1097
+ if (typeof id === "string") byId.set(id, obj);
1098
+ });
1099
+
1100
+ for (let index = 0; index < normalizedSpecs.length; index += 1) {
1101
+ const spec = normalizedSpecs[index];
1102
+ let current = byId.get(spec.id);
1103
+
1104
+ if (spec.type === "path") {
1105
+ const nextPathData = this.readPathDataFromSpec(spec);
1106
+ if (!nextPathData || !nextPathData.trim()) {
1107
+ if (current) {
1108
+ this.canvas.remove(current);
1109
+ byId.delete(spec.id);
1110
+ }
1111
+ continue;
1112
+ }
1113
+ }
1114
+
1115
+ if (current && this.shouldRecreateObject(current, spec)) {
1116
+ this.canvas.remove(current);
1117
+ byId.delete(spec.id);
1118
+ current = undefined;
1119
+ }
1120
+
1121
+ if (!current) {
1122
+ const created = await this.createFabricObject(spec);
1123
+ if (!created) continue;
1124
+ this.patchFabricObject(created as any, spec, {
1125
+ passId: normalizedPassId,
1126
+ layerId: normalizedPassId,
1127
+ passOrder: index,
1128
+ });
1129
+ this.canvas.add(created as any);
1130
+ byId.set(spec.id, created);
1131
+ continue;
1132
+ }
1133
+
1134
+ this.patchFabricObject(current, spec, {
1135
+ passId: normalizedPassId,
1136
+ layerId: normalizedPassId,
1137
+ passOrder: index,
1138
+ });
1139
+ }
1140
+
1141
+ if (options.render !== false) {
1142
+ this.requestRenderAll();
1143
+ }
1144
+ }
1145
+
1146
+ private patchFabricObject(
1147
+ obj: any,
1148
+ spec: RenderObjectSpec,
1149
+ extraData?: Record<string, any>,
1150
+ ) {
1151
+ const nextData = {
1152
+ ...(obj.data || {}),
1153
+ ...(spec.data || {}),
1154
+ ...(extraData || {}),
1155
+ id: spec.id,
1156
+ };
1157
+ nextData.__renderSourceKey = this.getSpecRenderSourceKey(spec);
1158
+ const props = this.resolveFabricProps(spec, spec.props || {});
1159
+ obj.set({ ...props, data: nextData });
1160
+ obj.setCoords();
1161
+ }
1162
+
1163
+ private readPathDataFromSpec(spec: RenderObjectSpec): string | undefined {
1164
+ if (spec.type !== "path") return undefined;
1165
+ const raw =
1166
+ (spec.props as any)?.path ||
1167
+ (spec.props as any)?.pathData;
1168
+ if (typeof raw !== "string") return undefined;
1169
+ return raw;
1170
+ }
1171
+
1172
+ private hashText(value: string): string {
1173
+ let hash = 2166136261;
1174
+ for (let i = 0; i < value.length; i += 1) {
1175
+ hash ^= value.charCodeAt(i);
1176
+ hash +=
1177
+ (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
1178
+ }
1179
+ return (hash >>> 0).toString(16);
1180
+ }
1181
+
1182
+ private getSpecRenderSourceKey(spec: RenderObjectSpec): string {
1183
+ switch (spec.type) {
1184
+ case "path": {
1185
+ const pathData = this.readPathDataFromSpec(spec) || "";
1186
+ return `path:${this.hashText(pathData)}`;
1187
+ }
1188
+ case "image":
1189
+ return `image:${String(spec.src || "")}`;
1190
+ case "text":
1191
+ return `text:${String((spec.props as any)?.text ?? "")}`;
1192
+ case "rect":
1193
+ return "rect";
1194
+ default:
1195
+ return String(spec.type || "");
1196
+ }
1197
+ }
1198
+
1199
+ private shouldRecreateObject(current: any, spec: RenderObjectSpec): boolean {
1200
+ if (!current) return true;
1201
+
1202
+ const currentType = String(current?.type || "").toLowerCase();
1203
+ if (currentType !== spec.type) return true;
1204
+
1205
+ const expectedKey = this.getSpecRenderSourceKey(spec);
1206
+ const currentKey = String(current?.data?.__renderSourceKey || "");
1207
+ if (currentKey && expectedKey && currentKey !== expectedKey) return true;
1208
+
1209
+ if (spec.type === "image" && spec.src && current.getSrc) {
1210
+ return current.getSrc() !== spec.src;
1211
+ }
1212
+
1213
+ return false;
1214
+ }
1215
+
1216
+ private resolveFabricProps(
1217
+ spec: RenderObjectSpec,
1218
+ props: Record<string, any>,
1219
+ ): Record<string, any> {
1220
+ const space: RenderCoordinateSpace = spec.space || "scene";
1221
+ const next = this.resolveLayoutProps(spec, props);
1222
+ if (space === "screen") {
1223
+ return next;
1224
+ }
1225
+
1226
+ const hasLeft = Number.isFinite(next.left);
1227
+ const hasTop = Number.isFinite(next.top);
1228
+ if (hasLeft || hasTop) {
1229
+ const mapped = this.toScreenPoint({
1230
+ x: hasLeft ? Number(next.left) : 0,
1231
+ y: hasTop ? Number(next.top) : 0,
1232
+ });
1233
+ if (hasLeft) next.left = mapped.x;
1234
+ if (hasTop) next.top = mapped.y;
1235
+ }
1236
+
1237
+ const rawScaleX = Number.isFinite(next.scaleX) ? Number(next.scaleX) : 1;
1238
+ const rawScaleY = Number.isFinite(next.scaleY) ? Number(next.scaleY) : 1;
1239
+ const sceneScale = this.getSceneScale();
1240
+ next.scaleX = rawScaleX * sceneScale;
1241
+ next.scaleY = rawScaleY * sceneScale;
1242
+ return next;
1243
+ }
1244
+
1245
+ private moveObjectInCanvas(obj: any, index: number) {
1246
+ if (!obj) return;
1247
+
1248
+ const moveObjectTo = (this.canvas as any).moveObjectTo;
1249
+ if (typeof moveObjectTo === "function") {
1250
+ moveObjectTo.call(this.canvas, obj, index);
1251
+ return;
1252
+ }
1253
+
1254
+ const list = (this.canvas as any)._objects as any[] | undefined;
1255
+ if (!Array.isArray(list)) return;
1256
+ const from = list.indexOf(obj);
1257
+ if (from < 0 || from === index) return;
1258
+
1259
+ list.splice(from, 1);
1260
+ const target = Math.max(0, Math.min(index, list.length));
1261
+ list.splice(target, 0, obj);
1262
+ if (typeof (this.canvas as any)._onStackOrderChanged === "function") {
1263
+ (this.canvas as any)._onStackOrderChanged();
1264
+ }
1265
+ }
1266
+
1267
+ private async createFabricObject(
1268
+ spec: RenderObjectSpec,
1269
+ ): Promise<FabricObject | undefined> {
1270
+ if (spec.type === "rect") {
1271
+ const props = this.resolveFabricProps(spec, spec.props || {});
1272
+ const rect = new Rect({
1273
+ ...props,
1274
+ data: { ...(spec.data || {}), id: spec.id },
1275
+ } as any);
1276
+ rect.setCoords();
1277
+ return rect;
1278
+ }
1279
+
1280
+ if (spec.type === "path") {
1281
+ const pathData = this.readPathDataFromSpec(spec);
1282
+ if (!pathData) return undefined;
1283
+ const props = this.resolveFabricProps(spec, spec.props || {});
1284
+ const path = new Path(pathData, {
1285
+ ...props,
1286
+ data: { ...(spec.data || {}), id: spec.id },
1287
+ } as any);
1288
+ path.setCoords();
1289
+ return path;
1290
+ }
1291
+
1292
+ if (spec.type === "image") {
1293
+ if (!spec.src) return undefined;
1294
+ const image = await Image.fromURL(spec.src, { crossOrigin: "anonymous" });
1295
+ const props = this.resolveFabricProps(spec, spec.props || {});
1296
+ image.set({
1297
+ ...props,
1298
+ data: { ...(spec.data || {}), id: spec.id },
1299
+ } as any);
1300
+ image.setCoords();
1301
+ return image as any;
1302
+ }
1303
+
1304
+ if (spec.type === "text") {
1305
+ const content = String((spec.props as any)?.text ?? "");
1306
+ const props = this.resolveFabricProps(spec, spec.props || {});
1307
+ const text = new Text(content, {
1308
+ ...props,
1309
+ data: { ...(spec.data || {}), id: spec.id },
1310
+ } as any);
1311
+ text.setCoords();
1312
+ return text as any;
1313
+ }
1314
+
1315
+ return undefined;
1316
+ }
1317
+ }