@pooder/kit 3.5.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/image.ts CHANGED
@@ -1,471 +1,512 @@
1
- import {
2
- Extension,
3
- ExtensionContext,
4
- ContributionPointIds,
5
- CommandContribution,
6
- ConfigurationContribution,
7
- ConfigurationService,
8
- } from "@pooder/core";
9
- import { Image, Point, util, Object as FabricObject } from "fabric";
10
- import CanvasService from "./CanvasService";
11
- import { Coordinate } from "./coordinate";
12
-
13
- export interface ImageItem {
14
- id: string;
15
- url: string;
16
- opacity: number;
17
- scale?: number;
18
- angle?: number;
19
- left?: number;
20
- top?: number;
21
- }
22
-
23
- export class ImageTool implements Extension {
24
- id = "pooder.kit.image";
25
-
26
- metadata = {
27
- name: "ImageTool",
28
- };
29
-
30
- private items: ImageItem[] = [];
31
- private objectMap: Map<string, FabricObject> = new Map();
32
- private loadResolvers: Map<string, () => void> = new Map();
33
- private canvasService?: CanvasService;
34
- private context?: ExtensionContext;
35
- private isUpdatingConfig = false;
36
-
37
- activate(context: ExtensionContext) {
38
- this.context = context;
39
- this.canvasService = context.services.get<CanvasService>("CanvasService");
40
- if (!this.canvasService) {
41
- console.warn("CanvasService not found for ImageTool");
42
- return;
43
- }
44
-
45
- const configService = context.services.get<ConfigurationService>(
46
- "ConfigurationService",
47
- );
48
- if (configService) {
49
- // Load initial config
50
- this.items = configService.get("image.items", []) || [];
51
-
52
- // Listen for changes
53
- configService.onAnyChange((e: { key: string; value: any }) => {
54
- if (this.isUpdatingConfig) return;
55
-
56
- if (e.key === "image.items") {
57
- this.items = e.value || [];
58
- this.updateImages();
59
- }
60
- });
61
- }
62
-
63
- this.ensureLayer();
64
- this.updateImages();
65
- }
66
-
67
- deactivate(context: ExtensionContext) {
68
- if (this.canvasService) {
69
- const layer = this.canvasService.getLayer("user");
70
- if (layer) {
71
- this.objectMap.forEach((obj) => {
72
- layer.remove(obj);
73
- });
74
- this.objectMap.clear();
75
- this.canvasService.requestRenderAll();
76
- }
77
- this.canvasService = undefined;
78
- this.context = undefined;
79
- }
80
- }
81
-
82
- contribute() {
83
- return {
84
- [ContributionPointIds.CONFIGURATIONS]: [
85
- {
86
- id: "image.items",
87
- type: "array",
88
- label: "Images",
89
- default: [],
90
- },
91
- ] as ConfigurationContribution[],
92
- [ContributionPointIds.COMMANDS]: [
93
- {
94
- command: "addImage",
95
- title: "Add Image",
96
- handler: async (url: string, options?: Partial<ImageItem>) => {
97
- const id = this.generateId();
98
- const newItem: ImageItem = {
99
- id,
100
- url,
101
- opacity: 1,
102
- ...options,
103
- };
104
-
105
- const promise = new Promise<string>((resolve) => {
106
- this.loadResolvers.set(id, () => resolve(id));
107
- });
108
-
109
- this.updateConfig([...this.items, newItem]);
110
- return promise;
111
- },
112
- },
113
- {
114
- command: "fitImageToArea",
115
- title: "Fit Image to Area",
116
- handler: (
117
- id: string,
118
- area: { width: number; height: number; left?: number; top?: number },
119
- ) => {
120
- const item = this.items.find((i) => i.id === id);
121
- const obj = this.objectMap.get(id);
122
- if (item && obj && obj.width && obj.height) {
123
- const scale = Math.max(
124
- area.width / obj.width,
125
- area.height / obj.height,
126
- );
127
- this.updateImageInConfig(id, {
128
- scale,
129
- left: area.left ?? 0.5,
130
- top: area.top ?? 0.5,
131
- });
132
- }
133
- },
134
- },
135
- {
136
- command: "removeImage",
137
- title: "Remove Image",
138
- handler: (id: string) => {
139
- const newItems = this.items.filter((item) => item.id !== id);
140
- if (newItems.length !== this.items.length) {
141
- this.updateConfig(newItems);
142
- }
143
- },
144
- },
145
- {
146
- command: "updateImage",
147
- title: "Update Image",
148
- handler: (id: string, updates: Partial<ImageItem>) => {
149
- const index = this.items.findIndex((item) => item.id === id);
150
- if (index !== -1) {
151
- const newItems = [...this.items];
152
- newItems[index] = { ...newItems[index], ...updates };
153
- this.updateConfig(newItems);
154
- }
155
- },
156
- },
157
- {
158
- command: "clearImages",
159
- title: "Clear Images",
160
- handler: () => {
161
- this.updateConfig([]);
162
- },
163
- },
164
- {
165
- command: "bringToFront",
166
- title: "Bring Image to Front",
167
- handler: (id: string) => {
168
- const index = this.items.findIndex((item) => item.id === id);
169
- if (index !== -1 && index < this.items.length - 1) {
170
- const newItems = [...this.items];
171
- const [item] = newItems.splice(index, 1);
172
- newItems.push(item);
173
- this.updateConfig(newItems);
174
- }
175
- },
176
- },
177
- {
178
- command: "sendToBack",
179
- title: "Send Image to Back",
180
- handler: (id: string) => {
181
- const index = this.items.findIndex((item) => item.id === id);
182
- if (index > 0) {
183
- const newItems = [...this.items];
184
- const [item] = newItems.splice(index, 1);
185
- newItems.unshift(item);
186
- this.updateConfig(newItems);
187
- }
188
- },
189
- },
190
- ] as CommandContribution[],
191
- };
192
- }
193
-
194
- private generateId(): string {
195
- return Math.random().toString(36).substring(2, 9);
196
- }
197
-
198
- private updateConfig(newItems: ImageItem[], skipCanvasUpdate = false) {
199
- if (!this.context) return;
200
- this.isUpdatingConfig = true;
201
- this.items = newItems;
202
- const configService = this.context.services.get<ConfigurationService>(
203
- "ConfigurationService",
204
- );
205
- if (configService) {
206
- configService.update("image.items", newItems);
207
- }
208
- // Update canvas immediately to reflect changes locally before config event comes back
209
- // (Optional, but good for responsiveness)
210
- if (!skipCanvasUpdate) {
211
- this.updateImages();
212
- }
213
-
214
- // Reset flag after a short delay to allow config propagation
215
- setTimeout(() => {
216
- this.isUpdatingConfig = false;
217
- }, 50);
218
- }
219
-
220
- private ensureLayer() {
221
- if (!this.canvasService) return;
222
- let userLayer = this.canvasService.getLayer("user");
223
- if (!userLayer) {
224
- userLayer = this.canvasService.createLayer("user", {
225
- width: this.canvasService.canvas.width,
226
- height: this.canvasService.canvas.height,
227
- left: 0,
228
- top: 0,
229
- originX: "left",
230
- originY: "top",
231
- selectable: false,
232
- evented: true,
233
- subTargetCheck: true,
234
- interactive: true,
235
- });
236
-
237
- // Try to insert below dieline-overlay
238
- const dielineLayer = this.canvasService.getLayer("dieline-overlay");
239
- if (dielineLayer) {
240
- const index = this.canvasService.canvas
241
- .getObjects()
242
- .indexOf(dielineLayer);
243
- // If dieline is at 0, move user to 0 (dieline shifts to 1)
244
- if (index >= 0) {
245
- this.canvasService.canvas.moveObjectTo(userLayer, index);
246
- }
247
- } else {
248
- // Ensure background is behind
249
- const bgLayer = this.canvasService.getLayer("background");
250
- if (bgLayer) {
251
- this.canvasService.canvas.sendObjectToBack(bgLayer);
252
- }
253
- }
254
- this.canvasService.requestRenderAll();
255
- }
256
- }
257
-
258
- private getLayoutInfo() {
259
- const canvasW = this.canvasService?.canvas.width || 800;
260
- const canvasH = this.canvasService?.canvas.height || 600;
261
-
262
- return {
263
- layoutScale: 1,
264
- layoutOffsetX: 0,
265
- layoutOffsetY: 0,
266
- visualWidth: canvasW,
267
- visualHeight: canvasH,
268
- };
269
- }
270
-
271
- private updateImages() {
272
- if (!this.canvasService) return;
273
- const layer = this.canvasService.getLayer("user");
274
- if (!layer) {
275
- console.warn("[ImageTool] User layer not found");
276
- return;
277
- }
278
-
279
- // 1. Remove objects that are no longer in items
280
- const currentIds = new Set(this.items.map((i) => i.id));
281
- for (const [id, obj] of this.objectMap) {
282
- if (!currentIds.has(id)) {
283
- layer.remove(obj);
284
- this.objectMap.delete(id);
285
- }
286
- }
287
-
288
- // 2. Add or Update objects
289
- const layout = this.getLayoutInfo();
290
-
291
- this.items.forEach((item, index) => {
292
- let obj = this.objectMap.get(item.id);
293
-
294
- if (!obj) {
295
- // New object, load it
296
- this.loadImage(item, layer, layout);
297
- } else {
298
- // Existing object, update properties
299
- // We remove and re-add to ensure coordinates are correctly converted
300
- // from absolute (updateObjectProperties) to relative (layer.add)
301
- layer.remove(obj);
302
- this.updateObjectProperties(obj, item, layout);
303
- layer.add(obj);
304
- }
305
- });
306
-
307
- layer.dirty = true;
308
- this.canvasService.requestRenderAll();
309
- }
310
-
311
- private updateObjectProperties(
312
- obj: FabricObject,
313
- item: ImageItem,
314
- layout: any,
315
- ) {
316
- const {
317
- layoutScale,
318
- layoutOffsetX,
319
- layoutOffsetY,
320
- visualWidth,
321
- visualHeight,
322
- } = layout;
323
- const updates: any = {};
324
-
325
- // Opacity
326
- if (obj.opacity !== item.opacity) updates.opacity = item.opacity;
327
-
328
- // Angle
329
- if (item.angle !== undefined && obj.angle !== item.angle)
330
- updates.angle = item.angle;
331
-
332
- // Position (Normalized -> Absolute)
333
- if (item.left !== undefined) {
334
- const globalLeft = layoutOffsetX + item.left * visualWidth;
335
- if (Math.abs(obj.left - globalLeft) > 1) updates.left = globalLeft;
336
- }
337
- if (item.top !== undefined) {
338
- const globalTop = layoutOffsetY + item.top * visualHeight;
339
- if (Math.abs(obj.top - globalTop) > 1) updates.top = globalTop;
340
- }
341
-
342
- // Scale
343
- if (item.scale !== undefined) {
344
- const targetScale = item.scale * layoutScale;
345
- if (Math.abs(obj.scaleX - targetScale) > 0.001) {
346
- updates.scaleX = targetScale;
347
- updates.scaleY = targetScale;
348
- }
349
- }
350
-
351
- // Center origin if not set
352
- if (obj.originX !== "center") {
353
- updates.originX = "center";
354
- updates.originY = "center";
355
- // Adjust position because origin changed (Fabric logic)
356
- // For simplicity, we just set it, next cycle will fix pos if needed,
357
- // or we can calculate the shift. Ideally we set origin on creation.
358
- }
359
-
360
- if (Object.keys(updates).length > 0) {
361
- obj.set(updates);
362
- obj.setCoords();
363
- }
364
- }
365
-
366
- private loadImage(item: ImageItem, layer: any, layout: any) {
367
- Image.fromURL(item.url, { crossOrigin: "anonymous" })
368
- .then((image) => {
369
- // Double check if item still exists
370
- if (!this.items.find((i) => i.id === item.id)) return;
371
-
372
- image.set({
373
- originX: "center",
374
- originY: "center",
375
- data: { id: item.id },
376
- uniformScaling: true,
377
- lockScalingFlip: true,
378
- });
379
-
380
- image.setControlsVisibility({
381
- mt: false,
382
- mb: false,
383
- ml: false,
384
- mr: false,
385
- });
386
-
387
- // Initial Layout
388
- let { scale, left, top } = item;
389
-
390
- if (scale === undefined) {
391
- scale = 1; // Default scale if not provided and not fitted yet
392
- item.scale = scale;
393
- }
394
-
395
- if (left === undefined && top === undefined) {
396
- left = 0.5;
397
- top = 0.5;
398
- item.left = left;
399
- item.top = top;
400
- }
401
-
402
- // Apply Props
403
- this.updateObjectProperties(image, item, layout);
404
-
405
- layer.add(image);
406
- this.objectMap.set(item.id, image);
407
-
408
- // Notify addImage that load is complete
409
- const resolver = this.loadResolvers.get(item.id);
410
- if (resolver) {
411
- resolver();
412
- this.loadResolvers.delete(item.id);
413
- }
414
-
415
- // Bind Events
416
- image.on("modified", (e: any) => {
417
- this.handleObjectModified(item.id, image);
418
- });
419
-
420
- layer.dirty = true;
421
- this.canvasService?.requestRenderAll();
422
-
423
- // Save defaults if we set them
424
- if (item.scale !== scale || item.left !== left || item.top !== top) {
425
- this.updateImageInConfig(item.id, { scale, left, top }, true);
426
- }
427
- })
428
- .catch((err) => {
429
- console.error("Failed to load image", item.url, err);
430
- });
431
- }
432
-
433
- private handleObjectModified(id: string, image: FabricObject) {
434
- const layout = this.getLayoutInfo();
435
- const {
436
- layoutScale,
437
- layoutOffsetX,
438
- layoutOffsetY,
439
- visualWidth,
440
- visualHeight,
441
- } = layout;
442
-
443
- const matrix = image.calcTransformMatrix();
444
- const globalPoint = util.transformPoint(new Point(0, 0), matrix);
445
-
446
- const updates: Partial<ImageItem> = {};
447
-
448
- // Normalize Position
449
- updates.left = (globalPoint.x - layoutOffsetX) / visualWidth;
450
- updates.top = (globalPoint.y - layoutOffsetY) / visualHeight;
451
- updates.angle = image.angle;
452
-
453
- // Scale
454
- updates.scale = image.scaleX / layoutScale;
455
-
456
- this.updateImageInConfig(id, updates, true);
457
- }
458
-
459
- private updateImageInConfig(
460
- id: string,
461
- updates: Partial<ImageItem>,
462
- skipCanvasUpdate = false,
463
- ) {
464
- const index = this.items.findIndex((i) => i.id === id);
465
- if (index !== -1) {
466
- const newItems = [...this.items];
467
- newItems[index] = { ...newItems[index], ...updates };
468
- this.updateConfig(newItems, skipCanvasUpdate);
469
- }
470
- }
471
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ } from "@pooder/core";
9
+ import { Image, Point, util, Object as FabricObject } from "fabric";
10
+ import CanvasService from "./CanvasService";
11
+ import { Coordinate } from "./coordinate";
12
+
13
+ export interface ImageItem {
14
+ id: string;
15
+ url: string;
16
+ opacity: number;
17
+ scale?: number;
18
+ angle?: number;
19
+ left?: number;
20
+ top?: number;
21
+ }
22
+
23
+ export class ImageTool implements Extension {
24
+ id = "pooder.kit.image";
25
+
26
+ metadata = {
27
+ name: "ImageTool",
28
+ };
29
+
30
+ private items: ImageItem[] = [];
31
+ private objectMap: Map<string, FabricObject> = new Map();
32
+ private loadResolvers: Map<string, () => void> = new Map();
33
+ private canvasService?: CanvasService;
34
+ private context?: ExtensionContext;
35
+ private isUpdatingConfig = false;
36
+ private isToolActive = false;
37
+
38
+ activate(context: ExtensionContext) {
39
+ this.context = context;
40
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
41
+ if (!this.canvasService) {
42
+ console.warn("CanvasService not found for ImageTool");
43
+ return;
44
+ }
45
+
46
+ // Listen to tool activation
47
+ context.eventBus.on("tool:activated", this.onToolActivated);
48
+
49
+ const configService = context.services.get<ConfigurationService>(
50
+ "ConfigurationService",
51
+ );
52
+ if (configService) {
53
+ // Load initial config
54
+ this.items = configService.get("image.items", []) || [];
55
+
56
+ // Listen for changes
57
+ configService.onAnyChange((e: { key: string; value: any }) => {
58
+ if (this.isUpdatingConfig) return;
59
+
60
+ if (e.key === "image.items") {
61
+ this.items = e.value || [];
62
+ this.updateImages();
63
+ }
64
+ });
65
+ }
66
+
67
+ this.ensureLayer();
68
+ this.updateImages();
69
+ }
70
+
71
+ deactivate(context: ExtensionContext) {
72
+ context.eventBus.off("tool:activated", this.onToolActivated);
73
+
74
+ if (this.canvasService) {
75
+ const layer = this.canvasService.getLayer("user");
76
+ if (layer) {
77
+ this.objectMap.forEach((obj) => {
78
+ layer.remove(obj);
79
+ });
80
+ this.objectMap.clear();
81
+ this.canvasService.requestRenderAll();
82
+ }
83
+ this.canvasService = undefined;
84
+ this.context = undefined;
85
+ }
86
+ }
87
+
88
+ private onToolActivated = (event: { id: string }) => {
89
+ this.isToolActive = event.id === this.id;
90
+ this.updateInteractivity();
91
+ };
92
+
93
+ private updateInteractivity() {
94
+ this.objectMap.forEach((obj) => {
95
+ obj.set({
96
+ selectable: this.isToolActive,
97
+ evented: this.isToolActive,
98
+ hasControls: this.isToolActive,
99
+ hasBorders: this.isToolActive,
100
+ });
101
+ });
102
+ this.canvasService?.requestRenderAll();
103
+ }
104
+
105
+ contribute() {
106
+ return {
107
+ [ContributionPointIds.CONFIGURATIONS]: [
108
+ {
109
+ id: "image.items",
110
+ type: "array",
111
+ label: "Images",
112
+ default: [],
113
+ },
114
+ ] as ConfigurationContribution[],
115
+ [ContributionPointIds.COMMANDS]: [
116
+ {
117
+ command: "addImage",
118
+ title: "Add Image",
119
+ handler: async (url: string, options?: Partial<ImageItem>) => {
120
+ const id = this.generateId();
121
+ const newItem: ImageItem = {
122
+ id,
123
+ url,
124
+ opacity: 1,
125
+ ...options,
126
+ };
127
+
128
+ const promise = new Promise<string>((resolve) => {
129
+ this.loadResolvers.set(id, () => resolve(id));
130
+ });
131
+
132
+ this.updateConfig([...this.items, newItem]);
133
+ return promise;
134
+ },
135
+ },
136
+ {
137
+ command: "fitImageToArea",
138
+ title: "Fit Image to Area",
139
+ handler: (
140
+ id: string,
141
+ area: { width: number; height: number; left?: number; top?: number },
142
+ ) => {
143
+ const item = this.items.find((i) => i.id === id);
144
+ const obj = this.objectMap.get(id);
145
+ if (item && obj && obj.width && obj.height) {
146
+ const scale = Math.max(
147
+ area.width / obj.width,
148
+ area.height / obj.height,
149
+ );
150
+ this.updateImageInConfig(id, {
151
+ scale,
152
+ left: area.left ?? 0.5,
153
+ top: area.top ?? 0.5,
154
+ });
155
+ }
156
+ },
157
+ },
158
+ {
159
+ command: "removeImage",
160
+ title: "Remove Image",
161
+ handler: (id: string) => {
162
+ const newItems = this.items.filter((item) => item.id !== id);
163
+ if (newItems.length !== this.items.length) {
164
+ this.updateConfig(newItems);
165
+ }
166
+ },
167
+ },
168
+ {
169
+ command: "updateImage",
170
+ title: "Update Image",
171
+ handler: (id: string, updates: Partial<ImageItem>) => {
172
+ const index = this.items.findIndex((item) => item.id === id);
173
+ if (index !== -1) {
174
+ const newItems = [...this.items];
175
+ newItems[index] = { ...newItems[index], ...updates };
176
+ this.updateConfig(newItems);
177
+ }
178
+ },
179
+ },
180
+ {
181
+ command: "clearImages",
182
+ title: "Clear Images",
183
+ handler: () => {
184
+ this.updateConfig([]);
185
+ },
186
+ },
187
+ {
188
+ command: "bringToFront",
189
+ title: "Bring Image to Front",
190
+ handler: (id: string) => {
191
+ const index = this.items.findIndex((item) => item.id === id);
192
+ if (index !== -1 && index < this.items.length - 1) {
193
+ const newItems = [...this.items];
194
+ const [item] = newItems.splice(index, 1);
195
+ newItems.push(item);
196
+ this.updateConfig(newItems);
197
+ }
198
+ },
199
+ },
200
+ {
201
+ command: "sendToBack",
202
+ title: "Send Image to Back",
203
+ handler: (id: string) => {
204
+ const index = this.items.findIndex((item) => item.id === id);
205
+ if (index > 0) {
206
+ const newItems = [...this.items];
207
+ const [item] = newItems.splice(index, 1);
208
+ newItems.unshift(item);
209
+ this.updateConfig(newItems);
210
+ }
211
+ },
212
+ },
213
+ ] as CommandContribution[],
214
+ };
215
+ }
216
+
217
+ private generateId(): string {
218
+ return Math.random().toString(36).substring(2, 9);
219
+ }
220
+
221
+ private updateConfig(newItems: ImageItem[], skipCanvasUpdate = false) {
222
+ if (!this.context) return;
223
+ this.isUpdatingConfig = true;
224
+ this.items = newItems;
225
+ const configService = this.context.services.get<ConfigurationService>(
226
+ "ConfigurationService",
227
+ );
228
+ if (configService) {
229
+ configService.update("image.items", newItems);
230
+ }
231
+ // Update canvas immediately to reflect changes locally before config event comes back
232
+ // (Optional, but good for responsiveness)
233
+ if (!skipCanvasUpdate) {
234
+ this.updateImages();
235
+ }
236
+
237
+ // Reset flag after a short delay to allow config propagation
238
+ setTimeout(() => {
239
+ this.isUpdatingConfig = false;
240
+ }, 50);
241
+ }
242
+
243
+ private ensureLayer() {
244
+ if (!this.canvasService) return;
245
+ let userLayer = this.canvasService.getLayer("user");
246
+ if (!userLayer) {
247
+ userLayer = this.canvasService.createLayer("user", {
248
+ width: this.canvasService.canvas.width,
249
+ height: this.canvasService.canvas.height,
250
+ left: 0,
251
+ top: 0,
252
+ originX: "left",
253
+ originY: "top",
254
+ selectable: false,
255
+ evented: true,
256
+ subTargetCheck: true,
257
+ interactive: true,
258
+ });
259
+
260
+ // Try to insert below dieline-overlay
261
+ const dielineLayer = this.canvasService.getLayer("dieline-overlay");
262
+ if (dielineLayer) {
263
+ const index = this.canvasService.canvas
264
+ .getObjects()
265
+ .indexOf(dielineLayer);
266
+ // If dieline is at 0, move user to 0 (dieline shifts to 1)
267
+ if (index >= 0) {
268
+ this.canvasService.canvas.moveObjectTo(userLayer, index);
269
+ }
270
+ } else {
271
+ // Ensure background is behind
272
+ const bgLayer = this.canvasService.getLayer("background");
273
+ if (bgLayer) {
274
+ this.canvasService.canvas.sendObjectToBack(bgLayer);
275
+ }
276
+ }
277
+ this.canvasService.requestRenderAll();
278
+ }
279
+ }
280
+
281
+ private getLayoutInfo() {
282
+ const canvasW = this.canvasService?.canvas.width || 800;
283
+ const canvasH = this.canvasService?.canvas.height || 600;
284
+
285
+ return {
286
+ layoutScale: 1,
287
+ layoutOffsetX: 0,
288
+ layoutOffsetY: 0,
289
+ visualWidth: canvasW,
290
+ visualHeight: canvasH,
291
+ };
292
+ }
293
+
294
+ private updateImages() {
295
+ if (!this.canvasService) return;
296
+ const layer = this.canvasService.getLayer("user");
297
+ if (!layer) {
298
+ console.warn("[ImageTool] User layer not found");
299
+ return;
300
+ }
301
+
302
+ // 1. Remove objects that are no longer in items
303
+ const currentIds = new Set(this.items.map((i) => i.id));
304
+ for (const [id, obj] of this.objectMap) {
305
+ if (!currentIds.has(id)) {
306
+ layer.remove(obj);
307
+ this.objectMap.delete(id);
308
+ }
309
+ }
310
+
311
+ // 2. Add or Update objects
312
+ const layout = this.getLayoutInfo();
313
+
314
+ this.items.forEach((item, index) => {
315
+ let obj = this.objectMap.get(item.id);
316
+
317
+ // Check if URL changed, if so remove object to force reload
318
+ // We assume Fabric object has getSrc() or we check data.url if we stored it
319
+ // Since we don't store url on object easily accessible without casting,
320
+ // let's rely on checking if we need to reload.
321
+ // Actually, standard Fabric Image doesn't expose src easily on type without casting to any.
322
+ if (obj && (obj as any).getSrc) {
323
+ const currentSrc = (obj as any).getSrc();
324
+ if (currentSrc !== item.url) {
325
+ layer.remove(obj);
326
+ this.objectMap.delete(item.id);
327
+ obj = undefined;
328
+ }
329
+ }
330
+
331
+ if (!obj) {
332
+ // New object, load it
333
+ this.loadImage(item, layer, layout);
334
+ } else {
335
+ // Existing object, update properties
336
+ // We remove and re-add to ensure coordinates are correctly converted
337
+ // from absolute (updateObjectProperties) to relative (layer.add)
338
+ layer.remove(obj);
339
+ this.updateObjectProperties(obj, item, layout);
340
+ layer.add(obj);
341
+ }
342
+ });
343
+
344
+ layer.dirty = true;
345
+ this.canvasService.requestRenderAll();
346
+ }
347
+
348
+ private updateObjectProperties(
349
+ obj: FabricObject,
350
+ item: ImageItem,
351
+ layout: any,
352
+ ) {
353
+ const {
354
+ layoutScale,
355
+ layoutOffsetX,
356
+ layoutOffsetY,
357
+ visualWidth,
358
+ visualHeight,
359
+ } = layout;
360
+ const updates: any = {};
361
+
362
+ // Opacity
363
+ if (obj.opacity !== item.opacity) updates.opacity = item.opacity;
364
+
365
+ // Angle
366
+ if (item.angle !== undefined && obj.angle !== item.angle)
367
+ updates.angle = item.angle;
368
+
369
+ // Position (Normalized -> Absolute)
370
+ if (item.left !== undefined) {
371
+ const globalLeft = layoutOffsetX + item.left * visualWidth;
372
+ if (Math.abs(obj.left - globalLeft) > 1) updates.left = globalLeft;
373
+ }
374
+ if (item.top !== undefined) {
375
+ const globalTop = layoutOffsetY + item.top * visualHeight;
376
+ if (Math.abs(obj.top - globalTop) > 1) updates.top = globalTop;
377
+ }
378
+
379
+ // Scale
380
+ if (item.scale !== undefined) {
381
+ const targetScale = item.scale * layoutScale;
382
+ if (Math.abs(obj.scaleX - targetScale) > 0.001) {
383
+ updates.scaleX = targetScale;
384
+ updates.scaleY = targetScale;
385
+ }
386
+ }
387
+
388
+ // Center origin if not set
389
+ if (obj.originX !== "center") {
390
+ updates.originX = "center";
391
+ updates.originY = "center";
392
+ // Adjust position because origin changed (Fabric logic)
393
+ // For simplicity, we just set it, next cycle will fix pos if needed,
394
+ // or we can calculate the shift. Ideally we set origin on creation.
395
+ }
396
+
397
+ if (Object.keys(updates).length > 0) {
398
+ obj.set(updates);
399
+ obj.setCoords();
400
+ }
401
+ }
402
+
403
+ private loadImage(item: ImageItem, layer: any, layout: any) {
404
+ Image.fromURL(item.url, { crossOrigin: "anonymous" })
405
+ .then((image) => {
406
+ // Double check if item still exists
407
+ if (!this.items.find((i) => i.id === item.id)) return;
408
+
409
+ image.set({
410
+ originX: "center",
411
+ originY: "center",
412
+ data: { id: item.id },
413
+ uniformScaling: true,
414
+ lockScalingFlip: true,
415
+ selectable: this.isToolActive,
416
+ evented: this.isToolActive,
417
+ hasControls: this.isToolActive,
418
+ hasBorders: this.isToolActive,
419
+ });
420
+
421
+ image.setControlsVisibility({
422
+ mt: false,
423
+ mb: false,
424
+ ml: false,
425
+ mr: false,
426
+ });
427
+
428
+ // Initial Layout
429
+ let { scale, left, top } = item;
430
+
431
+ if (scale === undefined) {
432
+ scale = 1; // Default scale if not provided and not fitted yet
433
+ item.scale = scale;
434
+ }
435
+
436
+ if (left === undefined && top === undefined) {
437
+ left = 0.5;
438
+ top = 0.5;
439
+ item.left = left;
440
+ item.top = top;
441
+ }
442
+
443
+ // Apply Props
444
+ this.updateObjectProperties(image, item, layout);
445
+
446
+ layer.add(image);
447
+ this.objectMap.set(item.id, image);
448
+
449
+ // Notify addImage that load is complete
450
+ const resolver = this.loadResolvers.get(item.id);
451
+ if (resolver) {
452
+ resolver();
453
+ this.loadResolvers.delete(item.id);
454
+ }
455
+
456
+ // Bind Events
457
+ image.on("modified", (e: any) => {
458
+ this.handleObjectModified(item.id, image);
459
+ });
460
+
461
+ layer.dirty = true;
462
+ this.canvasService?.requestRenderAll();
463
+
464
+ // Save defaults if we set them
465
+ if (item.scale !== scale || item.left !== left || item.top !== top) {
466
+ this.updateImageInConfig(item.id, { scale, left, top }, true);
467
+ }
468
+ })
469
+ .catch((err) => {
470
+ console.error("Failed to load image", item.url, err);
471
+ });
472
+ }
473
+
474
+ private handleObjectModified(id: string, image: FabricObject) {
475
+ const layout = this.getLayoutInfo();
476
+ const {
477
+ layoutScale,
478
+ layoutOffsetX,
479
+ layoutOffsetY,
480
+ visualWidth,
481
+ visualHeight,
482
+ } = layout;
483
+
484
+ const matrix = image.calcTransformMatrix();
485
+ const globalPoint = util.transformPoint(new Point(0, 0), matrix);
486
+
487
+ const updates: Partial<ImageItem> = {};
488
+
489
+ // Normalize Position
490
+ updates.left = (globalPoint.x - layoutOffsetX) / visualWidth;
491
+ updates.top = (globalPoint.y - layoutOffsetY) / visualHeight;
492
+ updates.angle = image.angle;
493
+
494
+ // Scale
495
+ updates.scale = image.scaleX / layoutScale;
496
+
497
+ this.updateImageInConfig(id, updates, true);
498
+ }
499
+
500
+ private updateImageInConfig(
501
+ id: string,
502
+ updates: Partial<ImageItem>,
503
+ skipCanvasUpdate = false,
504
+ ) {
505
+ const index = this.items.findIndex((i) => i.id === id);
506
+ if (index !== -1) {
507
+ const newItems = [...this.items];
508
+ newItems[index] = { ...newItems[index], ...updates };
509
+ this.updateConfig(newItems, skipCanvasUpdate);
510
+ }
511
+ }
512
+ }