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