@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
@@ -4,29 +4,268 @@ import {
4
4
  ContributionPointIds,
5
5
  CommandContribution,
6
6
  ConfigurationContribution,
7
+ ConfigurationService,
7
8
  } from "@pooder/core";
8
- import { Rect, FabricImage as Image } from "fabric";
9
- import { CanvasService } from "../services";
9
+ import { FabricImage } from "fabric";
10
+ import { CanvasService, RenderObjectSpec } from "../services";
11
+ import {
12
+ computeSceneLayout,
13
+ readSizeState,
14
+ type SceneLayoutSnapshot,
15
+ } from "./sceneLayoutModel";
16
+
17
+ interface SourceSize {
18
+ width: number;
19
+ height: number;
20
+ }
21
+
22
+ interface Rect {
23
+ left: number;
24
+ top: number;
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ export type BackgroundLayerKind = "color" | "image";
30
+ export type BackgroundFitMode = "cover" | "contain" | "stretch";
31
+
32
+ export interface BackgroundLayer {
33
+ id: string;
34
+ kind: BackgroundLayerKind;
35
+ anchor: string;
36
+ fit: BackgroundFitMode;
37
+ opacity: number;
38
+ order: number;
39
+ enabled: boolean;
40
+ exportable: boolean;
41
+ color?: string;
42
+ src?: string;
43
+ }
44
+
45
+ export interface BackgroundConfig {
46
+ version: number;
47
+ layers: BackgroundLayer[];
48
+ }
49
+
50
+ const BACKGROUND_LAYER_ID = "background";
51
+ const BACKGROUND_CONFIG_KEY = "background.config";
52
+
53
+ const DEFAULT_WIDTH = 800;
54
+ const DEFAULT_HEIGHT = 600;
55
+
56
+ const DEFAULT_BACKGROUND_CONFIG: BackgroundConfig = {
57
+ version: 1,
58
+ layers: [
59
+ {
60
+ id: "base-color",
61
+ kind: "color",
62
+ anchor: "viewport",
63
+ fit: "cover",
64
+ opacity: 1,
65
+ order: 0,
66
+ enabled: true,
67
+ exportable: false,
68
+ color: "#fff",
69
+ },
70
+ ],
71
+ };
72
+
73
+ function clampOpacity(value: unknown, fallback: number): number {
74
+ const numeric = Number(value);
75
+ if (!Number.isFinite(numeric)) {
76
+ return Math.max(0, Math.min(1, fallback));
77
+ }
78
+ return Math.max(0, Math.min(1, numeric));
79
+ }
80
+
81
+ function normalizeLayerKind(
82
+ value: unknown,
83
+ fallback: BackgroundLayerKind,
84
+ ): BackgroundLayerKind {
85
+ if (value === "color" || value === "image") {
86
+ return value;
87
+ }
88
+ return fallback;
89
+ }
90
+
91
+ function normalizeFitMode(
92
+ value: unknown,
93
+ fallback: BackgroundFitMode,
94
+ ): BackgroundFitMode {
95
+ if (value === "contain" || value === "cover" || value === "stretch") {
96
+ return value;
97
+ }
98
+ return fallback;
99
+ }
100
+
101
+ function normalizeAnchor(value: unknown, fallback: string): string {
102
+ if (typeof value !== "string") return fallback;
103
+ const trimmed = value.trim();
104
+ return trimmed || fallback;
105
+ }
106
+
107
+ function normalizeOrder(value: unknown, fallback: number): number {
108
+ const numeric = Number(value);
109
+ if (!Number.isFinite(numeric)) return fallback;
110
+ return numeric;
111
+ }
112
+
113
+ function normalizeLayer(
114
+ raw: unknown,
115
+ index: number,
116
+ fallback?: BackgroundLayer,
117
+ ): BackgroundLayer {
118
+ const fallbackLayer: BackgroundLayer = fallback || {
119
+ id: `layer-${index + 1}`,
120
+ kind: "image",
121
+ anchor: "viewport",
122
+ fit: "contain",
123
+ opacity: 1,
124
+ order: index,
125
+ enabled: true,
126
+ exportable: false,
127
+ src: "",
128
+ };
129
+
130
+ if (!raw || typeof raw !== "object") {
131
+ return { ...fallbackLayer };
132
+ }
133
+
134
+ const input = raw as Partial<BackgroundLayer>;
135
+ const kind = normalizeLayerKind(input.kind, fallbackLayer.kind);
136
+ return {
137
+ id:
138
+ typeof input.id === "string" && input.id.trim().length > 0
139
+ ? input.id.trim()
140
+ : fallbackLayer.id,
141
+ kind,
142
+ anchor: normalizeAnchor(input.anchor, fallbackLayer.anchor),
143
+ fit: normalizeFitMode(input.fit, fallbackLayer.fit),
144
+ opacity: clampOpacity(input.opacity, fallbackLayer.opacity),
145
+ order: normalizeOrder(input.order, fallbackLayer.order),
146
+ enabled:
147
+ typeof input.enabled === "boolean"
148
+ ? input.enabled
149
+ : fallbackLayer.enabled,
150
+ exportable:
151
+ typeof input.exportable === "boolean"
152
+ ? input.exportable
153
+ : fallbackLayer.exportable,
154
+ color:
155
+ kind === "color"
156
+ ? typeof input.color === "string"
157
+ ? input.color
158
+ : typeof fallbackLayer.color === "string"
159
+ ? fallbackLayer.color
160
+ : "#ffffff"
161
+ : undefined,
162
+ src:
163
+ kind === "image"
164
+ ? typeof input.src === "string"
165
+ ? input.src.trim()
166
+ : typeof fallbackLayer.src === "string"
167
+ ? fallbackLayer.src
168
+ : ""
169
+ : undefined,
170
+ };
171
+ }
172
+
173
+ function normalizeConfig(raw: unknown): BackgroundConfig {
174
+ if (!raw || typeof raw !== "object") {
175
+ return cloneConfig(DEFAULT_BACKGROUND_CONFIG);
176
+ }
177
+
178
+ const input = raw as Partial<BackgroundConfig>;
179
+ const version = Number.isFinite(Number(input.version))
180
+ ? Number(input.version)
181
+ : DEFAULT_BACKGROUND_CONFIG.version;
182
+
183
+ const baseLayers = Array.isArray(input.layers)
184
+ ? input.layers.map((layer, index) => normalizeLayer(layer, index))
185
+ : cloneConfig(DEFAULT_BACKGROUND_CONFIG).layers;
186
+
187
+ const uniqueLayers: BackgroundLayer[] = [];
188
+ const seen = new Set<string>();
189
+
190
+ baseLayers.forEach((layer, index) => {
191
+ let nextId = layer.id || `layer-${index + 1}`;
192
+ let serial = 1;
193
+ while (seen.has(nextId)) {
194
+ serial += 1;
195
+ nextId = `${layer.id || `layer-${index + 1}`}-${serial}`;
196
+ }
197
+ seen.add(nextId);
198
+ uniqueLayers.push({ ...layer, id: nextId });
199
+ });
200
+
201
+ return {
202
+ version,
203
+ layers: uniqueLayers,
204
+ };
205
+ }
206
+
207
+ function cloneConfig(config: BackgroundConfig): BackgroundConfig {
208
+ return {
209
+ version: config.version,
210
+ layers: (config.layers || []).map((layer) => ({ ...layer })),
211
+ };
212
+ }
213
+
214
+ function mergeConfig(base: BackgroundConfig, patch: Partial<BackgroundConfig>) {
215
+ const merged: BackgroundConfig = {
216
+ version:
217
+ patch.version === undefined
218
+ ? base.version
219
+ : Number.isFinite(Number(patch.version))
220
+ ? Number(patch.version)
221
+ : base.version,
222
+ layers: Array.isArray(patch.layers)
223
+ ? patch.layers.map((layer, index) => normalizeLayer(layer, index))
224
+ : base.layers.map((layer) => ({ ...layer })),
225
+ };
226
+
227
+ return normalizeConfig(merged);
228
+ }
229
+
230
+ function configSignature(config: BackgroundConfig): string {
231
+ return JSON.stringify(config);
232
+ }
10
233
 
11
234
  export class BackgroundTool implements Extension {
12
235
  id = "pooder.kit.background";
236
+
13
237
  public metadata = {
14
238
  name: "BackgroundTool",
15
239
  };
16
240
 
17
- private color: string = "";
18
- private url: string = "";
241
+ private config: BackgroundConfig = cloneConfig(DEFAULT_BACKGROUND_CONFIG);
19
242
 
20
243
  private canvasService?: CanvasService;
244
+ private configService?: ConfigurationService;
245
+
246
+ private specs: RenderObjectSpec[] = [];
247
+ private renderProducerDisposable?: { dispose: () => void };
248
+ private configChangeDisposable?: { dispose: () => void };
249
+
250
+ private renderSeq = 0;
251
+ private latestSceneLayout: SceneLayoutSnapshot | null = null;
21
252
 
22
- constructor(
23
- options?: Partial<{
24
- color: string;
25
- url: string;
26
- }>,
27
- ) {
28
- if (options) {
29
- Object.assign(this, options);
253
+ private sourceSizeBySrc: Map<string, SourceSize> = new Map();
254
+ private pendingSizeBySrc: Map<string, Promise<SourceSize | null>> = new Map();
255
+
256
+ private onCanvasResized = () => {
257
+ this.latestSceneLayout = null;
258
+ this.updateBackground();
259
+ };
260
+
261
+ private onSceneLayoutChanged = (layout: SceneLayoutSnapshot) => {
262
+ this.latestSceneLayout = layout;
263
+ this.updateBackground();
264
+ };
265
+
266
+ constructor(options?: Partial<BackgroundConfig>) {
267
+ if (options && typeof options === "object") {
268
+ this.config = mergeConfig(this.config, options);
30
269
  }
31
270
  }
32
271
 
@@ -37,100 +276,167 @@ export class BackgroundTool implements Extension {
37
276
  return;
38
277
  }
39
278
 
40
- const configService = context.services.get<any>("ConfigurationService");
41
- if (configService) {
42
- // Load initial config
43
- this.color = configService.get("background.color", this.color);
44
- this.url = configService.get("background.url", this.url);
45
-
46
- // Listen for changes
47
- configService.onAnyChange((e: { key: string; value: any }) => {
48
- if (e.key.startsWith("background.")) {
49
- const prop = e.key.split(".")[1];
50
- console.log(
51
- `[BackgroundTool] Config change detected: ${e.key} -> ${e.value}, prop: ${prop}`,
52
- );
53
- if (prop && prop in this) {
54
- console.log(
55
- `[BackgroundTool] Updating option ${prop} to ${e.value}`,
56
- );
57
- (this as any)[prop] = e.value;
279
+ this.configService = context.services.get<ConfigurationService>(
280
+ "ConfigurationService",
281
+ );
282
+ if (this.configService) {
283
+ this.config = normalizeConfig(
284
+ this.configService.get(
285
+ BACKGROUND_CONFIG_KEY,
286
+ DEFAULT_BACKGROUND_CONFIG,
287
+ ),
288
+ );
289
+ this.configChangeDisposable?.dispose();
290
+ this.configChangeDisposable = this.configService.onAnyChange(
291
+ (e: { key: string; value: any }) => {
292
+ if (e.key === BACKGROUND_CONFIG_KEY) {
293
+ this.config = normalizeConfig(e.value);
58
294
  this.updateBackground();
59
- } else {
60
- console.warn(
61
- `[BackgroundTool] Property ${prop} not found in options`,
62
- );
295
+ return;
63
296
  }
64
- }
65
- });
297
+
298
+ if (e.key.startsWith("size.")) {
299
+ this.latestSceneLayout = null;
300
+ this.updateBackground();
301
+ }
302
+ },
303
+ );
66
304
  }
67
305
 
68
- this.initLayer();
306
+ this.renderProducerDisposable?.dispose();
307
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(
308
+ this.id,
309
+ () => ({
310
+ passes: [
311
+ {
312
+ id: BACKGROUND_LAYER_ID,
313
+ stack: 0,
314
+ order: 0,
315
+ objects: this.specs,
316
+ },
317
+ ],
318
+ }),
319
+ { priority: 0 },
320
+ );
321
+
322
+ context.eventBus.on("canvas:resized", this.onCanvasResized);
323
+ context.eventBus.on("scene:layout:change", this.onSceneLayoutChanged);
69
324
  this.updateBackground();
70
325
  }
71
326
 
72
327
  deactivate(context: ExtensionContext) {
73
- if (this.canvasService) {
74
- const layer = this.canvasService.getLayer("background");
75
- if (layer) {
76
- this.canvasService.canvas.remove(layer);
77
- }
78
- this.canvasService = undefined;
79
- }
328
+ context.eventBus.off("canvas:resized", this.onCanvasResized);
329
+ context.eventBus.off("scene:layout:change", this.onSceneLayoutChanged);
330
+
331
+ this.renderSeq += 1;
332
+ this.specs = [];
333
+ this.latestSceneLayout = null;
334
+
335
+ this.configChangeDisposable?.dispose();
336
+ this.configChangeDisposable = undefined;
337
+
338
+ this.renderProducerDisposable?.dispose();
339
+ this.renderProducerDisposable = undefined;
340
+
341
+ if (!this.canvasService) return;
342
+
343
+ void this.canvasService.flushRenderFromProducers();
344
+ this.canvasService.requestRenderAll();
345
+
346
+ this.canvasService = undefined;
347
+ this.configService = undefined;
80
348
  }
81
349
 
82
350
  contribute() {
83
351
  return {
84
352
  [ContributionPointIds.CONFIGURATIONS]: [
85
353
  {
86
- id: "background.color",
87
- type: "color",
88
- label: "Background Color",
89
- default: "",
90
- },
91
- {
92
- id: "background.url",
93
- type: "string",
94
- label: "Image URL",
95
- default: "",
354
+ id: BACKGROUND_CONFIG_KEY,
355
+ type: "json",
356
+ label: "Background Config",
357
+ default: cloneConfig(DEFAULT_BACKGROUND_CONFIG),
96
358
  },
97
359
  ] as ConfigurationContribution[],
98
360
  [ContributionPointIds.COMMANDS]: [
99
361
  {
100
- command: "reset",
101
- title: "Reset Background",
362
+ command: "background.getConfig",
363
+ title: "Get Background Config",
364
+ handler: () => cloneConfig(this.config),
365
+ },
366
+ {
367
+ command: "background.resetConfig",
368
+ title: "Reset Background Config",
102
369
  handler: () => {
103
- this.updateBackground();
370
+ this.commitConfig(cloneConfig(DEFAULT_BACKGROUND_CONFIG));
104
371
  return true;
105
372
  },
106
373
  },
107
374
  {
108
- command: "clear",
109
- title: "Clear Background",
110
- handler: () => {
111
- this.color = "transparent";
112
- this.url = "";
113
- this.updateBackground();
375
+ command: "background.replaceConfig",
376
+ title: "Replace Background Config",
377
+ handler: (config: BackgroundConfig) => {
378
+ this.commitConfig(normalizeConfig(config));
114
379
  return true;
115
380
  },
116
381
  },
117
382
  {
118
- command: "setBackgroundColor",
119
- title: "Set Background Color",
120
- handler: (color: string) => {
121
- if (this.color === color) return true;
122
- this.color = color;
123
- this.updateBackground();
383
+ command: "background.patchConfig",
384
+ title: "Patch Background Config",
385
+ handler: (patch: Partial<BackgroundConfig>) => {
386
+ this.commitConfig(mergeConfig(this.config, patch || {}));
124
387
  return true;
125
388
  },
126
389
  },
127
390
  {
128
- command: "setBackgroundImage",
129
- title: "Set Background Image",
130
- handler: (url: string) => {
131
- if (this.url === url) return true;
132
- this.url = url;
133
- this.updateBackground();
391
+ command: "background.upsertLayer",
392
+ title: "Upsert Background Layer",
393
+ handler: (layer: Partial<BackgroundLayer> & { id: string }) => {
394
+ const normalized = normalizeLayer(layer, 0);
395
+ const existingIndex = this.config.layers.findIndex(
396
+ (item) => item.id === normalized.id,
397
+ );
398
+ const nextLayers = [...this.config.layers];
399
+ if (existingIndex >= 0) {
400
+ nextLayers[existingIndex] = normalizeLayer(
401
+ { ...nextLayers[existingIndex], ...layer },
402
+ existingIndex,
403
+ nextLayers[existingIndex],
404
+ );
405
+ } else {
406
+ nextLayers.push(
407
+ normalizeLayer(
408
+ {
409
+ ...normalized,
410
+ order: Number.isFinite(Number(layer.order))
411
+ ? Number(layer.order)
412
+ : nextLayers.length,
413
+ },
414
+ nextLayers.length,
415
+ ),
416
+ );
417
+ }
418
+ this.commitConfig(
419
+ normalizeConfig({
420
+ ...this.config,
421
+ layers: nextLayers,
422
+ }),
423
+ );
424
+ return true;
425
+ },
426
+ },
427
+ {
428
+ command: "background.removeLayer",
429
+ title: "Remove Background Layer",
430
+ handler: (id: string) => {
431
+ const nextLayers = this.config.layers.filter(
432
+ (layer) => layer.id !== id,
433
+ );
434
+ this.commitConfig(
435
+ normalizeConfig({
436
+ ...this.config,
437
+ layers: nextLayers,
438
+ }),
439
+ );
134
440
  return true;
135
441
  },
136
442
  },
@@ -138,93 +444,273 @@ export class BackgroundTool implements Extension {
138
444
  };
139
445
  }
140
446
 
141
- private initLayer() {
142
- if (!this.canvasService) return;
143
- let backgroundLayer = this.canvasService.getLayer("background");
144
- if (!backgroundLayer) {
145
- backgroundLayer = this.canvasService.createLayer("background", {
146
- width: this.canvasService.canvas.width,
147
- height: this.canvasService.canvas.height,
148
- selectable: false,
149
- evented: false,
150
- });
151
- this.canvasService.canvas.sendObjectToBack(backgroundLayer);
447
+ private commitConfig(next: BackgroundConfig) {
448
+ const normalized = normalizeConfig(next);
449
+ if (configSignature(normalized) === configSignature(this.config)) {
450
+ return;
152
451
  }
153
- }
154
452
 
155
- private async updateBackground() {
156
- if (!this.canvasService) return;
157
- const layer = this.canvasService.getLayer("background");
158
- if (!layer) {
159
- console.warn("[BackgroundTool] Background layer not found");
453
+ if (this.configService) {
454
+ this.configService.update(BACKGROUND_CONFIG_KEY, cloneConfig(normalized));
160
455
  return;
161
456
  }
162
457
 
163
- const { color, url } = this;
458
+ this.config = normalized;
459
+ this.updateBackground();
460
+ }
164
461
 
165
- const width = this.canvasService.canvas.width || 800;
166
- const height = this.canvasService.canvas.height || 600;
462
+ private getViewportRect(): Rect {
463
+ const width = Number(this.canvasService?.canvas.width || 0);
464
+ const height = Number(this.canvasService?.canvas.height || 0);
167
465
 
168
- let rect = this.canvasService.getObject(
169
- "background-color-rect",
170
- "background",
171
- ) as Rect;
172
- if (rect) {
173
- rect.set({
174
- fill: color,
175
- });
176
- } else {
177
- rect = new Rect({
178
- width,
179
- height,
180
- fill: color,
466
+ return {
467
+ left: 0,
468
+ top: 0,
469
+ width: width > 0 ? width : DEFAULT_WIDTH,
470
+ height: height > 0 ? height : DEFAULT_HEIGHT,
471
+ };
472
+ }
473
+
474
+ private resolveSceneLayout(): SceneLayoutSnapshot | null {
475
+ if (this.latestSceneLayout) return this.latestSceneLayout;
476
+ if (!this.canvasService || !this.configService) return null;
477
+
478
+ const layout = computeSceneLayout(
479
+ this.canvasService,
480
+ readSizeState(this.configService),
481
+ );
482
+ this.latestSceneLayout = layout;
483
+ return layout;
484
+ }
485
+
486
+ private resolveFocusRect(): Rect | null {
487
+ const layout = this.resolveSceneLayout();
488
+ if (!layout) return null;
489
+
490
+ return {
491
+ left: layout.trimRect.left,
492
+ top: layout.trimRect.top,
493
+ width: layout.trimRect.width,
494
+ height: layout.trimRect.height,
495
+ };
496
+ }
497
+
498
+ private resolveAnchorRect(anchor: string): Rect {
499
+ if (anchor === "focus") {
500
+ return this.resolveFocusRect() || this.getViewportRect();
501
+ }
502
+
503
+ if (anchor !== "viewport") {
504
+ return this.getViewportRect();
505
+ }
506
+
507
+ return this.getViewportRect();
508
+ }
509
+
510
+ private resolveImagePlacement(
511
+ target: Rect,
512
+ sourceSize: SourceSize,
513
+ fit: BackgroundFitMode,
514
+ ): { left: number; top: number; scaleX: number; scaleY: number } {
515
+ const targetWidth = Math.max(1, Number(target.width || 0));
516
+ const targetHeight = Math.max(1, Number(target.height || 0));
517
+ const sourceWidth = Math.max(1, Number(sourceSize.width || 0));
518
+ const sourceHeight = Math.max(1, Number(sourceSize.height || 0));
519
+
520
+ if (fit === "stretch") {
521
+ return {
522
+ left: target.left,
523
+ top: target.top,
524
+ scaleX: targetWidth / sourceWidth,
525
+ scaleY: targetHeight / sourceHeight,
526
+ };
527
+ }
528
+
529
+ const scale =
530
+ fit === "contain"
531
+ ? Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight)
532
+ : Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
533
+
534
+ const renderWidth = sourceWidth * scale;
535
+ const renderHeight = sourceHeight * scale;
536
+
537
+ return {
538
+ left: target.left + (targetWidth - renderWidth) / 2,
539
+ top: target.top + (targetHeight - renderHeight) / 2,
540
+ scaleX: scale,
541
+ scaleY: scale,
542
+ };
543
+ }
544
+
545
+ private buildColorLayerSpec(layer: BackgroundLayer): RenderObjectSpec {
546
+ const rect = this.resolveAnchorRect(layer.anchor);
547
+
548
+ return {
549
+ id: `background.layer.${layer.id}.color`,
550
+ type: "rect",
551
+ space: "screen",
552
+ data: {
553
+ id: `background.layer.${layer.id}.color`,
554
+ layerId: BACKGROUND_LAYER_ID,
555
+ type: "background-layer",
556
+ layerRef: layer.id,
557
+ layerKind: layer.kind,
558
+ },
559
+ props: {
560
+ left: rect.left,
561
+ top: rect.top,
562
+ width: rect.width,
563
+ height: rect.height,
564
+ originX: "left",
565
+ originY: "top",
566
+ fill: layer.color || "transparent",
567
+ opacity: layer.opacity,
181
568
  selectable: false,
182
569
  evented: false,
570
+ excludeFromExport: !layer.exportable,
571
+ },
572
+ };
573
+ }
574
+
575
+ private buildImageLayerSpec(layer: BackgroundLayer): RenderObjectSpec[] {
576
+ const src = String(layer.src || "").trim();
577
+ if (!src) return [];
578
+
579
+ const sourceSize = this.sourceSizeBySrc.get(src);
580
+ if (!sourceSize) return [];
581
+
582
+ const rect = this.resolveAnchorRect(layer.anchor);
583
+ const placement = this.resolveImagePlacement(rect, sourceSize, layer.fit);
584
+
585
+ return [
586
+ {
587
+ id: `background.layer.${layer.id}.image`,
588
+ type: "image",
589
+ src,
590
+ space: "screen",
183
591
  data: {
184
- id: "background-color-rect",
592
+ id: `background.layer.${layer.id}.image`,
593
+ layerId: BACKGROUND_LAYER_ID,
594
+ type: "background-layer",
595
+ layerRef: layer.id,
596
+ layerKind: layer.kind,
597
+ },
598
+ props: {
599
+ left: placement.left,
600
+ top: placement.top,
601
+ originX: "left",
602
+ originY: "top",
603
+ scaleX: placement.scaleX,
604
+ scaleY: placement.scaleY,
605
+ opacity: layer.opacity,
606
+ selectable: false,
607
+ evented: false,
608
+ excludeFromExport: !layer.exportable,
185
609
  },
610
+ },
611
+ ];
612
+ }
613
+
614
+ private buildBackgroundSpecs(config: BackgroundConfig): RenderObjectSpec[] {
615
+ const activeLayers = (config.layers || [])
616
+ .filter((layer) => layer.enabled)
617
+ .map((layer, index) => ({ layer, index }))
618
+ .sort((a, b) => {
619
+ if (a.layer.order !== b.layer.order) {
620
+ return a.layer.order - b.layer.order;
621
+ }
622
+ return a.index - b.index;
186
623
  });
187
- layer.add(rect);
188
- layer.sendObjectToBack(rect);
624
+
625
+ const specs: RenderObjectSpec[] = [];
626
+
627
+ activeLayers.forEach(({ layer }) => {
628
+ if (layer.kind === "color") {
629
+ specs.push(this.buildColorLayerSpec(layer));
630
+ return;
631
+ }
632
+ specs.push(...this.buildImageLayerSpec(layer));
633
+ });
634
+
635
+ return specs;
636
+ }
637
+
638
+ private collectActiveImageUrls(config: BackgroundConfig): string[] {
639
+ const urls = new Set<string>();
640
+
641
+ (config.layers || []).forEach((layer) => {
642
+ if (!layer.enabled || layer.kind !== "image") return;
643
+ const src = String(layer.src || "").trim();
644
+ if (!src) return;
645
+ urls.add(src);
646
+ });
647
+
648
+ return Array.from(urls);
649
+ }
650
+
651
+ private async ensureImageSize(src: string): Promise<SourceSize | null> {
652
+ if (!src) return null;
653
+
654
+ const cached = this.sourceSizeBySrc.get(src);
655
+ if (cached) return cached;
656
+
657
+ const pending = this.pendingSizeBySrc.get(src);
658
+ if (pending) {
659
+ return pending;
189
660
  }
190
661
 
191
- let img = this.canvasService.getObject(
192
- "background-image",
193
- "background",
194
- ) as Image;
662
+ const task = this.loadImageSize(src);
663
+ this.pendingSizeBySrc.set(src, task);
664
+
195
665
  try {
196
- if (img) {
197
- if (img.getSrc() !== url) {
198
- if (url) {
199
- await img.setSrc(url);
200
- } else {
201
- layer.remove(img);
202
- }
203
- }
204
- } else {
205
- if (url) {
206
- img = await Image.fromURL(url, { crossOrigin: "anonymous" });
207
- img.set({
208
- originX: "left",
209
- originY: "top",
210
- left: 0,
211
- top: 0,
212
- selectable: false,
213
- evented: false,
214
- data: {
215
- id: "background-image",
216
- },
217
- });
218
- img.scaleToWidth(width);
219
- if (img.getScaledHeight() < height) img.scaleToHeight(height);
220
- layer.add(img);
221
- }
666
+ return await task;
667
+ } finally {
668
+ if (this.pendingSizeBySrc.get(src) === task) {
669
+ this.pendingSizeBySrc.delete(src);
222
670
  }
223
- this.canvasService.requestRenderAll();
224
- } catch (e) {
225
- console.error("[BackgroundTool] Failed to load image", e);
226
671
  }
227
- layer.dirty = true;
672
+ }
673
+
674
+ private async loadImageSize(src: string): Promise<SourceSize | null> {
675
+ try {
676
+ const image = await FabricImage.fromURL(src, {
677
+ crossOrigin: "anonymous",
678
+ });
679
+ const width = Number(image?.width || 0);
680
+ const height = Number(image?.height || 0);
681
+ if (width > 0 && height > 0) {
682
+ const size = { width, height };
683
+ this.sourceSizeBySrc.set(src, size);
684
+ return size;
685
+ }
686
+ } catch (error) {
687
+ console.error("[BackgroundTool] Failed to load image", src, error);
688
+ }
689
+
690
+ return null;
691
+ }
692
+
693
+ private updateBackground() {
694
+ void this.updateBackgroundAsync();
695
+ }
696
+
697
+ private async updateBackgroundAsync() {
698
+ if (!this.canvasService) return;
699
+
700
+ const seq = ++this.renderSeq;
701
+ const currentConfig = cloneConfig(this.config);
702
+ const activeUrls = this.collectActiveImageUrls(currentConfig);
703
+
704
+ if (activeUrls.length > 0) {
705
+ await Promise.all(activeUrls.map((url) => this.ensureImageSize(url)));
706
+ if (seq !== this.renderSeq) return;
707
+ }
708
+
709
+ this.specs = this.buildBackgroundSpecs(currentConfig);
710
+
711
+ await this.canvasService.flushRenderFromProducers();
712
+ if (seq !== this.renderSeq) return;
713
+
228
714
  this.canvasService.requestRenderAll();
229
715
  }
230
716
  }