@pooder/kit 4.3.0 → 5.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 (60) hide show
  1. package/.test-dist/src/CanvasService.js +249 -0
  2. package/.test-dist/src/ViewportSystem.js +75 -0
  3. package/.test-dist/src/background.js +203 -0
  4. package/.test-dist/src/bridgeSelection.js +20 -0
  5. package/.test-dist/src/constraints.js +237 -0
  6. package/.test-dist/src/coordinate.js +74 -0
  7. package/.test-dist/src/dieline.js +723 -0
  8. package/.test-dist/src/edgeScale.js +12 -0
  9. package/.test-dist/src/feature.js +752 -0
  10. package/.test-dist/src/featureComplete.js +32 -0
  11. package/.test-dist/src/film.js +167 -0
  12. package/.test-dist/src/geometry.js +506 -0
  13. package/.test-dist/src/image.js +1234 -0
  14. package/.test-dist/src/index.js +35 -0
  15. package/.test-dist/src/maskOps.js +270 -0
  16. package/.test-dist/src/mirror.js +104 -0
  17. package/.test-dist/src/renderSpec.js +2 -0
  18. package/.test-dist/src/ruler.js +343 -0
  19. package/.test-dist/src/sceneLayout.js +99 -0
  20. package/.test-dist/src/sceneLayoutModel.js +196 -0
  21. package/.test-dist/src/sceneView.js +40 -0
  22. package/.test-dist/src/sceneVisibility.js +42 -0
  23. package/.test-dist/src/size.js +332 -0
  24. package/.test-dist/src/tracer.js +544 -0
  25. package/.test-dist/src/units.js +30 -0
  26. package/.test-dist/src/white-ink.js +829 -0
  27. package/.test-dist/src/wrappedOffsets.js +33 -0
  28. package/.test-dist/tests/run.js +94 -0
  29. package/CHANGELOG.md +17 -0
  30. package/dist/index.d.mts +339 -36
  31. package/dist/index.d.ts +339 -36
  32. package/dist/index.js +3587 -854
  33. package/dist/index.mjs +3580 -856
  34. package/package.json +2 -2
  35. package/src/CanvasService.ts +300 -96
  36. package/src/ViewportSystem.ts +92 -92
  37. package/src/background.ts +230 -230
  38. package/src/bridgeSelection.ts +17 -0
  39. package/src/coordinate.ts +106 -106
  40. package/src/dieline.ts +897 -955
  41. package/src/edgeScale.ts +19 -0
  42. package/src/feature.ts +83 -30
  43. package/src/film.ts +194 -194
  44. package/src/geometry.ts +234 -80
  45. package/src/image.ts +1582 -512
  46. package/src/index.ts +14 -10
  47. package/src/maskOps.ts +326 -0
  48. package/src/mirror.ts +128 -128
  49. package/src/renderSpec.ts +18 -0
  50. package/src/ruler.ts +449 -508
  51. package/src/sceneLayout.ts +121 -0
  52. package/src/sceneLayoutModel.ts +335 -0
  53. package/src/sceneVisibility.ts +49 -0
  54. package/src/size.ts +379 -0
  55. package/src/tracer.ts +719 -570
  56. package/src/units.ts +27 -27
  57. package/src/white-ink.ts +1018 -373
  58. package/src/wrappedOffsets.ts +33 -0
  59. package/tests/run.ts +118 -0
  60. package/tsconfig.test.json +15 -15
package/src/image.ts CHANGED
@@ -1,512 +1,1582 @@
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
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ ToolSessionService,
9
+ WorkbenchService,
10
+ } from "@pooder/core";
11
+ import { Canvas as FabricCanvas, Image as FabricImage, Point } from "fabric";
12
+ import CanvasService from "./CanvasService";
13
+ import type { RenderObjectSpec } from "./renderSpec";
14
+ import { computeSceneLayout, readSizeState } from "./sceneLayoutModel";
15
+
16
+ export interface ImageItem {
17
+ id: string;
18
+ url: string;
19
+ opacity: number;
20
+ scale?: number;
21
+ angle?: number;
22
+ left?: number;
23
+ top?: number;
24
+ sourceUrl?: string;
25
+ committedUrl?: string;
26
+ }
27
+
28
+ interface FrameRect {
29
+ left: number;
30
+ top: number;
31
+ width: number;
32
+ height: number;
33
+ }
34
+
35
+ interface SourceSize {
36
+ width: number;
37
+ height: number;
38
+ }
39
+
40
+ interface RenderImageState {
41
+ src: string;
42
+ left: number;
43
+ top: number;
44
+ scale: number;
45
+ angle: number;
46
+ opacity: number;
47
+ }
48
+
49
+ interface FrameVisualConfig {
50
+ strokeColor: string;
51
+ strokeWidth: number;
52
+ strokeStyle: "solid" | "dashed" | "hidden";
53
+ dashLength: number;
54
+ innerBackground: string;
55
+ outerBackground: string;
56
+ }
57
+
58
+ interface UpsertImageOptions {
59
+ id?: string;
60
+ mode?: "auto" | "replace" | "add";
61
+ createIfMissing?: boolean;
62
+ addOptions?: Partial<ImageItem>;
63
+ fitOnAdd?: boolean;
64
+ }
65
+
66
+ interface DielineFitArea {
67
+ width: number;
68
+ height: number;
69
+ left: number;
70
+ top: number;
71
+ }
72
+
73
+ interface UpdateImageOptions {
74
+ target?: "auto" | "config" | "working";
75
+ }
76
+
77
+ interface DetectBounds {
78
+ x: number;
79
+ y: number;
80
+ width: number;
81
+ height: number;
82
+ }
83
+
84
+ interface DetectEdgeResult {
85
+ pathData: string;
86
+ rawBounds?: DetectBounds;
87
+ baseBounds?: DetectBounds;
88
+ imageWidth?: number;
89
+ imageHeight?: number;
90
+ }
91
+
92
+ interface ImageRenderSnapshot {
93
+ id: string;
94
+ centerX: number;
95
+ centerY: number;
96
+ objectScale: number;
97
+ sourceWidth: number;
98
+ sourceHeight: number;
99
+ }
100
+
101
+ interface DetectFromFrameOptions {
102
+ expand?: number;
103
+ smoothing?: boolean;
104
+ simplifyTolerance?: number;
105
+ multiplier?: number;
106
+ debug?: boolean;
107
+ }
108
+
109
+ const IMAGE_OBJECT_LAYER_ID = "image.user";
110
+ const IMAGE_OVERLAY_LAYER_ID = "image-overlay";
111
+ const IMAGE_REPLACE_GUARD_MS = 2500;
112
+ const IMAGE_DETECT_EXPAND_DEFAULT = 30;
113
+ const IMAGE_DETECT_SIMPLIFY_TOLERANCE_DEFAULT = 2;
114
+ const IMAGE_DETECT_MULTIPLIER_DEFAULT = 2;
115
+
116
+ export class ImageTool implements Extension {
117
+ id = "pooder.kit.image";
118
+
119
+ metadata = {
120
+ name: "ImageTool",
121
+ };
122
+
123
+ private items: ImageItem[] = [];
124
+ private workingItems: ImageItem[] = [];
125
+ private hasWorkingChanges = false;
126
+ private loadResolvers: Map<string, () => void> = new Map();
127
+ private sourceSizeBySrc: Map<string, SourceSize> = new Map();
128
+ private canvasService?: CanvasService;
129
+ private context?: ExtensionContext;
130
+ private isUpdatingConfig = false;
131
+ private isToolActive = false;
132
+ private isImageSelectionActive = false;
133
+ private focusedImageId: string | null = null;
134
+ private suppressSelectionClearUntil = 0;
135
+ private renderSeq = 0;
136
+ private dirtyTrackerDisposable?: { dispose(): void };
137
+
138
+ activate(context: ExtensionContext) {
139
+ this.context = context;
140
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
141
+ if (!this.canvasService) {
142
+ console.warn("CanvasService not found for ImageTool");
143
+ return;
144
+ }
145
+
146
+ context.eventBus.on("tool:activated", this.onToolActivated);
147
+ context.eventBus.on("object:modified", this.onObjectModified);
148
+ context.eventBus.on("selection:created", this.onSelectionChanged);
149
+ context.eventBus.on("selection:updated", this.onSelectionChanged);
150
+ context.eventBus.on("selection:cleared", this.onSelectionCleared);
151
+ context.eventBus.on(
152
+ "scene:layout:change",
153
+ this.onSceneLayoutChanged,
154
+ );
155
+
156
+ const configService = context.services.get<ConfigurationService>(
157
+ "ConfigurationService",
158
+ );
159
+ if (configService) {
160
+ this.items = this.normalizeItems(
161
+ configService.get("image.items", []) || [],
162
+ );
163
+ this.workingItems = this.cloneItems(this.items);
164
+ this.hasWorkingChanges = false;
165
+
166
+ configService.onAnyChange((e: { key: string; value: any }) => {
167
+ if (this.isUpdatingConfig) return;
168
+
169
+ if (e.key === "image.items") {
170
+ this.items = this.normalizeItems(e.value || []);
171
+ if (!this.isToolActive || !this.hasWorkingChanges) {
172
+ this.workingItems = this.cloneItems(this.items);
173
+ this.hasWorkingChanges = false;
174
+ }
175
+ this.updateImages();
176
+ return;
177
+ }
178
+
179
+ if (e.key.startsWith("size.") || e.key.startsWith("image.frame.")) {
180
+ this.updateImages();
181
+ }
182
+ });
183
+ }
184
+
185
+ const toolSessionService =
186
+ context.services.get<ToolSessionService>("ToolSessionService");
187
+ this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(
188
+ this.id,
189
+ () => this.hasWorkingChanges,
190
+ );
191
+
192
+ this.updateImages();
193
+ }
194
+
195
+ deactivate(context: ExtensionContext) {
196
+ context.eventBus.off("tool:activated", this.onToolActivated);
197
+ context.eventBus.off("object:modified", this.onObjectModified);
198
+ context.eventBus.off("selection:created", this.onSelectionChanged);
199
+ context.eventBus.off("selection:updated", this.onSelectionChanged);
200
+ context.eventBus.off("selection:cleared", this.onSelectionCleared);
201
+ context.eventBus.off(
202
+ "scene:layout:change",
203
+ this.onSceneLayoutChanged,
204
+ );
205
+ this.dirtyTrackerDisposable?.dispose();
206
+ this.dirtyTrackerDisposable = undefined;
207
+
208
+ this.clearRenderedImages();
209
+ if (this.canvasService) {
210
+ void this.canvasService.applyObjectSpecsToRootLayer(
211
+ IMAGE_OVERLAY_LAYER_ID,
212
+ [],
213
+ );
214
+ this.canvasService = undefined;
215
+ }
216
+ this.context = undefined;
217
+ }
218
+
219
+ private onToolActivated = (event: {
220
+ id: string | null;
221
+ previous?: string | null;
222
+ reason?: string;
223
+ }) => {
224
+ const before = this.isToolActive;
225
+ this.syncToolActiveFromWorkbench(event.id);
226
+ if (!this.isToolActive) {
227
+ const now = Date.now();
228
+ const inGuardWindow =
229
+ now <= this.suppressSelectionClearUntil && !!this.focusedImageId;
230
+ if (!inGuardWindow) {
231
+ this.isImageSelectionActive = false;
232
+ this.focusedImageId = null;
233
+ }
234
+ }
235
+ this.debug("tool:activated", {
236
+ id: event.id,
237
+ previous: event.previous,
238
+ reason: event.reason,
239
+ before,
240
+ isToolActive: this.isToolActive,
241
+ focusedImageId: this.focusedImageId,
242
+ suppressSelectionClearUntil: this.suppressSelectionClearUntil,
243
+ });
244
+ if (!this.isToolActive && this.isDebugEnabled()) {
245
+ console.trace("[ImageTool] tool deactivated trace");
246
+ }
247
+ this.updateImages();
248
+ };
249
+
250
+ private onSelectionChanged = (e: any) => {
251
+ const list: any[] = [];
252
+ if (Array.isArray(e?.selected)) {
253
+ list.push(...e.selected);
254
+ }
255
+ if (Array.isArray(e?.target?._objects)) {
256
+ list.push(...e.target._objects);
257
+ }
258
+ if (e?.target && !Array.isArray(e?.target?._objects)) {
259
+ list.push(e.target);
260
+ }
261
+
262
+ const selectedImage = list.find(
263
+ (obj: any) => obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID,
264
+ );
265
+ this.isImageSelectionActive = !!selectedImage;
266
+ if (selectedImage?.data?.id) {
267
+ this.focusedImageId = selectedImage.data.id;
268
+ } else if (list.length > 0) {
269
+ this.focusedImageId = null;
270
+ }
271
+ this.debug("selection:changed", {
272
+ listSize: list.length,
273
+ isImageSelectionActive: this.isImageSelectionActive,
274
+ focusedImageId: this.focusedImageId,
275
+ });
276
+ this.updateImages();
277
+ };
278
+
279
+ private onSelectionCleared = () => {
280
+ const now = Date.now();
281
+ if (now <= this.suppressSelectionClearUntil && this.focusedImageId) {
282
+ this.debug("selection:cleared ignored", {
283
+ suppressUntil: this.suppressSelectionClearUntil,
284
+ focusedImageId: this.focusedImageId,
285
+ });
286
+ return;
287
+ }
288
+ this.isImageSelectionActive = false;
289
+ this.focusedImageId = null;
290
+ this.debug("selection:cleared applied");
291
+ this.updateImages();
292
+ };
293
+
294
+ private onSceneLayoutChanged = () => {
295
+ this.updateImages();
296
+ };
297
+
298
+ private syncToolActiveFromWorkbench(fallbackId?: string | null) {
299
+ const wb = this.context?.services.get<WorkbenchService>("WorkbenchService");
300
+ const activeId = wb?.activeToolId;
301
+ if (typeof activeId === "string" || activeId === null) {
302
+ this.isToolActive = activeId === this.id;
303
+ return;
304
+ }
305
+ this.isToolActive = fallbackId === this.id;
306
+ }
307
+
308
+ private isImageEditingVisible(): boolean {
309
+ return (
310
+ this.isToolActive || this.isImageSelectionActive || !!this.focusedImageId
311
+ );
312
+ }
313
+
314
+ private isDebugEnabled(): boolean {
315
+ return !!this.getConfig<boolean>("image.debug", false);
316
+ }
317
+
318
+ private debug(message: string, payload?: any) {
319
+ if (!this.isDebugEnabled()) return;
320
+ if (payload === undefined) {
321
+ console.log(`[ImageTool] ${message}`);
322
+ return;
323
+ }
324
+ console.log(`[ImageTool] ${message}`, payload);
325
+ }
326
+
327
+ contribute() {
328
+ return {
329
+ [ContributionPointIds.TOOLS]: [
330
+ {
331
+ id: this.id,
332
+ name: "Image",
333
+ interaction: "session",
334
+ commands: {
335
+ begin: "resetWorkingImages",
336
+ commit: "completeImages",
337
+ rollback: "resetWorkingImages",
338
+ },
339
+ session: {
340
+ autoBegin: true,
341
+ leavePolicy: "block",
342
+ },
343
+ },
344
+ ],
345
+ [ContributionPointIds.CONFIGURATIONS]: [
346
+ {
347
+ id: "image.items",
348
+ type: "array",
349
+ label: "Images",
350
+ default: [],
351
+ },
352
+ {
353
+ id: "image.debug",
354
+ type: "boolean",
355
+ label: "Image Debug Log",
356
+ default: false,
357
+ },
358
+ {
359
+ id: "image.frame.strokeColor",
360
+ type: "color",
361
+ label: "Image Frame Stroke Color",
362
+ default: "#FF0000",
363
+ },
364
+ {
365
+ id: "image.frame.strokeWidth",
366
+ type: "number",
367
+ label: "Image Frame Stroke Width",
368
+ min: 0,
369
+ max: 20,
370
+ step: 0.5,
371
+ default: 2,
372
+ },
373
+ {
374
+ id: "image.frame.strokeStyle",
375
+ type: "select",
376
+ label: "Image Frame Stroke Style",
377
+ options: ["solid", "dashed", "hidden"],
378
+ default: "solid",
379
+ },
380
+ {
381
+ id: "image.frame.dashLength",
382
+ type: "number",
383
+ label: "Image Frame Dash Length",
384
+ min: 1,
385
+ max: 40,
386
+ step: 1,
387
+ default: 8,
388
+ },
389
+ {
390
+ id: "image.frame.innerBackground",
391
+ type: "color",
392
+ label: "Image Frame Inner Background",
393
+ default: "rgba(0,0,0,0)",
394
+ },
395
+ {
396
+ id: "image.frame.outerBackground",
397
+ type: "color",
398
+ label: "Image Frame Outer Background",
399
+ default: "rgba(0,0,0,0.18)",
400
+ },
401
+ ] as ConfigurationContribution[],
402
+ [ContributionPointIds.COMMANDS]: [
403
+ {
404
+ command: "addImage",
405
+ title: "Add Image",
406
+ handler: async (url: string, options?: Partial<ImageItem>) => {
407
+ const result = await this.upsertImageEntry(url, {
408
+ mode: "add",
409
+ addOptions: options,
410
+ });
411
+ return result.id;
412
+ },
413
+ },
414
+ {
415
+ command: "upsertImage",
416
+ title: "Upsert Image",
417
+ handler: async (url: string, options: UpsertImageOptions = {}) => {
418
+ return await this.upsertImageEntry(url, options);
419
+ },
420
+ },
421
+ {
422
+ command: "getWorkingImages",
423
+ title: "Get Working Images",
424
+ handler: () => {
425
+ return this.cloneItems(this.workingItems);
426
+ },
427
+ },
428
+ {
429
+ command: "setWorkingImage",
430
+ title: "Set Working Image",
431
+ handler: (id: string, updates: Partial<ImageItem>) => {
432
+ this.updateImageInWorking(id, updates);
433
+ },
434
+ },
435
+ {
436
+ command: "resetWorkingImages",
437
+ title: "Reset Working Images",
438
+ handler: () => {
439
+ this.workingItems = this.cloneItems(this.items);
440
+ this.hasWorkingChanges = false;
441
+ this.updateImages();
442
+ },
443
+ },
444
+ {
445
+ command: "completeImages",
446
+ title: "Complete Images",
447
+ handler: async () => {
448
+ return await this.commitWorkingImagesAsCropped();
449
+ },
450
+ },
451
+ {
452
+ command: "exportImageFrameUrl",
453
+ title: "Export Image Frame Url",
454
+ handler: async (
455
+ options: { multiplier?: number; format?: "png" | "jpeg" } = {},
456
+ ) => {
457
+ return await this.exportImageFrameUrl(options);
458
+ },
459
+ },
460
+ {
461
+ command: "fitImageToArea",
462
+ title: "Fit Image to Area",
463
+ handler: async (
464
+ id: string,
465
+ area: {
466
+ width: number;
467
+ height: number;
468
+ left?: number;
469
+ top?: number;
470
+ },
471
+ ) => {
472
+ await this.fitImageToArea(id, area);
473
+ },
474
+ },
475
+ {
476
+ command: "fitImageToDefaultArea",
477
+ title: "Fit Image to Default Area",
478
+ handler: async (id: string) => {
479
+ await this.fitImageToDefaultArea(id);
480
+ },
481
+ },
482
+ {
483
+ command: "removeImage",
484
+ title: "Remove Image",
485
+ handler: (id: string) => {
486
+ const removed = this.items.find((item) => item.id === id);
487
+ const next = this.items.filter((item) => item.id !== id);
488
+ if (next.length !== this.items.length) {
489
+ this.purgeSourceSizeCacheForItem(removed);
490
+ if (this.focusedImageId === id) {
491
+ this.focusedImageId = null;
492
+ this.isImageSelectionActive = false;
493
+ }
494
+ this.updateConfig(next);
495
+ }
496
+ },
497
+ },
498
+ {
499
+ command: "updateImage",
500
+ title: "Update Image",
501
+ handler: async (
502
+ id: string,
503
+ updates: Partial<ImageItem>,
504
+ options: UpdateImageOptions = {},
505
+ ) => {
506
+ await this.updateImage(id, updates, options);
507
+ },
508
+ },
509
+ {
510
+ command: "clearImages",
511
+ title: "Clear Images",
512
+ handler: () => {
513
+ this.sourceSizeBySrc.clear();
514
+ this.focusedImageId = null;
515
+ this.isImageSelectionActive = false;
516
+ this.updateConfig([]);
517
+ },
518
+ },
519
+ {
520
+ command: "bringToFront",
521
+ title: "Bring Image to Front",
522
+ handler: (id: string) => {
523
+ const index = this.items.findIndex((item) => item.id === id);
524
+ if (index !== -1 && index < this.items.length - 1) {
525
+ const next = [...this.items];
526
+ const [item] = next.splice(index, 1);
527
+ next.push(item);
528
+ this.updateConfig(next);
529
+ }
530
+ },
531
+ },
532
+ {
533
+ command: "sendToBack",
534
+ title: "Send Image to Back",
535
+ handler: (id: string) => {
536
+ const index = this.items.findIndex((item) => item.id === id);
537
+ if (index > 0) {
538
+ const next = [...this.items];
539
+ const [item] = next.splice(index, 1);
540
+ next.unshift(item);
541
+ this.updateConfig(next);
542
+ }
543
+ },
544
+ },
545
+ ] as CommandContribution[],
546
+ };
547
+ }
548
+
549
+ private normalizeItem(item: ImageItem): ImageItem {
550
+ const url = typeof item.url === "string" ? item.url : "";
551
+ const sourceUrl =
552
+ typeof item.sourceUrl === "string" && item.sourceUrl.length > 0
553
+ ? item.sourceUrl
554
+ : url;
555
+ const committedUrl =
556
+ typeof item.committedUrl === "string" && item.committedUrl.length > 0
557
+ ? item.committedUrl
558
+ : undefined;
559
+
560
+ return {
561
+ ...item,
562
+ url: url || sourceUrl,
563
+ sourceUrl,
564
+ committedUrl,
565
+ opacity: Number.isFinite(item.opacity as any) ? item.opacity : 1,
566
+ scale: Number.isFinite(item.scale as any) ? item.scale : 1,
567
+ angle: Number.isFinite(item.angle as any) ? item.angle : 0,
568
+ left: Number.isFinite(item.left as any) ? item.left : 0.5,
569
+ top: Number.isFinite(item.top as any) ? item.top : 0.5,
570
+ };
571
+ }
572
+
573
+ private normalizeItems(items: ImageItem[]): ImageItem[] {
574
+ return (items || []).map((item) => this.normalizeItem(item));
575
+ }
576
+
577
+ private cloneItems(items: ImageItem[]): ImageItem[] {
578
+ return this.normalizeItems((items || []).map((i) => ({ ...i })));
579
+ }
580
+
581
+ private generateId(): string {
582
+ return Math.random().toString(36).substring(2, 9);
583
+ }
584
+
585
+ private getImageIdFromActiveObject(): string | null {
586
+ const active = this.canvasService?.canvas.getActiveObject() as any;
587
+ if (
588
+ active?.data?.layerId === IMAGE_OBJECT_LAYER_ID &&
589
+ typeof active?.data?.id === "string"
590
+ ) {
591
+ return active.data.id;
592
+ }
593
+ return null;
594
+ }
595
+
596
+ private resolveReplaceTargetId(explicitId?: string | null): string | null {
597
+ const has = (id: string | null | undefined) =>
598
+ !!id && this.items.some((item) => item.id === id);
599
+
600
+ if (has(explicitId)) return explicitId as string;
601
+ if (has(this.focusedImageId)) return this.focusedImageId as string;
602
+
603
+ const activeId = this.getImageIdFromActiveObject();
604
+ if (has(activeId)) return activeId;
605
+
606
+ if (this.items.length === 1) return this.items[0].id;
607
+ return null;
608
+ }
609
+
610
+ private async addImageEntry(
611
+ url: string,
612
+ options?: Partial<ImageItem>,
613
+ fitOnAdd = true,
614
+ ): Promise<string> {
615
+ const id = this.generateId();
616
+ const newItem = this.normalizeItem({
617
+ id,
618
+ url,
619
+ opacity: 1,
620
+ ...options,
621
+ } as ImageItem);
622
+
623
+ this.focusedImageId = id;
624
+ this.isImageSelectionActive = true;
625
+ this.suppressSelectionClearUntil = Date.now() + IMAGE_REPLACE_GUARD_MS;
626
+ const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
627
+ const waitLoaded = this.waitImageLoaded(id, true);
628
+ this.updateConfig([...this.items, newItem]);
629
+ this.addItemToWorkingSessionIfNeeded(newItem, sessionDirtyBeforeAdd);
630
+ const loaded = await waitLoaded;
631
+ if (loaded && fitOnAdd) {
632
+ await this.fitImageToDefaultArea(id);
633
+ }
634
+ if (loaded) {
635
+ this.focusImageSelection(id);
636
+ }
637
+ return id;
638
+ }
639
+
640
+ private async upsertImageEntry(
641
+ url: string,
642
+ options: UpsertImageOptions = {},
643
+ ): Promise<{ id: string; mode: "replace" | "add" }> {
644
+ const mode = options.mode || "auto";
645
+ const fitOnAdd = options.fitOnAdd !== false;
646
+ if (mode === "add") {
647
+ const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
648
+ return { id, mode: "add" };
649
+ }
650
+
651
+ const targetId = this.resolveReplaceTargetId(options.id ?? null);
652
+ if (targetId) {
653
+ await this.updateImageInConfig(targetId, { url });
654
+ return { id: targetId, mode: "replace" };
655
+ }
656
+
657
+ if (mode === "replace" || options.createIfMissing === false) {
658
+ throw new Error("replace-target-not-found");
659
+ }
660
+
661
+ const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
662
+ return { id, mode: "add" };
663
+ }
664
+
665
+ private addItemToWorkingSessionIfNeeded(
666
+ item: ImageItem,
667
+ sessionDirtyBeforeAdd: boolean,
668
+ ) {
669
+ if (!sessionDirtyBeforeAdd || !this.isToolActive) return;
670
+ if (this.workingItems.some((existing) => existing.id === item.id)) return;
671
+ this.workingItems = this.cloneItems([...this.workingItems, item]);
672
+ this.updateImages();
673
+ }
674
+
675
+ private async updateImage(
676
+ id: string,
677
+ updates: Partial<ImageItem>,
678
+ options: UpdateImageOptions = {},
679
+ ) {
680
+ this.syncToolActiveFromWorkbench();
681
+ const target = options.target || "auto";
682
+
683
+ if (target === "working" || (target === "auto" && this.isToolActive)) {
684
+ this.updateImageInWorking(id, updates);
685
+ return;
686
+ }
687
+
688
+ await this.updateImageInConfig(id, updates);
689
+ }
690
+
691
+ private getConfig<T>(key: string, fallback?: T): T | undefined {
692
+ if (!this.context) return fallback;
693
+ const configService = this.context.services.get<ConfigurationService>(
694
+ "ConfigurationService",
695
+ );
696
+ if (!configService) return fallback;
697
+ return (configService.get(key, fallback) as T) ?? fallback;
698
+ }
699
+
700
+ private updateConfig(newItems: ImageItem[], skipCanvasUpdate = false) {
701
+ if (!this.context) return;
702
+
703
+ this.isUpdatingConfig = true;
704
+ this.items = this.normalizeItems(newItems);
705
+ if (!this.isToolActive || !this.hasWorkingChanges) {
706
+ this.workingItems = this.cloneItems(this.items);
707
+ this.hasWorkingChanges = false;
708
+ }
709
+
710
+ const configService = this.context.services.get<ConfigurationService>(
711
+ "ConfigurationService",
712
+ );
713
+ configService?.update("image.items", this.items);
714
+
715
+ if (!skipCanvasUpdate) {
716
+ this.updateImages();
717
+ }
718
+
719
+ setTimeout(() => {
720
+ this.isUpdatingConfig = false;
721
+ }, 50);
722
+ }
723
+
724
+ private getFrameRect(): FrameRect {
725
+ if (!this.canvasService) {
726
+ return { left: 0, top: 0, width: 0, height: 0 };
727
+ }
728
+ const configService = this.context?.services.get<ConfigurationService>(
729
+ "ConfigurationService",
730
+ );
731
+ if (!configService) {
732
+ return { left: 0, top: 0, width: 0, height: 0 };
733
+ }
734
+
735
+ const sizeState = readSizeState(configService);
736
+ const layout = computeSceneLayout(this.canvasService, sizeState);
737
+ if (!layout) {
738
+ return { left: 0, top: 0, width: 0, height: 0 };
739
+ }
740
+
741
+ return {
742
+ left: layout.cutRect.left,
743
+ top: layout.cutRect.top,
744
+ width: layout.cutRect.width,
745
+ height: layout.cutRect.height,
746
+ };
747
+ }
748
+
749
+ private async resolveDefaultFitArea(): Promise<DielineFitArea | null> {
750
+ if (!this.context || !this.canvasService) return null;
751
+ const commandService = this.context.services.get<any>("CommandService");
752
+ if (!commandService) return null;
753
+
754
+ try {
755
+ const layout = await Promise.resolve(
756
+ commandService.executeCommand("getSceneLayout"),
757
+ );
758
+ const cutRect = layout?.cutRect;
759
+ const width = Number(cutRect?.width);
760
+ const height = Number(cutRect?.height);
761
+ const left = Number(cutRect?.left);
762
+ const top = Number(cutRect?.top);
763
+
764
+ if (
765
+ !Number.isFinite(width) ||
766
+ !Number.isFinite(height) ||
767
+ !Number.isFinite(left) ||
768
+ !Number.isFinite(top)
769
+ ) {
770
+ return null;
771
+ }
772
+
773
+ return {
774
+ width: Math.max(1, width),
775
+ height: Math.max(1, height),
776
+ left: left + width / 2,
777
+ top: top + height / 2,
778
+ };
779
+ } catch {
780
+ return null;
781
+ }
782
+ }
783
+
784
+ private async fitImageToDefaultArea(id: string) {
785
+ if (!this.canvasService) return;
786
+ const area = await this.resolveDefaultFitArea();
787
+
788
+ if (area) {
789
+ await this.fitImageToArea(id, area);
790
+ return;
791
+ }
792
+
793
+ const canvasW = Math.max(1, this.canvasService.canvas.width || 0);
794
+ const canvasH = Math.max(1, this.canvasService.canvas.height || 0);
795
+ await this.fitImageToArea(id, {
796
+ width: canvasW,
797
+ height: canvasH,
798
+ left: canvasW / 2,
799
+ top: canvasH / 2,
800
+ });
801
+ }
802
+
803
+ private getImageObjects(): any[] {
804
+ if (!this.canvasService) return [];
805
+ return this.canvasService.canvas.getObjects().filter((obj: any) => {
806
+ return obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID;
807
+ }) as any[];
808
+ }
809
+
810
+ private getOverlayObjects(): any[] {
811
+ if (!this.canvasService) return [];
812
+ return this.canvasService.getRootLayerObjects(
813
+ IMAGE_OVERLAY_LAYER_ID,
814
+ ) as any[];
815
+ }
816
+
817
+ private getImageObject(id: string): any | undefined {
818
+ return this.getImageObjects().find((obj: any) => obj?.data?.id === id);
819
+ }
820
+
821
+ private clearRenderedImages() {
822
+ if (!this.canvasService) return;
823
+ const canvas = this.canvasService.canvas;
824
+ this.getImageObjects().forEach((obj) => canvas.remove(obj));
825
+ this.canvasService.requestRenderAll();
826
+ }
827
+
828
+ private purgeSourceSizeCacheForItem(item?: ImageItem) {
829
+ if (!item) return;
830
+ const sources = [item.url, item.sourceUrl, item.committedUrl].filter(
831
+ (value): value is string => typeof value === "string" && value.length > 0,
832
+ );
833
+ sources.forEach((src) => this.sourceSizeBySrc.delete(src));
834
+ }
835
+
836
+ private rememberSourceSize(src: string, obj: any) {
837
+ const width = Number(obj?.width || 0);
838
+ const height = Number(obj?.height || 0);
839
+ if (src && width > 0 && height > 0) {
840
+ this.sourceSizeBySrc.set(src, { width, height });
841
+ }
842
+ }
843
+
844
+ private getSourceSize(src: string, obj?: any): SourceSize {
845
+ const cached = src ? this.sourceSizeBySrc.get(src) : undefined;
846
+ if (cached) return cached;
847
+
848
+ const width = Number(obj?.width || 0);
849
+ const height = Number(obj?.height || 0);
850
+ if (src && width > 0 && height > 0) {
851
+ const size = { width, height };
852
+ this.sourceSizeBySrc.set(src, size);
853
+ return size;
854
+ }
855
+
856
+ return { width: 1, height: 1 };
857
+ }
858
+
859
+ private getCoverScale(frame: FrameRect, size: SourceSize): number {
860
+ const sw = Math.max(1, size.width);
861
+ const sh = Math.max(1, size.height);
862
+ const fw = Math.max(1, frame.width);
863
+ const fh = Math.max(1, frame.height);
864
+ return Math.max(fw / sw, fh / sh);
865
+ }
866
+
867
+ private getFrameVisualConfig(): FrameVisualConfig {
868
+ const strokeStyleRaw = (this.getConfig<string>(
869
+ "image.frame.strokeStyle",
870
+ "solid",
871
+ ) || "solid") as string;
872
+ const strokeStyle: "solid" | "dashed" | "hidden" =
873
+ strokeStyleRaw === "dashed" || strokeStyleRaw === "hidden"
874
+ ? strokeStyleRaw
875
+ : "solid";
876
+
877
+ const strokeWidth = Number(
878
+ this.getConfig<number>("image.frame.strokeWidth", 2) ?? 2,
879
+ );
880
+ const dashLength = Number(
881
+ this.getConfig<number>("image.frame.dashLength", 8) ?? 8,
882
+ );
883
+
884
+ return {
885
+ strokeColor:
886
+ this.getConfig<string>("image.frame.strokeColor", "#FF0000") ||
887
+ "#FF0000",
888
+ strokeWidth: Number.isFinite(strokeWidth) ? Math.max(0, strokeWidth) : 2,
889
+ strokeStyle,
890
+ dashLength: Number.isFinite(dashLength) ? Math.max(1, dashLength) : 8,
891
+ innerBackground:
892
+ this.getConfig<string>(
893
+ "image.frame.innerBackground",
894
+ "rgba(0,0,0,0)",
895
+ ) || "rgba(0,0,0,0)",
896
+ outerBackground:
897
+ this.getConfig<string>(
898
+ "image.frame.outerBackground",
899
+ "rgba(0,0,0,0.18)",
900
+ ) || "rgba(0,0,0,0.18)",
901
+ };
902
+ }
903
+
904
+ private resolveRenderImageState(item: ImageItem): RenderImageState {
905
+ const active = this.isToolActive;
906
+ const sourceUrl = item.sourceUrl || item.url;
907
+ const committedUrl = item.committedUrl;
908
+
909
+ if (!active && committedUrl) {
910
+ return {
911
+ src: committedUrl,
912
+ left: 0.5,
913
+ top: 0.5,
914
+ scale: 1,
915
+ angle: 0,
916
+ opacity: item.opacity,
917
+ };
918
+ }
919
+
920
+ return {
921
+ src: sourceUrl || item.url,
922
+ left: Number.isFinite(item.left as any) ? (item.left as number) : 0.5,
923
+ top: Number.isFinite(item.top as any) ? (item.top as number) : 0.5,
924
+ scale: Math.max(0.05, item.scale ?? 1),
925
+ angle: Number.isFinite(item.angle as any) ? (item.angle as number) : 0,
926
+ opacity: item.opacity,
927
+ };
928
+ }
929
+
930
+ private computeCanvasProps(
931
+ render: RenderImageState,
932
+ size: SourceSize,
933
+ frame: FrameRect,
934
+ ) {
935
+ const left = render.left;
936
+ const top = render.top;
937
+ const zoom = render.scale;
938
+ const angle = render.angle;
939
+
940
+ const centerX = frame.left + left * frame.width;
941
+ const centerY = frame.top + top * frame.height;
942
+ const scale = this.getCoverScale(frame, size) * zoom;
943
+
944
+ return {
945
+ left: centerX,
946
+ top: centerY,
947
+ scaleX: scale,
948
+ scaleY: scale,
949
+ angle,
950
+ originX: "center" as const,
951
+ originY: "center" as const,
952
+ uniformScaling: true,
953
+ lockScalingFlip: true,
954
+ selectable: this.isImageEditingVisible(),
955
+ evented: this.isImageEditingVisible(),
956
+ hasControls: this.isImageEditingVisible(),
957
+ hasBorders: this.isImageEditingVisible(),
958
+ opacity: render.opacity,
959
+ };
960
+ }
961
+
962
+ private getCurrentSrc(obj: any): string | undefined {
963
+ if (!obj) return undefined;
964
+ if (typeof obj.getSrc === "function") return obj.getSrc();
965
+ return obj?._originalElement?.src;
966
+ }
967
+
968
+ private async upsertImageObject(
969
+ item: ImageItem,
970
+ frame: FrameRect,
971
+ seq: number,
972
+ ) {
973
+ if (!this.canvasService) return;
974
+ const canvas = this.canvasService.canvas;
975
+ const render = this.resolveRenderImageState(item);
976
+ if (!render.src) return;
977
+
978
+ let obj = this.getImageObject(item.id);
979
+ const currentSrc = this.getCurrentSrc(obj);
980
+
981
+ if (obj && currentSrc && currentSrc !== render.src) {
982
+ canvas.remove(obj);
983
+ obj = undefined;
984
+ }
985
+
986
+ if (!obj) {
987
+ const created = await FabricImage.fromURL(render.src, {
988
+ crossOrigin: "anonymous",
989
+ });
990
+ if (seq !== this.renderSeq) return;
991
+
992
+ created.set({
993
+ data: {
994
+ id: item.id,
995
+ layerId: IMAGE_OBJECT_LAYER_ID,
996
+ type: "image-item",
997
+ },
998
+ } as any);
999
+ canvas.add(created as any);
1000
+ obj = created as any;
1001
+ }
1002
+
1003
+ this.rememberSourceSize(render.src, obj);
1004
+ const sourceSize = this.getSourceSize(render.src, obj);
1005
+ const props = this.computeCanvasProps(render, sourceSize, frame);
1006
+
1007
+ obj.set({
1008
+ ...props,
1009
+ data: {
1010
+ ...(obj.data || {}),
1011
+ id: item.id,
1012
+ layerId: IMAGE_OBJECT_LAYER_ID,
1013
+ type: "image-item",
1014
+ },
1015
+ });
1016
+ obj.setCoords();
1017
+
1018
+ const resolver = this.loadResolvers.get(item.id);
1019
+ if (resolver) {
1020
+ resolver();
1021
+ this.loadResolvers.delete(item.id);
1022
+ }
1023
+ }
1024
+
1025
+ private syncImageZOrder(items: ImageItem[]) {
1026
+ if (!this.canvasService) return;
1027
+ const canvas = this.canvasService.canvas;
1028
+
1029
+ const objects = canvas.getObjects();
1030
+ let insertIndex = 0;
1031
+
1032
+ const backgroundLayer = this.canvasService.getLayer("background");
1033
+ if (backgroundLayer) {
1034
+ const bgIndex = objects.indexOf(backgroundLayer as any);
1035
+ if (bgIndex >= 0) insertIndex = bgIndex + 1;
1036
+ }
1037
+
1038
+ items.forEach((item) => {
1039
+ const obj = this.getImageObject(item.id);
1040
+ if (!obj) return;
1041
+ canvas.moveObjectTo(obj, insertIndex);
1042
+ insertIndex += 1;
1043
+ });
1044
+
1045
+ const overlayObjects = this.getOverlayObjects().sort((a: any, b: any) => {
1046
+ const az = Number(a?.data?.zIndex ?? 0);
1047
+ const bz = Number(b?.data?.zIndex ?? 0);
1048
+ return az - bz;
1049
+ });
1050
+ overlayObjects.forEach((obj) => {
1051
+ canvas.bringObjectToFront(obj);
1052
+ });
1053
+ }
1054
+
1055
+ private buildOverlaySpecs(frame: FrameRect): RenderObjectSpec[] {
1056
+ const visible = this.isImageEditingVisible();
1057
+ if (
1058
+ !visible ||
1059
+ frame.width <= 0 ||
1060
+ frame.height <= 0 ||
1061
+ !this.canvasService
1062
+ ) {
1063
+ this.debug("overlay:hidden", {
1064
+ visible,
1065
+ frame,
1066
+ isToolActive: this.isToolActive,
1067
+ isImageSelectionActive: this.isImageSelectionActive,
1068
+ focusedImageId: this.focusedImageId,
1069
+ });
1070
+ return [];
1071
+ }
1072
+
1073
+ const canvasW = this.canvasService.canvas.width || 0;
1074
+ const canvasH = this.canvasService.canvas.height || 0;
1075
+ const visual = this.getFrameVisualConfig();
1076
+
1077
+ const topH = Math.max(0, frame.top);
1078
+ const bottomH = Math.max(0, canvasH - (frame.top + frame.height));
1079
+ const leftW = Math.max(0, frame.left);
1080
+ const rightW = Math.max(0, canvasW - (frame.left + frame.width));
1081
+
1082
+ const mask: RenderObjectSpec[] = [
1083
+ {
1084
+ id: "image.cropMask.top",
1085
+ type: "rect",
1086
+ data: { id: "image.cropMask.top", zIndex: 1 },
1087
+ props: {
1088
+ left: canvasW / 2,
1089
+ top: topH / 2,
1090
+ width: canvasW,
1091
+ height: topH,
1092
+ originX: "center",
1093
+ originY: "center",
1094
+ fill: visual.outerBackground,
1095
+ selectable: false,
1096
+ evented: false,
1097
+ },
1098
+ },
1099
+ {
1100
+ id: "image.cropMask.bottom",
1101
+ type: "rect",
1102
+ data: { id: "image.cropMask.bottom", zIndex: 2 },
1103
+ props: {
1104
+ left: canvasW / 2,
1105
+ top: frame.top + frame.height + bottomH / 2,
1106
+ width: canvasW,
1107
+ height: bottomH,
1108
+ originX: "center",
1109
+ originY: "center",
1110
+ fill: visual.outerBackground,
1111
+ selectable: false,
1112
+ evented: false,
1113
+ },
1114
+ },
1115
+ {
1116
+ id: "image.cropMask.left",
1117
+ type: "rect",
1118
+ data: { id: "image.cropMask.left", zIndex: 3 },
1119
+ props: {
1120
+ left: leftW / 2,
1121
+ top: frame.top + frame.height / 2,
1122
+ width: leftW,
1123
+ height: frame.height,
1124
+ originX: "center",
1125
+ originY: "center",
1126
+ fill: visual.outerBackground,
1127
+ selectable: false,
1128
+ evented: false,
1129
+ },
1130
+ },
1131
+ {
1132
+ id: "image.cropMask.right",
1133
+ type: "rect",
1134
+ data: { id: "image.cropMask.right", zIndex: 4 },
1135
+ props: {
1136
+ left: frame.left + frame.width + rightW / 2,
1137
+ top: frame.top + frame.height / 2,
1138
+ width: rightW,
1139
+ height: frame.height,
1140
+ originX: "center",
1141
+ originY: "center",
1142
+ fill: visual.outerBackground,
1143
+ selectable: false,
1144
+ evented: false,
1145
+ },
1146
+ },
1147
+ ];
1148
+
1149
+ const frameSpec: RenderObjectSpec = {
1150
+ id: "image.cropFrame",
1151
+ type: "rect",
1152
+ data: { id: "image.cropFrame", zIndex: 5 },
1153
+ props: {
1154
+ left: frame.left + frame.width / 2,
1155
+ top: frame.top + frame.height / 2,
1156
+ width: frame.width,
1157
+ height: frame.height,
1158
+ originX: "center",
1159
+ originY: "center",
1160
+ fill: visual.innerBackground,
1161
+ stroke:
1162
+ visual.strokeStyle === "hidden"
1163
+ ? "rgba(0,0,0,0)"
1164
+ : visual.strokeColor,
1165
+ strokeWidth: visual.strokeStyle === "hidden" ? 0 : visual.strokeWidth,
1166
+ strokeDashArray:
1167
+ visual.strokeStyle === "dashed"
1168
+ ? [visual.dashLength, visual.dashLength]
1169
+ : undefined,
1170
+ selectable: false,
1171
+ evented: false,
1172
+ },
1173
+ };
1174
+
1175
+ return [...mask, frameSpec];
1176
+ }
1177
+
1178
+ private updateImages() {
1179
+ void this.updateImagesAsync();
1180
+ }
1181
+
1182
+ private async updateImagesAsync() {
1183
+ if (!this.canvasService) return;
1184
+ this.syncToolActiveFromWorkbench();
1185
+ const seq = ++this.renderSeq;
1186
+
1187
+ const renderItems = this.isToolActive ? this.workingItems : this.items;
1188
+ const frame = this.getFrameRect();
1189
+ const desiredIds = new Set(renderItems.map((item) => item.id));
1190
+ if (this.focusedImageId && !desiredIds.has(this.focusedImageId)) {
1191
+ this.focusedImageId = null;
1192
+ this.isImageSelectionActive = false;
1193
+ }
1194
+
1195
+ this.getImageObjects().forEach((obj: any) => {
1196
+ const id = obj?.data?.id;
1197
+ if (typeof id === "string" && !desiredIds.has(id)) {
1198
+ this.canvasService?.canvas.remove(obj);
1199
+ }
1200
+ });
1201
+
1202
+ for (const item of renderItems) {
1203
+ if (seq !== this.renderSeq) return;
1204
+ await this.upsertImageObject(item, frame, seq);
1205
+ }
1206
+ if (seq !== this.renderSeq) return;
1207
+
1208
+ this.syncImageZOrder(renderItems);
1209
+ const overlaySpecs = this.buildOverlaySpecs(frame);
1210
+ await this.canvasService.applyObjectSpecsToRootLayer(
1211
+ IMAGE_OVERLAY_LAYER_ID,
1212
+ overlaySpecs,
1213
+ );
1214
+ this.syncImageZOrder(renderItems);
1215
+ const overlayCanvasCount = this.getOverlayObjects().length;
1216
+
1217
+ this.debug("render:done", {
1218
+ seq,
1219
+ renderCount: renderItems.length,
1220
+ overlayCount: overlaySpecs.length,
1221
+ overlayCanvasCount,
1222
+ isToolActive: this.isToolActive,
1223
+ isImageSelectionActive: this.isImageSelectionActive,
1224
+ focusedImageId: this.focusedImageId,
1225
+ });
1226
+ this.canvasService.requestRenderAll();
1227
+ }
1228
+
1229
+ private clampNormalized(value: number): number {
1230
+ return Math.max(-1, Math.min(2, value));
1231
+ }
1232
+
1233
+ private onObjectModified = (e: any) => {
1234
+ if (!this.isToolActive) return;
1235
+ const target = e?.target;
1236
+ const id = target?.data?.id;
1237
+ const layerId = target?.data?.layerId;
1238
+ if (typeof id !== "string" || layerId !== IMAGE_OBJECT_LAYER_ID) return;
1239
+
1240
+ const frame = this.getFrameRect();
1241
+ if (!frame.width || !frame.height) return;
1242
+
1243
+ const center = target.getCenterPoint
1244
+ ? target.getCenterPoint()
1245
+ : new Point(target.left ?? 0, target.top ?? 0);
1246
+
1247
+ const objectScale = Number.isFinite(target?.scaleX) ? target.scaleX : 1;
1248
+
1249
+ const workingItem = this.workingItems.find((item) => item.id === id);
1250
+ const sourceKey = workingItem?.sourceUrl || workingItem?.url || "";
1251
+ const sourceSize = this.getSourceSize(sourceKey, target);
1252
+ const coverScale = this.getCoverScale(frame, sourceSize);
1253
+
1254
+ const updates: Partial<ImageItem> = {
1255
+ left: this.clampNormalized((center.x - frame.left) / frame.width),
1256
+ top: this.clampNormalized((center.y - frame.top) / frame.height),
1257
+ angle: Number.isFinite(target.angle) ? target.angle : 0,
1258
+ scale: Math.max(0.05, (objectScale || 1) / coverScale),
1259
+ };
1260
+
1261
+ this.focusedImageId = id;
1262
+ this.updateImageInWorking(id, updates);
1263
+ };
1264
+
1265
+ private updateImageInWorking(id: string, updates: Partial<ImageItem>) {
1266
+ const index = this.workingItems.findIndex((item) => item.id === id);
1267
+ if (index < 0) return;
1268
+
1269
+ const next = [...this.workingItems];
1270
+ next[index] = this.normalizeItem({ ...next[index], ...updates });
1271
+ this.workingItems = next;
1272
+ this.hasWorkingChanges = true;
1273
+ this.isImageSelectionActive = true;
1274
+ this.focusedImageId = id;
1275
+ if (this.isToolActive) {
1276
+ this.updateImages();
1277
+ }
1278
+ }
1279
+
1280
+ private async updateImageInConfig(id: string, updates: Partial<ImageItem>) {
1281
+ const index = this.items.findIndex((item) => item.id === id);
1282
+ if (index < 0) return;
1283
+
1284
+ const replacingSource =
1285
+ typeof updates.url === "string" && updates.url.length > 0;
1286
+ const next = [...this.items];
1287
+ const base = next[index];
1288
+ const replacingUrl = replacingSource ? (updates.url as string) : undefined;
1289
+
1290
+ next[index] = this.normalizeItem({
1291
+ ...base,
1292
+ ...updates,
1293
+ ...(replacingSource
1294
+ ? {
1295
+ url: replacingUrl,
1296
+ sourceUrl: replacingUrl,
1297
+ committedUrl: undefined,
1298
+ scale: updates.scale ?? 1,
1299
+ angle: updates.angle ?? 0,
1300
+ left: updates.left ?? 0.5,
1301
+ top: updates.top ?? 0.5,
1302
+ }
1303
+ : {}),
1304
+ });
1305
+
1306
+ this.updateConfig(next);
1307
+
1308
+ if (replacingSource) {
1309
+ this.focusedImageId = id;
1310
+ this.isImageSelectionActive = true;
1311
+ this.suppressSelectionClearUntil = Date.now() + IMAGE_REPLACE_GUARD_MS;
1312
+ this.debug("replace:image:begin", { id, replacingUrl });
1313
+ this.purgeSourceSizeCacheForItem(base);
1314
+ const loaded = await this.waitImageLoaded(id, true);
1315
+ this.debug("replace:image:loaded", { id, loaded });
1316
+ if (loaded) {
1317
+ await this.refitImageToFrame(id);
1318
+ this.focusImageSelection(id);
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ private waitImageLoaded(id: string, forceWait = false): Promise<boolean> {
1324
+ if (!forceWait && this.getImageObject(id)) {
1325
+ return Promise.resolve(true);
1326
+ }
1327
+
1328
+ return new Promise<boolean>((resolve) => {
1329
+ const timeout = setTimeout(() => {
1330
+ this.loadResolvers.delete(id);
1331
+ resolve(false);
1332
+ }, 4000);
1333
+
1334
+ this.loadResolvers.set(id, () => {
1335
+ clearTimeout(timeout);
1336
+ resolve(true);
1337
+ });
1338
+ });
1339
+ }
1340
+
1341
+ private async refitImageToFrame(id: string) {
1342
+ const obj = this.getImageObject(id);
1343
+ if (!obj || !this.canvasService) return;
1344
+ const current = this.items.find((item) => item.id === id);
1345
+ if (!current) return;
1346
+ const render = this.resolveRenderImageState(current);
1347
+
1348
+ this.rememberSourceSize(render.src, obj);
1349
+ const source = this.getSourceSize(render.src, obj);
1350
+ const frame = this.getFrameRect();
1351
+ const coverScale = this.getCoverScale(frame, source);
1352
+
1353
+ const currentScale = obj.scaleX || 1;
1354
+ const zoom = Math.max(0.05, currentScale / coverScale);
1355
+
1356
+ const updated: Partial<ImageItem> = {
1357
+ scale: Number.isFinite(zoom) ? zoom : 1,
1358
+ angle: 0,
1359
+ left: 0.5,
1360
+ top: 0.5,
1361
+ };
1362
+
1363
+ const index = this.items.findIndex((item) => item.id === id);
1364
+ if (index < 0) return;
1365
+
1366
+ const next = [...this.items];
1367
+ next[index] = this.normalizeItem({ ...next[index], ...updated });
1368
+ this.updateConfig(next);
1369
+ this.workingItems = this.cloneItems(next);
1370
+ this.hasWorkingChanges = false;
1371
+ this.isImageSelectionActive = true;
1372
+ this.focusedImageId = id;
1373
+ this.updateImages();
1374
+ }
1375
+
1376
+ private focusImageSelection(id: string) {
1377
+ if (!this.canvasService) return;
1378
+ const obj = this.getImageObject(id);
1379
+ if (!obj) return;
1380
+
1381
+ this.isImageSelectionActive = true;
1382
+ this.focusedImageId = id;
1383
+ this.suppressSelectionClearUntil = Date.now() + 700;
1384
+ obj.set({
1385
+ selectable: true,
1386
+ evented: true,
1387
+ hasControls: true,
1388
+ hasBorders: true,
1389
+ });
1390
+ this.canvasService.canvas.setActiveObject(obj);
1391
+ this.debug("focus:image", { id });
1392
+ this.canvasService.requestRenderAll();
1393
+ this.updateImages();
1394
+ }
1395
+
1396
+ private async fitImageToArea(
1397
+ id: string,
1398
+ area: { width: number; height: number; left?: number; top?: number },
1399
+ ) {
1400
+ if (!this.canvasService) return;
1401
+
1402
+ const loaded = await this.waitImageLoaded(id, false);
1403
+ if (!loaded) return;
1404
+
1405
+ const obj = this.getImageObject(id);
1406
+ if (!obj) return;
1407
+ const renderItems = this.isToolActive ? this.workingItems : this.items;
1408
+ const current = renderItems.find((item) => item.id === id);
1409
+ if (!current) return;
1410
+ const render = this.resolveRenderImageState(current);
1411
+
1412
+ this.rememberSourceSize(render.src, obj);
1413
+ const source = this.getSourceSize(render.src, obj);
1414
+ const frame = this.getFrameRect();
1415
+ const baseCover = this.getCoverScale(frame, source);
1416
+
1417
+ const desiredScale = Math.max(
1418
+ Math.max(1, area.width) / Math.max(1, source.width),
1419
+ Math.max(1, area.height) / Math.max(1, source.height),
1420
+ );
1421
+
1422
+ const canvasW = this.canvasService.canvas.width || 1;
1423
+ const canvasH = this.canvasService.canvas.height || 1;
1424
+
1425
+ const areaLeftInput = area.left ?? 0.5;
1426
+ const areaTopInput = area.top ?? 0.5;
1427
+
1428
+ const areaLeftPx =
1429
+ areaLeftInput <= 1.5 ? areaLeftInput * canvasW : areaLeftInput;
1430
+ const areaTopPx =
1431
+ areaTopInput <= 1.5 ? areaTopInput * canvasH : areaTopInput;
1432
+
1433
+ const updates: Partial<ImageItem> = {
1434
+ scale: Math.max(0.05, desiredScale / baseCover),
1435
+ left: this.clampNormalized(
1436
+ (areaLeftPx - frame.left) / Math.max(1, frame.width),
1437
+ ),
1438
+ top: this.clampNormalized(
1439
+ (areaTopPx - frame.top) / Math.max(1, frame.height),
1440
+ ),
1441
+ };
1442
+
1443
+ if (this.isToolActive) {
1444
+ this.updateImageInWorking(id, updates);
1445
+ return;
1446
+ }
1447
+
1448
+ await this.updateImageInConfig(id, updates);
1449
+ }
1450
+
1451
+ private async commitWorkingImagesAsCropped() {
1452
+ if (!this.canvasService) {
1453
+ return { ok: false, reason: "canvas-not-ready" };
1454
+ }
1455
+
1456
+ await this.updateImagesAsync();
1457
+
1458
+ const frame = this.getFrameRect();
1459
+ if (!frame.width || !frame.height) {
1460
+ return { ok: false, reason: "frame-not-ready" };
1461
+ }
1462
+
1463
+ const focusId =
1464
+ this.resolveReplaceTargetId(this.focusedImageId) ||
1465
+ (this.workingItems.length === 1 ? this.workingItems[0].id : null);
1466
+
1467
+ const next: ImageItem[] = [];
1468
+ for (const item of this.workingItems) {
1469
+ const url = await this.exportCroppedImageByIds([item.id], {
1470
+ multiplier: 2,
1471
+ format: "png",
1472
+ });
1473
+
1474
+ const sourceUrl = item.sourceUrl || item.url;
1475
+ const previousCommitted = item.committedUrl;
1476
+ next.push(
1477
+ this.normalizeItem({
1478
+ ...item,
1479
+ url,
1480
+ sourceUrl,
1481
+ committedUrl: url,
1482
+ }),
1483
+ );
1484
+ if (previousCommitted && previousCommitted !== url) {
1485
+ this.sourceSizeBySrc.delete(previousCommitted);
1486
+ }
1487
+ }
1488
+
1489
+ this.hasWorkingChanges = false;
1490
+ this.workingItems = this.cloneItems(next);
1491
+ this.updateConfig(next);
1492
+ if (focusId) {
1493
+ this.focusedImageId = focusId;
1494
+ this.isImageSelectionActive = true;
1495
+ this.suppressSelectionClearUntil = Date.now() + IMAGE_REPLACE_GUARD_MS;
1496
+ this.focusImageSelection(focusId);
1497
+ }
1498
+ return { ok: true };
1499
+ }
1500
+
1501
+ private async exportCroppedImageByIds(
1502
+ imageIds: string[],
1503
+ options: { multiplier?: number; format?: "png" | "jpeg" },
1504
+ ): Promise<string> {
1505
+ if (!this.canvasService) {
1506
+ throw new Error("CanvasService not initialized");
1507
+ }
1508
+
1509
+ const frame = this.getFrameRect();
1510
+ const multiplier = Math.max(1, options.multiplier ?? 2);
1511
+ const format = options.format ?? "png";
1512
+
1513
+ const width = Math.max(1, Math.round(frame.width * multiplier));
1514
+ const height = Math.max(1, Math.round(frame.height * multiplier));
1515
+
1516
+ const el = document.createElement("canvas");
1517
+ const tempCanvas = new FabricCanvas(el, {
1518
+ renderOnAddRemove: false,
1519
+ selection: false,
1520
+ enableRetinaScaling: false,
1521
+ preserveObjectStacking: true,
1522
+ } as any);
1523
+ tempCanvas.setDimensions({ width, height });
1524
+
1525
+ const idSet = new Set(imageIds);
1526
+ const sourceObjects = this.canvasService.canvas
1527
+ .getObjects()
1528
+ .filter((obj: any) => {
1529
+ return (
1530
+ obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID &&
1531
+ typeof obj?.data?.id === "string" &&
1532
+ idSet.has(obj.data.id)
1533
+ );
1534
+ });
1535
+
1536
+ for (const source of sourceObjects as any[]) {
1537
+ const clone = await source.clone();
1538
+ const center = source.getCenterPoint
1539
+ ? source.getCenterPoint()
1540
+ : new Point(source.left ?? 0, source.top ?? 0);
1541
+
1542
+ clone.set({
1543
+ originX: "center",
1544
+ originY: "center",
1545
+ left: (center.x - frame.left) * multiplier,
1546
+ top: (center.y - frame.top) * multiplier,
1547
+ scaleX: (source.scaleX || 1) * multiplier,
1548
+ scaleY: (source.scaleY || 1) * multiplier,
1549
+ angle: source.angle || 0,
1550
+ selectable: false,
1551
+ evented: false,
1552
+ });
1553
+ clone.setCoords();
1554
+ tempCanvas.add(clone);
1555
+ }
1556
+
1557
+ tempCanvas.renderAll();
1558
+ const dataUrl = tempCanvas.toDataURL({ format, multiplier: 1 });
1559
+ tempCanvas.dispose();
1560
+
1561
+ const blob = await (await fetch(dataUrl)).blob();
1562
+ return URL.createObjectURL(blob);
1563
+ }
1564
+
1565
+ private async exportImageFrameUrl(
1566
+ options: { multiplier?: number; format?: "png" | "jpeg" } = {},
1567
+ ): Promise<{ url: string }> {
1568
+ if (!this.canvasService) {
1569
+ throw new Error("CanvasService not initialized");
1570
+ }
1571
+
1572
+ const imageIds = this.getImageObjects()
1573
+ .map((obj: any) => obj?.data?.id)
1574
+ .filter((id: any) => typeof id === "string");
1575
+
1576
+ const url = await this.exportCroppedImageByIds(
1577
+ imageIds as string[],
1578
+ options,
1579
+ );
1580
+ return { url };
1581
+ }
1582
+ }