@pooder/kit 5.4.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.test-dist/src/coordinate.js +74 -0
  2. package/.test-dist/src/extensions/background.js +547 -0
  3. package/.test-dist/src/extensions/bridgeSelection.js +20 -0
  4. package/.test-dist/src/extensions/constraints.js +237 -0
  5. package/.test-dist/src/extensions/dieline.js +931 -0
  6. package/.test-dist/src/extensions/dielineShape.js +66 -0
  7. package/.test-dist/src/extensions/edgeScale.js +12 -0
  8. package/.test-dist/src/extensions/feature.js +910 -0
  9. package/.test-dist/src/extensions/featureComplete.js +32 -0
  10. package/.test-dist/src/extensions/film.js +226 -0
  11. package/.test-dist/src/extensions/geometry.js +609 -0
  12. package/.test-dist/src/extensions/image.js +1613 -0
  13. package/.test-dist/src/extensions/index.js +28 -0
  14. package/.test-dist/src/extensions/maskOps.js +334 -0
  15. package/.test-dist/src/extensions/mirror.js +104 -0
  16. package/.test-dist/src/extensions/ruler.js +442 -0
  17. package/.test-dist/src/extensions/sceneLayout.js +96 -0
  18. package/.test-dist/src/extensions/sceneLayoutModel.js +202 -0
  19. package/.test-dist/src/extensions/sceneVisibility.js +55 -0
  20. package/.test-dist/src/extensions/size.js +331 -0
  21. package/.test-dist/src/extensions/tracer.js +709 -0
  22. package/.test-dist/src/extensions/white-ink.js +1200 -0
  23. package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
  24. package/.test-dist/src/index.js +18 -0
  25. package/.test-dist/src/services/CanvasService.js +1011 -0
  26. package/.test-dist/src/services/ViewportSystem.js +76 -0
  27. package/.test-dist/src/services/index.js +25 -0
  28. package/.test-dist/src/services/renderSpec.js +2 -0
  29. package/.test-dist/src/services/visibility.js +54 -0
  30. package/.test-dist/src/units.js +30 -0
  31. package/.test-dist/tests/run.js +148 -0
  32. package/CHANGELOG.md +6 -0
  33. package/dist/index.d.mts +150 -62
  34. package/dist/index.d.ts +150 -62
  35. package/dist/index.js +2219 -1714
  36. package/dist/index.mjs +2226 -1718
  37. package/package.json +1 -1
  38. package/src/coordinate.ts +106 -106
  39. package/src/extensions/background.ts +716 -323
  40. package/src/extensions/bridgeSelection.ts +17 -17
  41. package/src/extensions/constraints.ts +322 -322
  42. package/src/extensions/dieline.ts +1169 -1149
  43. package/src/extensions/dielineShape.ts +109 -109
  44. package/src/extensions/edgeScale.ts +19 -19
  45. package/src/extensions/feature.ts +1140 -1137
  46. package/src/extensions/featureComplete.ts +46 -46
  47. package/src/extensions/film.ts +270 -266
  48. package/src/extensions/geometry.ts +851 -885
  49. package/src/extensions/image.ts +2007 -2054
  50. package/src/extensions/index.ts +10 -11
  51. package/src/extensions/maskOps.ts +283 -283
  52. package/src/extensions/mirror.ts +128 -128
  53. package/src/extensions/ruler.ts +664 -654
  54. package/src/extensions/sceneLayout.ts +140 -140
  55. package/src/extensions/sceneLayoutModel.ts +364 -364
  56. package/src/extensions/size.ts +389 -389
  57. package/src/extensions/tracer.ts +1019 -1019
  58. package/src/extensions/white-ink.ts +1508 -1575
  59. package/src/extensions/wrappedOffsets.ts +33 -33
  60. package/src/index.ts +2 -2
  61. package/src/services/CanvasService.ts +1286 -832
  62. package/src/services/ViewportSystem.ts +95 -95
  63. package/src/services/index.ts +4 -3
  64. package/src/services/renderSpec.ts +83 -53
  65. package/src/services/visibility.ts +78 -0
  66. package/src/units.ts +27 -27
  67. package/tests/run.ts +253 -118
  68. package/tsconfig.test.json +15 -15
  69. package/src/extensions/sceneVisibility.ts +0 -64
@@ -1,2054 +1,2007 @@
1
- import {
2
- Extension,
3
- ExtensionContext,
4
- ContributionPointIds,
5
- CommandContribution,
6
- ConfigurationContribution,
7
- ConfigurationService,
8
- ToolSessionService,
9
- WorkbenchService,
10
- } from "@pooder/core";
11
- import {
12
- Canvas as FabricCanvas,
13
- Image as FabricImage,
14
- Pattern,
15
- Point,
16
- } from "fabric";
17
- import { CanvasService, RenderLayoutRect, RenderObjectSpec } from "../services";
18
- import { isDielineShape, normalizeShapeStyle } from "./dielineShape";
19
- import type { DielineShape, DielineShapeStyle } from "./dielineShape";
20
- import { generateDielinePath, getPathBounds } from "./geometry";
21
- import {
22
- buildSceneGeometry,
23
- computeSceneLayout,
24
- readSizeState,
25
- } from "./sceneLayoutModel";
26
-
27
- export interface ImageItem {
28
- id: string;
29
- url: string;
30
- opacity: number;
31
- scale?: number;
32
- angle?: number;
33
- left?: number;
34
- top?: number;
35
- sourceUrl?: string;
36
- committedUrl?: string;
37
- }
38
-
39
- interface FrameRect {
40
- left: number;
41
- top: number;
42
- width: number;
43
- height: number;
44
- }
45
-
46
- interface SourceSize {
47
- width: number;
48
- height: number;
49
- }
50
-
51
- interface RenderImageState {
52
- src: string;
53
- left: number;
54
- top: number;
55
- scale: number;
56
- angle: number;
57
- opacity: number;
58
- }
59
-
60
- interface FrameVisualConfig {
61
- strokeColor: string;
62
- strokeWidth: number;
63
- strokeStyle: "solid" | "dashed" | "hidden";
64
- dashLength: number;
65
- innerBackground: string;
66
- outerBackground: string;
67
- }
68
-
69
- type ShapeOverlayShape = Exclude<DielineShape, "custom">;
70
-
71
- interface SceneGeometryLike {
72
- shape: DielineShape;
73
- shapeStyle: DielineShapeStyle;
74
- radius: number;
75
- offset: number;
76
- }
77
-
78
- interface UpsertImageOptions {
79
- id?: string;
80
- mode?: "replace" | "add";
81
- addOptions?: Partial<ImageItem>;
82
- fitOnAdd?: boolean;
83
- }
84
-
85
- interface DielineFitArea {
86
- width: number;
87
- height: number;
88
- left: number;
89
- top: number;
90
- }
91
-
92
- interface UpdateImageOptions {
93
- target?: "auto" | "config" | "working";
94
- }
95
-
96
- interface ExportCroppedImageOptions {
97
- multiplier?: number;
98
- format?: "png" | "jpeg";
99
- }
100
-
101
- interface ExportUserCroppedImageOptions extends ExportCroppedImageOptions {
102
- imageIds?: string[];
103
- }
104
-
105
- interface ExportUserCroppedImageResult {
106
- url: string;
107
- width: number;
108
- height: number;
109
- multiplier: number;
110
- format: "png" | "jpeg";
111
- imageIds: string[];
112
- }
113
-
114
- const IMAGE_OBJECT_LAYER_ID = "image.user";
115
- const IMAGE_OVERLAY_LAYER_ID = "image-overlay";
116
-
117
- export class ImageTool implements Extension {
118
- id = "pooder.kit.image";
119
-
120
- metadata = {
121
- name: "ImageTool",
122
- };
123
-
124
- private items: ImageItem[] = [];
125
- private workingItems: ImageItem[] = [];
126
- private hasWorkingChanges = false;
127
- private loadResolvers: Map<string, () => void> = new Map();
128
- private sourceSizeBySrc: Map<string, SourceSize> = new Map();
129
- private canvasService?: CanvasService;
130
- private context?: ExtensionContext;
131
- private isUpdatingConfig = false;
132
- private isToolActive = false;
133
- private isImageSelectionActive = false;
134
- private focusedImageId: string | null = null;
135
- private renderSeq = 0;
136
- private dirtyTrackerDisposable?: { dispose(): void };
137
- private cropShapeHatchPattern?: Pattern;
138
- private cropShapeHatchPatternColor?: string;
139
- private cropShapeHatchPatternKey?: string;
140
- private overlaySpecs: RenderObjectSpec[] = [];
141
- private renderProducerDisposable?: { dispose: () => void };
142
-
143
- activate(context: ExtensionContext) {
144
- this.context = context;
145
- this.canvasService = context.services.get<CanvasService>("CanvasService");
146
- if (!this.canvasService) {
147
- console.warn("CanvasService not found for ImageTool");
148
- return;
149
- }
150
- this.renderProducerDisposable?.dispose();
151
- this.renderProducerDisposable = this.canvasService.registerRenderProducer(
152
- this.id,
153
- () => ({
154
- rootLayerSpecs: {
155
- [IMAGE_OVERLAY_LAYER_ID]: this.overlaySpecs,
156
- },
157
- }),
158
- { priority: 300 },
159
- );
160
-
161
- context.eventBus.on("tool:activated", this.onToolActivated);
162
- context.eventBus.on("object:modified", this.onObjectModified);
163
- context.eventBus.on("selection:created", this.onSelectionChanged);
164
- context.eventBus.on("selection:updated", this.onSelectionChanged);
165
- context.eventBus.on("selection:cleared", this.onSelectionCleared);
166
- context.eventBus.on("scene:layout:change", this.onSceneLayoutChanged);
167
- context.eventBus.on("scene:geometry:change", this.onSceneGeometryChanged);
168
-
169
- const configService = context.services.get<ConfigurationService>(
170
- "ConfigurationService",
171
- );
172
- if (configService) {
173
- this.items = this.normalizeItems(
174
- configService.get("image.items", []) || [],
175
- );
176
- this.workingItems = this.cloneItems(this.items);
177
- this.hasWorkingChanges = false;
178
-
179
- configService.onAnyChange((e: { key: string; value: any }) => {
180
- if (this.isUpdatingConfig) return;
181
-
182
- if (e.key === "image.items") {
183
- this.items = this.normalizeItems(e.value || []);
184
- if (!this.isToolActive || !this.hasWorkingChanges) {
185
- this.workingItems = this.cloneItems(this.items);
186
- this.hasWorkingChanges = false;
187
- }
188
- this.updateImages();
189
- return;
190
- }
191
-
192
- if (e.key.startsWith("size.") || e.key.startsWith("image.frame.")) {
193
- this.updateImages();
194
- }
195
- });
196
- }
197
-
198
- const toolSessionService =
199
- context.services.get<ToolSessionService>("ToolSessionService");
200
- this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(
201
- this.id,
202
- () => this.hasWorkingChanges,
203
- );
204
-
205
- this.updateImages();
206
- }
207
-
208
- deactivate(context: ExtensionContext) {
209
- context.eventBus.off("tool:activated", this.onToolActivated);
210
- context.eventBus.off("object:modified", this.onObjectModified);
211
- context.eventBus.off("selection:created", this.onSelectionChanged);
212
- context.eventBus.off("selection:updated", this.onSelectionChanged);
213
- context.eventBus.off("selection:cleared", this.onSelectionCleared);
214
- context.eventBus.off("scene:layout:change", this.onSceneLayoutChanged);
215
- context.eventBus.off("scene:geometry:change", this.onSceneGeometryChanged);
216
- this.dirtyTrackerDisposable?.dispose();
217
- this.dirtyTrackerDisposable = undefined;
218
- this.cropShapeHatchPattern = undefined;
219
- this.cropShapeHatchPatternColor = undefined;
220
- this.cropShapeHatchPatternKey = undefined;
221
- this.overlaySpecs = [];
222
-
223
- this.clearRenderedImages();
224
- this.renderProducerDisposable?.dispose();
225
- this.renderProducerDisposable = undefined;
226
- if (this.canvasService) {
227
- void this.canvasService.flushRenderFromProducers();
228
- this.canvasService = undefined;
229
- }
230
- this.context = undefined;
231
- }
232
-
233
- private onToolActivated = (event: {
234
- id: string | null;
235
- previous?: string | null;
236
- reason?: string;
237
- }) => {
238
- const before = this.isToolActive;
239
- this.syncToolActiveFromWorkbench(event.id);
240
- if (!this.isToolActive) {
241
- this.setImageFocus(null, {
242
- syncCanvasSelection: true,
243
- skipRender: true,
244
- });
245
- }
246
- this.debug("tool:activated", {
247
- id: event.id,
248
- previous: event.previous,
249
- reason: event.reason,
250
- before,
251
- isToolActive: this.isToolActive,
252
- focusedImageId: this.focusedImageId,
253
- });
254
- if (!this.isToolActive && this.isDebugEnabled()) {
255
- console.trace("[ImageTool] tool deactivated trace");
256
- }
257
- this.updateImages();
258
- };
259
-
260
- private onSelectionChanged = (e: any) => {
261
- const list: any[] = [];
262
- if (Array.isArray(e?.selected)) {
263
- list.push(...e.selected);
264
- }
265
- if (Array.isArray(e?.target?._objects)) {
266
- list.push(...e.target._objects);
267
- }
268
- if (e?.target && !Array.isArray(e?.target?._objects)) {
269
- list.push(e.target);
270
- }
271
-
272
- const selectedImage = list.find(
273
- (obj: any) => obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID,
274
- );
275
- this.isImageSelectionActive = !!selectedImage;
276
- if (selectedImage?.data?.id) {
277
- this.focusedImageId = selectedImage.data.id;
278
- } else if (list.length > 0) {
279
- this.focusedImageId = null;
280
- }
281
- this.debug("selection:changed", {
282
- listSize: list.length,
283
- isImageSelectionActive: this.isImageSelectionActive,
284
- focusedImageId: this.focusedImageId,
285
- });
286
- this.updateImages();
287
- };
288
-
289
- private onSelectionCleared = () => {
290
- this.setImageFocus(null, {
291
- syncCanvasSelection: false,
292
- skipRender: true,
293
- });
294
- this.debug("selection:cleared applied");
295
- this.updateImages();
296
- };
297
-
298
- private onSceneLayoutChanged = () => {
299
- this.updateImages();
300
- };
301
-
302
- private onSceneGeometryChanged = () => {
303
- this.updateImages();
304
- };
305
-
306
- private syncToolActiveFromWorkbench(fallbackId?: string | null) {
307
- const wb = this.context?.services.get<WorkbenchService>("WorkbenchService");
308
- const activeId = wb?.activeToolId;
309
- if (typeof activeId === "string" || activeId === null) {
310
- this.isToolActive = activeId === this.id;
311
- return;
312
- }
313
- this.isToolActive = fallbackId === this.id;
314
- }
315
-
316
- private isImageEditingVisible(): boolean {
317
- return (
318
- this.isToolActive || this.isImageSelectionActive || !!this.focusedImageId
319
- );
320
- }
321
-
322
- private isDebugEnabled(): boolean {
323
- return !!this.getConfig<boolean>("image.debug", false);
324
- }
325
-
326
- private debug(message: string, payload?: any) {
327
- if (!this.isDebugEnabled()) return;
328
- if (payload === undefined) {
329
- console.log(`[ImageTool] ${message}`);
330
- return;
331
- }
332
- console.log(`[ImageTool] ${message}`, payload);
333
- }
334
-
335
- contribute() {
336
- return {
337
- [ContributionPointIds.TOOLS]: [
338
- {
339
- id: this.id,
340
- name: "Image",
341
- interaction: "session",
342
- commands: {
343
- begin: "resetWorkingImages",
344
- commit: "completeImages",
345
- rollback: "resetWorkingImages",
346
- },
347
- session: {
348
- autoBegin: true,
349
- leavePolicy: "block",
350
- },
351
- },
352
- ],
353
- [ContributionPointIds.CONFIGURATIONS]: [
354
- {
355
- id: "image.items",
356
- type: "array",
357
- label: "Images",
358
- default: [],
359
- },
360
- {
361
- id: "image.debug",
362
- type: "boolean",
363
- label: "Image Debug Log",
364
- default: false,
365
- },
366
- {
367
- id: "image.frame.strokeColor",
368
- type: "color",
369
- label: "Image Frame Stroke Color",
370
- default: "#808080",
371
- },
372
- {
373
- id: "image.frame.strokeWidth",
374
- type: "number",
375
- label: "Image Frame Stroke Width",
376
- min: 0,
377
- max: 20,
378
- step: 0.5,
379
- default: 2,
380
- },
381
- {
382
- id: "image.frame.strokeStyle",
383
- type: "select",
384
- label: "Image Frame Stroke Style",
385
- options: ["solid", "dashed", "hidden"],
386
- default: "dashed",
387
- },
388
- {
389
- id: "image.frame.dashLength",
390
- type: "number",
391
- label: "Image Frame Dash Length",
392
- min: 1,
393
- max: 40,
394
- step: 1,
395
- default: 8,
396
- },
397
- {
398
- id: "image.frame.innerBackground",
399
- type: "color",
400
- label: "Image Frame Inner Background",
401
- default: "rgba(0,0,0,0)",
402
- },
403
- {
404
- id: "image.frame.outerBackground",
405
- type: "color",
406
- label: "Image Frame Outer Background",
407
- default: "#f5f5f5",
408
- },
409
- ] as ConfigurationContribution[],
410
- [ContributionPointIds.COMMANDS]: [
411
- {
412
- command: "addImage",
413
- title: "Add Image",
414
- handler: async (url: string, options?: Partial<ImageItem>) => {
415
- const result = await this.upsertImageEntry(url, {
416
- mode: "add",
417
- addOptions: options,
418
- });
419
- return result.id;
420
- },
421
- },
422
- {
423
- command: "upsertImage",
424
- title: "Upsert Image",
425
- handler: async (url: string, options: UpsertImageOptions = {}) => {
426
- return await this.upsertImageEntry(url, options);
427
- },
428
- },
429
- {
430
- command: "getWorkingImages",
431
- title: "Get Working Images",
432
- handler: () => {
433
- return this.cloneItems(this.workingItems);
434
- },
435
- },
436
- {
437
- command: "setWorkingImage",
438
- title: "Set Working Image",
439
- handler: (id: string, updates: Partial<ImageItem>) => {
440
- this.updateImageInWorking(id, updates);
441
- },
442
- },
443
- {
444
- command: "resetWorkingImages",
445
- title: "Reset Working Images",
446
- handler: () => {
447
- this.workingItems = this.cloneItems(this.items);
448
- this.hasWorkingChanges = false;
449
- this.updateImages();
450
- this.emitWorkingChange();
451
- },
452
- },
453
- {
454
- command: "completeImages",
455
- title: "Complete Images",
456
- handler: async () => {
457
- return await this.commitWorkingImagesAsCropped();
458
- },
459
- },
460
- {
461
- command: "exportUserCroppedImage",
462
- title: "Export User Cropped Image",
463
- handler: async (options: ExportUserCroppedImageOptions = {}) => {
464
- return await this.exportUserCroppedImage(options);
465
- },
466
- },
467
- {
468
- command: "fitImageToArea",
469
- title: "Fit Image to Area",
470
- handler: async (
471
- id: string,
472
- area: {
473
- width: number;
474
- height: number;
475
- left?: number;
476
- top?: number;
477
- },
478
- ) => {
479
- await this.fitImageToArea(id, area);
480
- },
481
- },
482
- {
483
- command: "fitImageToDefaultArea",
484
- title: "Fit Image to Default Area",
485
- handler: async (id: string) => {
486
- await this.fitImageToDefaultArea(id);
487
- },
488
- },
489
- {
490
- command: "focusImage",
491
- title: "Focus Image",
492
- handler: (
493
- id: string | null,
494
- options: { syncCanvasSelection?: boolean } = {},
495
- ) => {
496
- return this.setImageFocus(id, options);
497
- },
498
- },
499
- {
500
- command: "removeImage",
501
- title: "Remove Image",
502
- handler: (id: string) => {
503
- const removed = this.items.find((item) => item.id === id);
504
- const next = this.items.filter((item) => item.id !== id);
505
- if (next.length !== this.items.length) {
506
- this.purgeSourceSizeCacheForItem(removed);
507
- if (this.focusedImageId === id) {
508
- this.setImageFocus(null, {
509
- syncCanvasSelection: true,
510
- skipRender: true,
511
- });
512
- }
513
- this.updateConfig(next);
514
- }
515
- },
516
- },
517
- {
518
- command: "updateImage",
519
- title: "Update Image",
520
- handler: async (
521
- id: string,
522
- updates: Partial<ImageItem>,
523
- options: UpdateImageOptions = {},
524
- ) => {
525
- await this.updateImage(id, updates, options);
526
- },
527
- },
528
- {
529
- command: "clearImages",
530
- title: "Clear Images",
531
- handler: () => {
532
- this.sourceSizeBySrc.clear();
533
- this.setImageFocus(null, {
534
- syncCanvasSelection: true,
535
- skipRender: true,
536
- });
537
- this.updateConfig([]);
538
- },
539
- },
540
- {
541
- command: "bringToFront",
542
- title: "Bring Image to Front",
543
- handler: (id: string) => {
544
- const index = this.items.findIndex((item) => item.id === id);
545
- if (index !== -1 && index < this.items.length - 1) {
546
- const next = [...this.items];
547
- const [item] = next.splice(index, 1);
548
- next.push(item);
549
- this.updateConfig(next);
550
- }
551
- },
552
- },
553
- {
554
- command: "sendToBack",
555
- title: "Send Image to Back",
556
- handler: (id: string) => {
557
- const index = this.items.findIndex((item) => item.id === id);
558
- if (index > 0) {
559
- const next = [...this.items];
560
- const [item] = next.splice(index, 1);
561
- next.unshift(item);
562
- this.updateConfig(next);
563
- }
564
- },
565
- },
566
- ] as CommandContribution[],
567
- };
568
- }
569
-
570
- private normalizeItem(item: ImageItem): ImageItem {
571
- const url = typeof item.url === "string" ? item.url : "";
572
- const sourceUrl =
573
- typeof item.sourceUrl === "string" && item.sourceUrl.length > 0
574
- ? item.sourceUrl
575
- : url;
576
- const committedUrl =
577
- typeof item.committedUrl === "string" && item.committedUrl.length > 0
578
- ? item.committedUrl
579
- : undefined;
580
-
581
- return {
582
- ...item,
583
- url: url || sourceUrl,
584
- sourceUrl,
585
- committedUrl,
586
- opacity: Number.isFinite(item.opacity as any) ? item.opacity : 1,
587
- scale: Number.isFinite(item.scale as any) ? item.scale : 1,
588
- angle: Number.isFinite(item.angle as any) ? item.angle : 0,
589
- left: Number.isFinite(item.left as any) ? item.left : 0.5,
590
- top: Number.isFinite(item.top as any) ? item.top : 0.5,
591
- };
592
- }
593
-
594
- private normalizeItems(items: ImageItem[]): ImageItem[] {
595
- return (items || []).map((item) => this.normalizeItem(item));
596
- }
597
-
598
- private cloneItems(items: ImageItem[]): ImageItem[] {
599
- return this.normalizeItems((items || []).map((i) => ({ ...i })));
600
- }
601
-
602
- private emitWorkingChange(changedId: string | null = null) {
603
- this.context?.eventBus.emit("image:working:change", {
604
- changedId,
605
- items: this.cloneItems(this.workingItems),
606
- });
607
- }
608
-
609
- private generateId(): string {
610
- return Math.random().toString(36).substring(2, 9);
611
- }
612
-
613
- private hasImageItem(id: string): boolean {
614
- return (
615
- this.items.some((item) => item.id === id) ||
616
- this.workingItems.some((item) => item.id === id)
617
- );
618
- }
619
-
620
- private setImageFocus(
621
- id: string | null,
622
- options: { syncCanvasSelection?: boolean; skipRender?: boolean } = {},
623
- ) {
624
- const syncCanvasSelection = options.syncCanvasSelection !== false;
625
-
626
- if (id && !this.hasImageItem(id)) {
627
- return { ok: false, reason: "image-not-found" as const };
628
- }
629
-
630
- this.focusedImageId = id;
631
- this.isImageSelectionActive = !!id;
632
-
633
- if (syncCanvasSelection && this.canvasService) {
634
- const canvas = this.canvasService.canvas;
635
- if (!id) {
636
- canvas.discardActiveObject();
637
- } else {
638
- const obj = this.getImageObject(id);
639
- if (obj) {
640
- obj.set({
641
- selectable: true,
642
- evented: true,
643
- hasControls: true,
644
- hasBorders: true,
645
- });
646
- canvas.setActiveObject(obj);
647
- }
648
- }
649
- this.canvasService.requestRenderAll();
650
- }
651
-
652
- if (!options.skipRender) {
653
- this.updateImages();
654
- }
655
-
656
- return { ok: true, id };
657
- }
658
-
659
- private async addImageEntry(
660
- url: string,
661
- options?: Partial<ImageItem>,
662
- fitOnAdd = true,
663
- ): Promise<string> {
664
- const id = this.generateId();
665
- const newItem = this.normalizeItem({
666
- id,
667
- url,
668
- opacity: 1,
669
- ...options,
670
- } as ImageItem);
671
-
672
- const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
673
- const waitLoaded = this.waitImageLoaded(id, true);
674
- this.updateConfig([...this.items, newItem]);
675
- this.addItemToWorkingSessionIfNeeded(newItem, sessionDirtyBeforeAdd);
676
- const loaded = await waitLoaded;
677
- if (loaded && fitOnAdd) {
678
- await this.fitImageToDefaultArea(id);
679
- }
680
- if (loaded) {
681
- this.setImageFocus(id);
682
- }
683
- return id;
684
- }
685
-
686
- private async upsertImageEntry(
687
- url: string,
688
- options: UpsertImageOptions = {},
689
- ): Promise<{ id: string; mode: "replace" | "add" }> {
690
- const mode = options.mode || (options.id ? "replace" : "add");
691
- const fitOnAdd = options.fitOnAdd !== false;
692
- if (mode === "replace") {
693
- if (!options.id) {
694
- throw new Error("replace-target-id-required");
695
- }
696
- const targetId = options.id;
697
- if (!this.hasImageItem(targetId)) {
698
- throw new Error("replace-target-not-found");
699
- }
700
- await this.updateImageInConfig(targetId, { url });
701
- return { id: targetId, mode: "replace" };
702
- }
703
-
704
- const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
705
- return { id, mode: "add" };
706
- }
707
-
708
- private addItemToWorkingSessionIfNeeded(
709
- item: ImageItem,
710
- sessionDirtyBeforeAdd: boolean,
711
- ) {
712
- if (!sessionDirtyBeforeAdd || !this.isToolActive) return;
713
- if (this.workingItems.some((existing) => existing.id === item.id)) return;
714
- this.workingItems = this.cloneItems([...this.workingItems, item]);
715
- this.updateImages();
716
- this.emitWorkingChange(item.id);
717
- }
718
-
719
- private async updateImage(
720
- id: string,
721
- updates: Partial<ImageItem>,
722
- options: UpdateImageOptions = {},
723
- ) {
724
- this.syncToolActiveFromWorkbench();
725
- const target = options.target || "auto";
726
-
727
- if (target === "working" || (target === "auto" && this.isToolActive)) {
728
- this.updateImageInWorking(id, updates);
729
- return;
730
- }
731
-
732
- await this.updateImageInConfig(id, updates);
733
- }
734
-
735
- private getConfig<T>(key: string, fallback?: T): T | undefined {
736
- if (!this.context) return fallback;
737
- const configService = this.context.services.get<ConfigurationService>(
738
- "ConfigurationService",
739
- );
740
- if (!configService) return fallback;
741
- return (configService.get(key, fallback) as T) ?? fallback;
742
- }
743
-
744
- private updateConfig(newItems: ImageItem[], skipCanvasUpdate = false) {
745
- if (!this.context) return;
746
-
747
- this.isUpdatingConfig = true;
748
- this.items = this.normalizeItems(newItems);
749
- if (!this.isToolActive || !this.hasWorkingChanges) {
750
- this.workingItems = this.cloneItems(this.items);
751
- this.hasWorkingChanges = false;
752
- }
753
-
754
- const configService = this.context.services.get<ConfigurationService>(
755
- "ConfigurationService",
756
- );
757
- configService?.update("image.items", this.items);
758
-
759
- if (!skipCanvasUpdate) {
760
- this.updateImages();
761
- }
762
-
763
- setTimeout(() => {
764
- this.isUpdatingConfig = false;
765
- }, 50);
766
- }
767
-
768
- private getFrameRect(): FrameRect {
769
- if (!this.canvasService) {
770
- return { left: 0, top: 0, width: 0, height: 0 };
771
- }
772
- const configService = this.context?.services.get<ConfigurationService>(
773
- "ConfigurationService",
774
- );
775
- if (!configService) {
776
- return { left: 0, top: 0, width: 0, height: 0 };
777
- }
778
-
779
- const sizeState = readSizeState(configService);
780
- const layout = computeSceneLayout(this.canvasService, sizeState);
781
- if (!layout) {
782
- return { left: 0, top: 0, width: 0, height: 0 };
783
- }
784
-
785
- return this.canvasService.toSceneRect({
786
- left: layout.cutRect.left,
787
- top: layout.cutRect.top,
788
- width: layout.cutRect.width,
789
- height: layout.cutRect.height,
790
- });
791
- }
792
-
793
- private getFrameRectScreen(frame?: FrameRect): FrameRect {
794
- if (!this.canvasService) {
795
- return { left: 0, top: 0, width: 0, height: 0 };
796
- }
797
- return this.canvasService.toScreenRect(frame || this.getFrameRect());
798
- }
799
-
800
- private toLayoutSceneRect(rect: FrameRect): RenderLayoutRect {
801
- return {
802
- left: rect.left,
803
- top: rect.top,
804
- width: rect.width,
805
- height: rect.height,
806
- space: "scene",
807
- };
808
- }
809
-
810
- private async resolveDefaultFitArea(): Promise<DielineFitArea | null> {
811
- if (!this.canvasService) return null;
812
- const frame = this.getFrameRect();
813
- if (frame.width <= 0 || frame.height <= 0) return null;
814
- return {
815
- width: Math.max(1, frame.width),
816
- height: Math.max(1, frame.height),
817
- left: frame.left + frame.width / 2,
818
- top: frame.top + frame.height / 2,
819
- };
820
- }
821
-
822
- private async fitImageToDefaultArea(id: string) {
823
- if (!this.canvasService) return;
824
- const area = await this.resolveDefaultFitArea();
825
-
826
- if (area) {
827
- await this.fitImageToArea(id, area);
828
- return;
829
- }
830
-
831
- const viewport = this.canvasService.getSceneViewportRect();
832
- const canvasW = Math.max(1, viewport.width || 0);
833
- const canvasH = Math.max(1, viewport.height || 0);
834
- await this.fitImageToArea(id, {
835
- width: canvasW,
836
- height: canvasH,
837
- left: viewport.left + canvasW / 2,
838
- top: viewport.top + canvasH / 2,
839
- });
840
- }
841
-
842
- private getImageObjects(): any[] {
843
- if (!this.canvasService) return [];
844
- return this.canvasService.canvas.getObjects().filter((obj: any) => {
845
- return obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID;
846
- }) as any[];
847
- }
848
-
849
- private getOverlayObjects(): any[] {
850
- if (!this.canvasService) return [];
851
- return this.canvasService.getRootLayerObjects(
852
- IMAGE_OVERLAY_LAYER_ID,
853
- ) as any[];
854
- }
855
-
856
- private getImageObject(id: string): any | undefined {
857
- return this.getImageObjects().find((obj: any) => obj?.data?.id === id);
858
- }
859
-
860
- private clearRenderedImages() {
861
- if (!this.canvasService) return;
862
- const canvas = this.canvasService.canvas;
863
- this.getImageObjects().forEach((obj) => canvas.remove(obj));
864
- this.canvasService.requestRenderAll();
865
- }
866
-
867
- private purgeSourceSizeCacheForItem(item?: ImageItem) {
868
- if (!item) return;
869
- const sources = [item.url, item.sourceUrl, item.committedUrl].filter(
870
- (value): value is string => typeof value === "string" && value.length > 0,
871
- );
872
- sources.forEach((src) => this.sourceSizeBySrc.delete(src));
873
- }
874
-
875
- private rememberSourceSize(src: string, obj: any) {
876
- const width = Number(obj?.width || 0);
877
- const height = Number(obj?.height || 0);
878
- if (src && width > 0 && height > 0) {
879
- this.sourceSizeBySrc.set(src, { width, height });
880
- }
881
- }
882
-
883
- private getSourceSize(src: string, obj?: any): SourceSize {
884
- const cached = src ? this.sourceSizeBySrc.get(src) : undefined;
885
- if (cached) return cached;
886
-
887
- const width = Number(obj?.width || 0);
888
- const height = Number(obj?.height || 0);
889
- if (src && width > 0 && height > 0) {
890
- const size = { width, height };
891
- this.sourceSizeBySrc.set(src, size);
892
- return size;
893
- }
894
-
895
- return { width: 1, height: 1 };
896
- }
897
-
898
- private getCoverScale(frame: FrameRect, size: SourceSize): number {
899
- const sw = Math.max(1, size.width);
900
- const sh = Math.max(1, size.height);
901
- const fw = Math.max(1, frame.width);
902
- const fh = Math.max(1, frame.height);
903
- return Math.max(fw / sw, fh / sh);
904
- }
905
-
906
- private getFrameVisualConfig(): FrameVisualConfig {
907
- const strokeStyleRaw = (this.getConfig<string>(
908
- "image.frame.strokeStyle",
909
- "dashed",
910
- ) || "dashed") as string;
911
- const strokeStyle: "solid" | "dashed" | "hidden" =
912
- strokeStyleRaw === "dashed" || strokeStyleRaw === "hidden"
913
- ? strokeStyleRaw
914
- : "dashed";
915
-
916
- const strokeWidth = Number(
917
- this.getConfig<number>("image.frame.strokeWidth", 2) ?? 2,
918
- );
919
- const dashLength = Number(
920
- this.getConfig<number>("image.frame.dashLength", 8) ?? 8,
921
- );
922
-
923
- return {
924
- strokeColor:
925
- this.getConfig<string>("image.frame.strokeColor", "#808080") ||
926
- "#808080",
927
- strokeWidth: Number.isFinite(strokeWidth) ? Math.max(0, strokeWidth) : 2,
928
- strokeStyle,
929
- dashLength: Number.isFinite(dashLength) ? Math.max(1, dashLength) : 8,
930
- innerBackground:
931
- this.getConfig<string>(
932
- "image.frame.innerBackground",
933
- "rgba(0,0,0,0)",
934
- ) || "rgba(0,0,0,0)",
935
- outerBackground:
936
- this.getConfig<string>("image.frame.outerBackground", "#f5f5f5") ||
937
- "#f5f5f5",
938
- };
939
- }
940
-
941
- private toSceneGeometryLike(raw: any): SceneGeometryLike | null {
942
- const shape = raw?.shape;
943
- if (!isDielineShape(shape)) {
944
- return null;
945
- }
946
-
947
- const radiusRaw = Number(raw?.radius);
948
- const offsetRaw = Number(raw?.offset);
949
- const unit = typeof raw?.unit === "string" ? raw.unit : "px";
950
- const radius =
951
- unit === "scene" || !this.canvasService
952
- ? radiusRaw
953
- : this.canvasService.toSceneLength(radiusRaw);
954
- const offset =
955
- unit === "scene" || !this.canvasService
956
- ? offsetRaw
957
- : this.canvasService.toSceneLength(offsetRaw);
958
- return {
959
- shape,
960
- shapeStyle: normalizeShapeStyle(raw?.shapeStyle),
961
- radius: Number.isFinite(radius) ? radius : 0,
962
- offset: Number.isFinite(offset) ? offset : 0,
963
- };
964
- }
965
-
966
- private async resolveSceneGeometryForOverlay(): Promise<SceneGeometryLike | null> {
967
- if (!this.context) return null;
968
- const commandService = this.context.services.get<any>("CommandService");
969
- if (commandService) {
970
- try {
971
- const raw = await Promise.resolve(
972
- commandService.executeCommand("getSceneGeometry"),
973
- );
974
- const geometry = this.toSceneGeometryLike(raw);
975
- if (geometry) {
976
- this.debug("overlay:sceneGeometry:command", geometry);
977
- return geometry;
978
- }
979
- this.debug("overlay:sceneGeometry:command:invalid", { raw });
980
- } catch (error) {
981
- this.debug("overlay:sceneGeometry:command:error", {
982
- error: error instanceof Error ? error.message : String(error),
983
- });
984
- }
985
- }
986
-
987
- if (!this.canvasService) return null;
988
- const configService = this.context.services.get<ConfigurationService>(
989
- "ConfigurationService",
990
- );
991
- if (!configService) return null;
992
-
993
- const sizeState = readSizeState(configService);
994
- const layout = computeSceneLayout(this.canvasService, sizeState);
995
- if (!layout) {
996
- this.debug("overlay:sceneGeometry:fallback:missing-layout");
997
- return null;
998
- }
999
-
1000
- const geometry = this.toSceneGeometryLike(
1001
- buildSceneGeometry(configService, layout),
1002
- );
1003
- if (geometry) {
1004
- this.debug("overlay:sceneGeometry:fallback", geometry);
1005
- }
1006
- return geometry;
1007
- }
1008
-
1009
- private resolveCutShapeRadius(
1010
- geometry: SceneGeometryLike,
1011
- frame: FrameRect,
1012
- ): number {
1013
- const visualRadius = Number.isFinite(geometry.radius)
1014
- ? Math.max(0, geometry.radius)
1015
- : 0;
1016
- const visualOffset = Number.isFinite(geometry.offset) ? geometry.offset : 0;
1017
- const rawCutRadius =
1018
- visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
1019
- const maxRadius = Math.max(0, Math.min(frame.width, frame.height) / 2);
1020
- return Math.max(0, Math.min(maxRadius, rawCutRadius));
1021
- }
1022
-
1023
- private getCropShapeHatchPattern(
1024
- color = "rgba(255, 0, 0, 0.6)",
1025
- ): Pattern | undefined {
1026
- if (typeof document === "undefined") return undefined;
1027
- const sceneScale = this.canvasService?.getSceneScale() || 1;
1028
- const cacheKey = `${color}::${sceneScale.toFixed(6)}`;
1029
- if (
1030
- this.cropShapeHatchPattern &&
1031
- this.cropShapeHatchPatternColor === color &&
1032
- this.cropShapeHatchPatternKey === cacheKey
1033
- ) {
1034
- return this.cropShapeHatchPattern;
1035
- }
1036
-
1037
- const size = 16;
1038
- const patternCanvas = document.createElement("canvas");
1039
- patternCanvas.width = size;
1040
- patternCanvas.height = size;
1041
- const ctx = patternCanvas.getContext("2d");
1042
- if (!ctx) return undefined;
1043
-
1044
- ctx.clearRect(0, 0, size, size);
1045
- ctx.fillStyle = "rgba(255, 0, 0, 0.08)";
1046
- ctx.fillRect(0, 0, size, size);
1047
- ctx.strokeStyle = color;
1048
- ctx.lineWidth = 1.5;
1049
- ctx.beginPath();
1050
- ctx.moveTo(-size, size);
1051
- ctx.lineTo(size, -size);
1052
- ctx.moveTo(-size / 2, size + size / 2);
1053
- ctx.lineTo(size + size / 2, -size / 2);
1054
- ctx.moveTo(0, size);
1055
- ctx.lineTo(size, 0);
1056
- ctx.moveTo(size / 2, size + size / 2);
1057
- ctx.lineTo(size + size + size / 2, -size / 2);
1058
- ctx.stroke();
1059
-
1060
- const pattern = new Pattern({
1061
- source: patternCanvas,
1062
- // @ts-ignore: Fabric Pattern accepts canvas source here.
1063
- repetition: "repeat",
1064
- });
1065
- // Scene specs are scaled to screen by CanvasService; keep hatch density in screen pixels.
1066
- (pattern as any).patternTransform = [
1067
- 1 / sceneScale,
1068
- 0,
1069
- 0,
1070
- 1 / sceneScale,
1071
- 0,
1072
- 0,
1073
- ];
1074
- this.cropShapeHatchPattern = pattern;
1075
- this.cropShapeHatchPatternColor = color;
1076
- this.cropShapeHatchPatternKey = cacheKey;
1077
- return pattern;
1078
- }
1079
-
1080
- private buildCropShapeOverlaySpecs(
1081
- frame: FrameRect,
1082
- sceneGeometry: SceneGeometryLike | null,
1083
- ): RenderObjectSpec[] {
1084
- if (!sceneGeometry) {
1085
- this.debug("overlay:shape:skip", { reason: "scene-geometry-missing" });
1086
- return [];
1087
- }
1088
- if (sceneGeometry.shape === "custom") {
1089
- this.debug("overlay:shape:skip", { reason: "shape-custom" });
1090
- return [];
1091
- }
1092
-
1093
- const shape = sceneGeometry.shape as ShapeOverlayShape;
1094
- const shapeStyle = sceneGeometry.shapeStyle;
1095
- const inset = 0;
1096
- const shapeWidth = Math.max(1, frame.width);
1097
- const shapeHeight = Math.max(1, frame.height);
1098
- const radius = this.resolveCutShapeRadius(sceneGeometry, frame);
1099
-
1100
- this.debug("overlay:shape:geometry", {
1101
- shape,
1102
- frameWidth: frame.width,
1103
- frameHeight: frame.height,
1104
- offset: sceneGeometry.offset,
1105
- shapeStyle,
1106
- inset,
1107
- shapeWidth,
1108
- shapeHeight,
1109
- baseRadius: sceneGeometry.radius,
1110
- radius,
1111
- });
1112
-
1113
- const isSameAsFrame =
1114
- Math.abs(shapeWidth - frame.width) <= 0.0001 &&
1115
- Math.abs(shapeHeight - frame.height) <= 0.0001;
1116
- if (shape === "rect" && radius <= 0.0001 && isSameAsFrame) {
1117
- this.debug("overlay:shape:skip", {
1118
- reason: "shape-rect-no-radius",
1119
- });
1120
- return [];
1121
- }
1122
-
1123
- const baseOptions = {
1124
- shape,
1125
- width: shapeWidth,
1126
- height: shapeHeight,
1127
- radius,
1128
- x: frame.width / 2,
1129
- y: frame.height / 2,
1130
- features: [],
1131
- shapeStyle,
1132
- canvasWidth: frame.width,
1133
- canvasHeight: frame.height,
1134
- };
1135
-
1136
- try {
1137
- const shapePathData = generateDielinePath(baseOptions);
1138
- const outerRectPathData = `M 0 0 L ${frame.width} 0 L ${frame.width} ${frame.height} L 0 ${frame.height} Z`;
1139
- const hatchPathData = `${outerRectPathData} ${shapePathData}`;
1140
- if (!shapePathData || !hatchPathData) {
1141
- this.debug("overlay:shape:skip", {
1142
- reason: "path-generation-empty",
1143
- shape,
1144
- radius,
1145
- });
1146
- return [];
1147
- }
1148
-
1149
- const patternFill = this.getCropShapeHatchPattern();
1150
- const hatchFill = patternFill || "rgba(255, 0, 0, 0.22)";
1151
- const shapeBounds = getPathBounds(shapePathData);
1152
- const hatchBounds = getPathBounds(hatchPathData);
1153
- const frameRect = this.toLayoutSceneRect(frame);
1154
- const hatchPathLength = hatchPathData.length;
1155
- const shapePathLength = shapePathData.length;
1156
- const specs: RenderObjectSpec[] = [
1157
- {
1158
- id: "image.cropShapeHatch",
1159
- type: "path",
1160
- data: { id: "image.cropShapeHatch", zIndex: 5 },
1161
- layout: {
1162
- reference: "custom",
1163
- referenceRect: frameRect,
1164
- alignX: "start",
1165
- alignY: "start",
1166
- offsetX: hatchBounds.x,
1167
- offsetY: hatchBounds.y,
1168
- },
1169
- props: {
1170
- pathData: hatchPathData,
1171
- originX: "left",
1172
- originY: "top",
1173
- fill: hatchFill,
1174
- opacity: patternFill ? 1 : 0.8,
1175
- stroke: null,
1176
- fillRule: "evenodd",
1177
- selectable: false,
1178
- evented: false,
1179
- excludeFromExport: true,
1180
- objectCaching: false,
1181
- },
1182
- },
1183
- {
1184
- id: "image.cropShapePath",
1185
- type: "path",
1186
- data: { id: "image.cropShapePath", zIndex: 6 },
1187
- layout: {
1188
- reference: "custom",
1189
- referenceRect: frameRect,
1190
- alignX: "start",
1191
- alignY: "start",
1192
- offsetX: shapeBounds.x,
1193
- offsetY: shapeBounds.y,
1194
- },
1195
- props: {
1196
- pathData: shapePathData,
1197
- originX: "left",
1198
- originY: "top",
1199
- fill: "rgba(0,0,0,0)",
1200
- stroke: "rgba(255, 0, 0, 0.9)",
1201
- strokeWidth: this.canvasService?.toSceneLength(1) ?? 1,
1202
- selectable: false,
1203
- evented: false,
1204
- excludeFromExport: true,
1205
- objectCaching: false,
1206
- },
1207
- },
1208
- ];
1209
- this.debug("overlay:shape:built", {
1210
- shape,
1211
- radius,
1212
- inset,
1213
- shapeWidth,
1214
- shapeHeight,
1215
- fillRule: "evenodd",
1216
- shapePathLength,
1217
- hatchPathLength,
1218
- shapeBounds,
1219
- hatchBounds,
1220
- hatchFillType:
1221
- hatchFill && typeof hatchFill === "object" ? "pattern" : "color",
1222
- ids: specs.map((spec) => spec.id),
1223
- });
1224
- return specs;
1225
- } catch (error) {
1226
- this.debug("overlay:shape:error", {
1227
- shape,
1228
- radius,
1229
- error: error instanceof Error ? error.message : String(error),
1230
- });
1231
- return [];
1232
- }
1233
- }
1234
-
1235
- private resolveRenderImageState(item: ImageItem): RenderImageState {
1236
- const active = this.isToolActive;
1237
- const sourceUrl = item.sourceUrl || item.url;
1238
- const committedUrl = item.committedUrl;
1239
-
1240
- if (!active && committedUrl) {
1241
- return {
1242
- src: committedUrl,
1243
- left: 0.5,
1244
- top: 0.5,
1245
- scale: 1,
1246
- angle: 0,
1247
- opacity: item.opacity,
1248
- };
1249
- }
1250
-
1251
- return {
1252
- src: sourceUrl || item.url,
1253
- left: Number.isFinite(item.left as any) ? (item.left as number) : 0.5,
1254
- top: Number.isFinite(item.top as any) ? (item.top as number) : 0.5,
1255
- scale: Math.max(0.05, item.scale ?? 1),
1256
- angle: Number.isFinite(item.angle as any) ? (item.angle as number) : 0,
1257
- opacity: item.opacity,
1258
- };
1259
- }
1260
-
1261
- private computeCanvasProps(
1262
- render: RenderImageState,
1263
- size: SourceSize,
1264
- frame: FrameRect,
1265
- ) {
1266
- const left = render.left;
1267
- const top = render.top;
1268
- const zoom = render.scale;
1269
- const angle = render.angle;
1270
-
1271
- const centerX = frame.left + left * frame.width;
1272
- const centerY = frame.top + top * frame.height;
1273
- const scale = this.getCoverScale(frame, size) * zoom;
1274
-
1275
- return {
1276
- left: centerX,
1277
- top: centerY,
1278
- scaleX: scale,
1279
- scaleY: scale,
1280
- angle,
1281
- originX: "center" as const,
1282
- originY: "center" as const,
1283
- uniformScaling: true,
1284
- lockScalingFlip: true,
1285
- selectable: this.isImageEditingVisible(),
1286
- evented: this.isImageEditingVisible(),
1287
- hasControls: this.isImageEditingVisible(),
1288
- hasBorders: this.isImageEditingVisible(),
1289
- opacity: render.opacity,
1290
- };
1291
- }
1292
-
1293
- private toScreenObjectProps(props: Record<string, any>): Record<string, any> {
1294
- if (!this.canvasService) return props;
1295
- const next = { ...props };
1296
- if (Number.isFinite(next.left) || Number.isFinite(next.top)) {
1297
- const mapped = this.canvasService.toScreenPoint({
1298
- x: Number.isFinite(next.left) ? Number(next.left) : 0,
1299
- y: Number.isFinite(next.top) ? Number(next.top) : 0,
1300
- });
1301
- if (Number.isFinite(next.left)) next.left = mapped.x;
1302
- if (Number.isFinite(next.top)) next.top = mapped.y;
1303
- }
1304
- const sceneScale = this.canvasService.getSceneScale();
1305
- const sx = Number.isFinite(next.scaleX) ? Number(next.scaleX) : 1;
1306
- const sy = Number.isFinite(next.scaleY) ? Number(next.scaleY) : 1;
1307
- next.scaleX = sx * sceneScale;
1308
- next.scaleY = sy * sceneScale;
1309
- return next;
1310
- }
1311
-
1312
- private toSceneObjectScale(value: number): number {
1313
- if (!this.canvasService) return value;
1314
- return value / this.canvasService.getSceneScale();
1315
- }
1316
-
1317
- private getCurrentSrc(obj: any): string | undefined {
1318
- if (!obj) return undefined;
1319
- if (typeof obj.getSrc === "function") return obj.getSrc();
1320
- return obj?._originalElement?.src;
1321
- }
1322
-
1323
- private applyImageControlVisibility(obj: any) {
1324
- if (typeof obj?.setControlsVisibility !== "function") return;
1325
- obj.setControlsVisibility({
1326
- mt: false,
1327
- mb: false,
1328
- ml: false,
1329
- mr: false,
1330
- tl: true,
1331
- tr: true,
1332
- bl: true,
1333
- br: true,
1334
- mtr: true,
1335
- });
1336
- }
1337
-
1338
- private async upsertImageObject(
1339
- item: ImageItem,
1340
- frame: FrameRect,
1341
- seq: number,
1342
- ) {
1343
- if (!this.canvasService) return;
1344
- const canvas = this.canvasService.canvas;
1345
- const render = this.resolveRenderImageState(item);
1346
- if (!render.src) return;
1347
-
1348
- let obj = this.getImageObject(item.id);
1349
- const currentSrc = this.getCurrentSrc(obj);
1350
-
1351
- if (obj && currentSrc && currentSrc !== render.src) {
1352
- canvas.remove(obj);
1353
- obj = undefined;
1354
- }
1355
-
1356
- if (!obj) {
1357
- const created = await FabricImage.fromURL(render.src, {
1358
- crossOrigin: "anonymous",
1359
- });
1360
- if (seq !== this.renderSeq) return;
1361
-
1362
- created.set({
1363
- data: {
1364
- id: item.id,
1365
- layerId: IMAGE_OBJECT_LAYER_ID,
1366
- type: "image-item",
1367
- },
1368
- } as any);
1369
- canvas.add(created as any);
1370
- obj = created as any;
1371
- }
1372
-
1373
- this.rememberSourceSize(render.src, obj);
1374
- const sourceSize = this.getSourceSize(render.src, obj);
1375
- const props = this.computeCanvasProps(render, sourceSize, frame);
1376
- const screenProps = this.toScreenObjectProps(props);
1377
-
1378
- obj.set({
1379
- ...screenProps,
1380
- data: {
1381
- ...(obj.data || {}),
1382
- id: item.id,
1383
- layerId: IMAGE_OBJECT_LAYER_ID,
1384
- type: "image-item",
1385
- },
1386
- });
1387
- this.applyImageControlVisibility(obj);
1388
- obj.setCoords();
1389
-
1390
- const resolver = this.loadResolvers.get(item.id);
1391
- if (resolver) {
1392
- resolver();
1393
- this.loadResolvers.delete(item.id);
1394
- }
1395
- }
1396
-
1397
- private syncImageZOrder(items: ImageItem[]) {
1398
- if (!this.canvasService) return;
1399
- const canvas = this.canvasService.canvas;
1400
-
1401
- const objects = canvas.getObjects();
1402
- let insertIndex = 0;
1403
-
1404
- const backgroundLayer = this.canvasService.getLayer("background");
1405
- if (backgroundLayer) {
1406
- const bgIndex = objects.indexOf(backgroundLayer as any);
1407
- if (bgIndex >= 0) insertIndex = bgIndex + 1;
1408
- }
1409
-
1410
- items.forEach((item) => {
1411
- const obj = this.getImageObject(item.id);
1412
- if (!obj) return;
1413
- canvas.moveObjectTo(obj, insertIndex);
1414
- insertIndex += 1;
1415
- });
1416
-
1417
- const overlayObjects = this.getOverlayObjects().sort((a: any, b: any) => {
1418
- const az = Number(a?.data?.zIndex ?? 0);
1419
- const bz = Number(b?.data?.zIndex ?? 0);
1420
- return az - bz;
1421
- });
1422
- overlayObjects.forEach((obj) => {
1423
- canvas.bringObjectToFront(obj);
1424
- });
1425
-
1426
- if (this.isDebugEnabled()) {
1427
- const stack = canvas
1428
- .getObjects()
1429
- .map((obj: any, index: number) => ({
1430
- index,
1431
- id: obj?.data?.id,
1432
- layerId: obj?.data?.layerId,
1433
- zIndex: obj?.data?.zIndex,
1434
- }))
1435
- .filter((item) => item.layerId === IMAGE_OVERLAY_LAYER_ID);
1436
- this.debug("overlay:stack", stack);
1437
- }
1438
- }
1439
-
1440
- private buildOverlaySpecs(
1441
- frame: FrameRect,
1442
- sceneGeometry: SceneGeometryLike | null,
1443
- ): RenderObjectSpec[] {
1444
- const visible = this.isImageEditingVisible();
1445
- if (
1446
- !visible ||
1447
- frame.width <= 0 ||
1448
- frame.height <= 0 ||
1449
- !this.canvasService
1450
- ) {
1451
- this.debug("overlay:hidden", {
1452
- visible,
1453
- frame,
1454
- isToolActive: this.isToolActive,
1455
- isImageSelectionActive: this.isImageSelectionActive,
1456
- focusedImageId: this.focusedImageId,
1457
- });
1458
- return [];
1459
- }
1460
-
1461
- const viewport = this.canvasService.getSceneViewportRect();
1462
- const canvasW = viewport.width || 0;
1463
- const canvasH = viewport.height || 0;
1464
- const canvasLeft = viewport.left || 0;
1465
- const canvasTop = viewport.top || 0;
1466
- const visual = this.getFrameVisualConfig();
1467
- const strokeWidthScene = this.canvasService.toSceneLength(
1468
- visual.strokeWidth,
1469
- );
1470
- const dashLengthScene = this.canvasService.toSceneLength(visual.dashLength);
1471
-
1472
- const frameLeft = Math.max(
1473
- canvasLeft,
1474
- Math.min(canvasLeft + canvasW, frame.left),
1475
- );
1476
- const frameTop = Math.max(
1477
- canvasTop,
1478
- Math.min(canvasTop + canvasH, frame.top),
1479
- );
1480
- const frameRight = Math.max(
1481
- frameLeft,
1482
- Math.min(canvasLeft + canvasW, frame.left + frame.width),
1483
- );
1484
- const frameBottom = Math.max(
1485
- frameTop,
1486
- Math.min(canvasTop + canvasH, frame.top + frame.height),
1487
- );
1488
- const visibleFrameH = Math.max(0, frameBottom - frameTop);
1489
-
1490
- const topH = Math.max(0, frameTop - canvasTop);
1491
- const bottomH = Math.max(0, canvasTop + canvasH - frameBottom);
1492
- const leftW = Math.max(0, frameLeft - canvasLeft);
1493
- const rightW = Math.max(0, canvasLeft + canvasW - frameRight);
1494
- const viewportRect = this.toLayoutSceneRect({
1495
- left: canvasLeft,
1496
- top: canvasTop,
1497
- width: canvasW,
1498
- height: canvasH,
1499
- });
1500
- const visibleFrameBandRect = this.toLayoutSceneRect({
1501
- left: canvasLeft,
1502
- top: frameTop,
1503
- width: canvasW,
1504
- height: visibleFrameH,
1505
- });
1506
- const frameRect = this.toLayoutSceneRect(frame);
1507
- const shapeOverlay = this.buildCropShapeOverlaySpecs(frame, sceneGeometry);
1508
-
1509
- const mask: RenderObjectSpec[] = [
1510
- {
1511
- id: "image.cropMask.top",
1512
- type: "rect",
1513
- data: { id: "image.cropMask.top", zIndex: 1 },
1514
- layout: {
1515
- reference: "custom",
1516
- referenceRect: viewportRect,
1517
- alignX: "start",
1518
- alignY: "start",
1519
- width: "100%",
1520
- height: topH,
1521
- },
1522
- props: {
1523
- originX: "left",
1524
- originY: "top",
1525
- fill: visual.outerBackground,
1526
- selectable: false,
1527
- evented: false,
1528
- },
1529
- },
1530
- {
1531
- id: "image.cropMask.bottom",
1532
- type: "rect",
1533
- data: { id: "image.cropMask.bottom", zIndex: 2 },
1534
- layout: {
1535
- reference: "custom",
1536
- referenceRect: viewportRect,
1537
- alignX: "start",
1538
- alignY: "end",
1539
- width: "100%",
1540
- height: bottomH,
1541
- },
1542
- props: {
1543
- originX: "left",
1544
- originY: "top",
1545
- fill: visual.outerBackground,
1546
- selectable: false,
1547
- evented: false,
1548
- },
1549
- },
1550
- {
1551
- id: "image.cropMask.left",
1552
- type: "rect",
1553
- data: { id: "image.cropMask.left", zIndex: 3 },
1554
- layout: {
1555
- reference: "custom",
1556
- referenceRect: visibleFrameBandRect,
1557
- alignX: "start",
1558
- alignY: "start",
1559
- width: leftW,
1560
- height: "100%",
1561
- },
1562
- props: {
1563
- originX: "left",
1564
- originY: "top",
1565
- fill: visual.outerBackground,
1566
- selectable: false,
1567
- evented: false,
1568
- },
1569
- },
1570
- {
1571
- id: "image.cropMask.right",
1572
- type: "rect",
1573
- data: { id: "image.cropMask.right", zIndex: 4 },
1574
- layout: {
1575
- reference: "custom",
1576
- referenceRect: visibleFrameBandRect,
1577
- alignX: "end",
1578
- alignY: "start",
1579
- width: rightW,
1580
- height: "100%",
1581
- },
1582
- props: {
1583
- originX: "left",
1584
- originY: "top",
1585
- fill: visual.outerBackground,
1586
- selectable: false,
1587
- evented: false,
1588
- },
1589
- },
1590
- ];
1591
-
1592
- const frameSpec: RenderObjectSpec = {
1593
- id: "image.cropFrame",
1594
- type: "rect",
1595
- data: { id: "image.cropFrame", zIndex: 7 },
1596
- layout: {
1597
- reference: "custom",
1598
- referenceRect: frameRect,
1599
- alignX: "start",
1600
- alignY: "start",
1601
- width: "100%",
1602
- height: "100%",
1603
- },
1604
- props: {
1605
- originX: "left",
1606
- originY: "top",
1607
- fill: visual.innerBackground,
1608
- stroke:
1609
- visual.strokeStyle === "hidden"
1610
- ? "rgba(0,0,0,0)"
1611
- : visual.strokeColor,
1612
- strokeWidth: visual.strokeStyle === "hidden" ? 0 : strokeWidthScene,
1613
- strokeDashArray:
1614
- visual.strokeStyle === "dashed"
1615
- ? [dashLengthScene, dashLengthScene]
1616
- : undefined,
1617
- selectable: false,
1618
- evented: false,
1619
- },
1620
- };
1621
-
1622
- const specs = [...mask, ...shapeOverlay, frameSpec];
1623
- this.debug("overlay:built", {
1624
- frame,
1625
- shape: sceneGeometry?.shape,
1626
- overlayIds: specs.map((spec) => ({
1627
- id: spec.id,
1628
- zIndex: spec.data?.zIndex,
1629
- })),
1630
- });
1631
- return specs;
1632
- }
1633
-
1634
- private updateImages() {
1635
- void this.updateImagesAsync();
1636
- }
1637
-
1638
- private async updateImagesAsync() {
1639
- if (!this.canvasService) return;
1640
- this.syncToolActiveFromWorkbench();
1641
- const seq = ++this.renderSeq;
1642
-
1643
- const renderItems = this.isToolActive ? this.workingItems : this.items;
1644
- const frame = this.getFrameRect();
1645
- const desiredIds = new Set(renderItems.map((item) => item.id));
1646
- if (this.focusedImageId && !desiredIds.has(this.focusedImageId)) {
1647
- this.setImageFocus(null, {
1648
- syncCanvasSelection: false,
1649
- skipRender: true,
1650
- });
1651
- }
1652
-
1653
- this.getImageObjects().forEach((obj: any) => {
1654
- const id = obj?.data?.id;
1655
- if (typeof id === "string" && !desiredIds.has(id)) {
1656
- this.canvasService?.canvas.remove(obj);
1657
- }
1658
- });
1659
-
1660
- for (const item of renderItems) {
1661
- if (seq !== this.renderSeq) return;
1662
- await this.upsertImageObject(item, frame, seq);
1663
- }
1664
- if (seq !== this.renderSeq) return;
1665
-
1666
- this.syncImageZOrder(renderItems);
1667
- const sceneGeometry = await this.resolveSceneGeometryForOverlay();
1668
- if (seq !== this.renderSeq) return;
1669
-
1670
- const overlaySpecs = this.buildOverlaySpecs(frame, sceneGeometry);
1671
- this.overlaySpecs = overlaySpecs;
1672
- await this.canvasService.flushRenderFromProducers();
1673
- this.syncImageZOrder(renderItems);
1674
- const overlayCanvasCount = this.getOverlayObjects().length;
1675
-
1676
- this.debug("render:done", {
1677
- seq,
1678
- renderCount: renderItems.length,
1679
- overlayCount: overlaySpecs.length,
1680
- overlayCanvasCount,
1681
- isToolActive: this.isToolActive,
1682
- isImageSelectionActive: this.isImageSelectionActive,
1683
- focusedImageId: this.focusedImageId,
1684
- });
1685
- this.canvasService.requestRenderAll();
1686
- }
1687
-
1688
- private clampNormalized(value: number): number {
1689
- return Math.max(-1, Math.min(2, value));
1690
- }
1691
-
1692
- private onObjectModified = (e: any) => {
1693
- if (!this.isToolActive) return;
1694
- const target = e?.target;
1695
- const id = target?.data?.id;
1696
- const layerId = target?.data?.layerId;
1697
- if (typeof id !== "string" || layerId !== IMAGE_OBJECT_LAYER_ID) return;
1698
-
1699
- const frame = this.getFrameRect();
1700
- if (!frame.width || !frame.height) return;
1701
-
1702
- const center = target.getCenterPoint
1703
- ? target.getCenterPoint()
1704
- : new Point(target.left ?? 0, target.top ?? 0);
1705
- const centerScene = this.canvasService
1706
- ? this.canvasService.toScenePoint({ x: center.x, y: center.y })
1707
- : { x: center.x, y: center.y };
1708
-
1709
- const objectScale = Number.isFinite(target?.scaleX) ? target.scaleX : 1;
1710
- const objectScaleScene = this.toSceneObjectScale(objectScale || 1);
1711
-
1712
- const workingItem = this.workingItems.find((item) => item.id === id);
1713
- const sourceKey = workingItem?.sourceUrl || workingItem?.url || "";
1714
- const sourceSize = this.getSourceSize(sourceKey, target);
1715
- const coverScale = this.getCoverScale(frame, sourceSize);
1716
-
1717
- const updates: Partial<ImageItem> = {
1718
- left: this.clampNormalized((centerScene.x - frame.left) / frame.width),
1719
- top: this.clampNormalized((centerScene.y - frame.top) / frame.height),
1720
- angle: Number.isFinite(target.angle) ? target.angle : 0,
1721
- scale: Math.max(0.05, objectScaleScene / coverScale),
1722
- };
1723
-
1724
- this.focusedImageId = id;
1725
- this.updateImageInWorking(id, updates);
1726
- };
1727
-
1728
- private updateImageInWorking(id: string, updates: Partial<ImageItem>) {
1729
- const index = this.workingItems.findIndex((item) => item.id === id);
1730
- if (index < 0) return;
1731
-
1732
- const next = [...this.workingItems];
1733
- next[index] = this.normalizeItem({ ...next[index], ...updates });
1734
- this.workingItems = next;
1735
- this.hasWorkingChanges = true;
1736
- this.setImageFocus(id, {
1737
- syncCanvasSelection: false,
1738
- skipRender: true,
1739
- });
1740
- if (this.isToolActive) {
1741
- this.updateImages();
1742
- }
1743
- this.emitWorkingChange(id);
1744
- }
1745
-
1746
- private async updateImageInConfig(id: string, updates: Partial<ImageItem>) {
1747
- const index = this.items.findIndex((item) => item.id === id);
1748
- if (index < 0) return;
1749
-
1750
- const replacingSource =
1751
- typeof updates.url === "string" && updates.url.length > 0;
1752
- const next = [...this.items];
1753
- const base = next[index];
1754
- const replacingUrl = replacingSource ? (updates.url as string) : undefined;
1755
-
1756
- next[index] = this.normalizeItem({
1757
- ...base,
1758
- ...updates,
1759
- ...(replacingSource
1760
- ? {
1761
- url: replacingUrl,
1762
- sourceUrl: replacingUrl,
1763
- committedUrl: undefined,
1764
- scale: updates.scale ?? 1,
1765
- angle: updates.angle ?? 0,
1766
- left: updates.left ?? 0.5,
1767
- top: updates.top ?? 0.5,
1768
- }
1769
- : {}),
1770
- });
1771
-
1772
- this.updateConfig(next);
1773
-
1774
- if (replacingSource) {
1775
- this.debug("replace:image:begin", { id, replacingUrl });
1776
- this.purgeSourceSizeCacheForItem(base);
1777
- const loaded = await this.waitImageLoaded(id, true);
1778
- this.debug("replace:image:loaded", { id, loaded });
1779
- if (loaded) {
1780
- await this.refitImageToFrame(id);
1781
- this.setImageFocus(id);
1782
- }
1783
- }
1784
- }
1785
-
1786
- private waitImageLoaded(id: string, forceWait = false): Promise<boolean> {
1787
- if (!forceWait && this.getImageObject(id)) {
1788
- return Promise.resolve(true);
1789
- }
1790
-
1791
- return new Promise<boolean>((resolve) => {
1792
- const timeout = setTimeout(() => {
1793
- this.loadResolvers.delete(id);
1794
- resolve(false);
1795
- }, 4000);
1796
-
1797
- this.loadResolvers.set(id, () => {
1798
- clearTimeout(timeout);
1799
- resolve(true);
1800
- });
1801
- });
1802
- }
1803
-
1804
- private async refitImageToFrame(id: string) {
1805
- const obj = this.getImageObject(id);
1806
- if (!obj || !this.canvasService) return;
1807
- const current = this.items.find((item) => item.id === id);
1808
- if (!current) return;
1809
- const render = this.resolveRenderImageState(current);
1810
-
1811
- this.rememberSourceSize(render.src, obj);
1812
- const source = this.getSourceSize(render.src, obj);
1813
- const frame = this.getFrameRect();
1814
- const coverScale = this.getCoverScale(frame, source);
1815
-
1816
- const currentScale = this.toSceneObjectScale(obj.scaleX || 1);
1817
- const zoom = Math.max(0.05, currentScale / coverScale);
1818
-
1819
- const updated: Partial<ImageItem> = {
1820
- scale: Number.isFinite(zoom) ? zoom : 1,
1821
- angle: 0,
1822
- left: 0.5,
1823
- top: 0.5,
1824
- };
1825
-
1826
- const index = this.items.findIndex((item) => item.id === id);
1827
- if (index < 0) return;
1828
-
1829
- const next = [...this.items];
1830
- next[index] = this.normalizeItem({ ...next[index], ...updated });
1831
- this.updateConfig(next);
1832
- this.workingItems = this.cloneItems(next);
1833
- this.hasWorkingChanges = false;
1834
- this.updateImages();
1835
- this.emitWorkingChange(id);
1836
- }
1837
-
1838
- private async fitImageToArea(
1839
- id: string,
1840
- area: { width: number; height: number; left?: number; top?: number },
1841
- ) {
1842
- if (!this.canvasService) return;
1843
-
1844
- const loaded = await this.waitImageLoaded(id, false);
1845
- if (!loaded) return;
1846
-
1847
- const obj = this.getImageObject(id);
1848
- if (!obj) return;
1849
- const renderItems = this.isToolActive ? this.workingItems : this.items;
1850
- const current = renderItems.find((item) => item.id === id);
1851
- if (!current) return;
1852
- const render = this.resolveRenderImageState(current);
1853
-
1854
- this.rememberSourceSize(render.src, obj);
1855
- const source = this.getSourceSize(render.src, obj);
1856
- const frame = this.getFrameRect();
1857
- const baseCover = this.getCoverScale(frame, source);
1858
-
1859
- const desiredScale = Math.max(
1860
- Math.max(1, area.width) / Math.max(1, source.width),
1861
- Math.max(1, area.height) / Math.max(1, source.height),
1862
- );
1863
-
1864
- const viewport = this.canvasService.getSceneViewportRect();
1865
- const canvasW = viewport.width || 1;
1866
- const canvasH = viewport.height || 1;
1867
-
1868
- const areaLeftInput = area.left ?? 0.5;
1869
- const areaTopInput = area.top ?? 0.5;
1870
-
1871
- const areaLeftPx =
1872
- areaLeftInput <= 1.5
1873
- ? viewport.left + areaLeftInput * canvasW
1874
- : areaLeftInput;
1875
- const areaTopPx =
1876
- areaTopInput <= 1.5
1877
- ? viewport.top + areaTopInput * canvasH
1878
- : areaTopInput;
1879
-
1880
- const updates: Partial<ImageItem> = {
1881
- scale: Math.max(0.05, desiredScale / baseCover),
1882
- left: this.clampNormalized(
1883
- (areaLeftPx - frame.left) / Math.max(1, frame.width),
1884
- ),
1885
- top: this.clampNormalized(
1886
- (areaTopPx - frame.top) / Math.max(1, frame.height),
1887
- ),
1888
- };
1889
-
1890
- if (this.isToolActive) {
1891
- this.updateImageInWorking(id, updates);
1892
- return;
1893
- }
1894
-
1895
- await this.updateImageInConfig(id, updates);
1896
- }
1897
-
1898
- private async commitWorkingImagesAsCropped() {
1899
- if (!this.canvasService) {
1900
- return { ok: false, reason: "canvas-not-ready" };
1901
- }
1902
-
1903
- await this.updateImagesAsync();
1904
-
1905
- const frame = this.getFrameRect();
1906
- if (!frame.width || !frame.height) {
1907
- return { ok: false, reason: "frame-not-ready" };
1908
- }
1909
-
1910
- const next: ImageItem[] = [];
1911
- for (const item of this.workingItems) {
1912
- const exported = await this.exportCroppedImageByIds([item.id], {
1913
- multiplier: 2,
1914
- format: "png",
1915
- });
1916
- const url = exported.url;
1917
-
1918
- const sourceUrl = item.sourceUrl || item.url;
1919
- const previousCommitted = item.committedUrl;
1920
- next.push(
1921
- this.normalizeItem({
1922
- ...item,
1923
- url,
1924
- // Keep original source for next image-tool session editing,
1925
- // and use committedUrl as non-image-tools render source.
1926
- sourceUrl,
1927
- committedUrl: url,
1928
- }),
1929
- );
1930
- if (previousCommitted && previousCommitted !== url) {
1931
- this.sourceSizeBySrc.delete(previousCommitted);
1932
- }
1933
- }
1934
-
1935
- this.hasWorkingChanges = false;
1936
- this.workingItems = this.cloneItems(next);
1937
- this.updateConfig(next);
1938
- this.emitWorkingChange(this.focusedImageId);
1939
- return { ok: true };
1940
- }
1941
-
1942
- private async exportCroppedImageByIds(
1943
- imageIds: string[],
1944
- options: ExportCroppedImageOptions,
1945
- ): Promise<ExportUserCroppedImageResult> {
1946
- if (!this.canvasService) {
1947
- throw new Error("CanvasService not initialized");
1948
- }
1949
-
1950
- const normalizedIds = [...new Set(imageIds)].filter(
1951
- (id): id is string => typeof id === "string" && id.length > 0,
1952
- );
1953
- if (!normalizedIds.length) {
1954
- throw new Error("image-ids-required");
1955
- }
1956
-
1957
- const frameScene = this.getFrameRect();
1958
- const frame = this.getFrameRectScreen(frameScene);
1959
- const multiplier = Math.max(1, options.multiplier ?? 2);
1960
- const format: "png" | "jpeg" = options.format === "jpeg" ? "jpeg" : "png";
1961
-
1962
- const width = Math.max(1, Math.round(frame.width * multiplier));
1963
- const height = Math.max(1, Math.round(frame.height * multiplier));
1964
-
1965
- const el = document.createElement("canvas");
1966
- const tempCanvas = new FabricCanvas(el, {
1967
- renderOnAddRemove: false,
1968
- selection: false,
1969
- enableRetinaScaling: false,
1970
- preserveObjectStacking: true,
1971
- } as any);
1972
- tempCanvas.setDimensions({ width, height });
1973
-
1974
- try {
1975
- const idSet = new Set(normalizedIds);
1976
- const sourceObjects = this.canvasService.canvas
1977
- .getObjects()
1978
- .filter((obj: any) => {
1979
- return (
1980
- obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID &&
1981
- typeof obj?.data?.id === "string" &&
1982
- idSet.has(obj.data.id)
1983
- );
1984
- });
1985
-
1986
- if (!sourceObjects.length) {
1987
- throw new Error("image-objects-not-found");
1988
- }
1989
-
1990
- for (const source of sourceObjects as any[]) {
1991
- const clone = await source.clone();
1992
- const center = source.getCenterPoint
1993
- ? source.getCenterPoint()
1994
- : new Point(source.left ?? 0, source.top ?? 0);
1995
-
1996
- clone.set({
1997
- originX: "center",
1998
- originY: "center",
1999
- left: (center.x - frame.left) * multiplier,
2000
- top: (center.y - frame.top) * multiplier,
2001
- scaleX: (source.scaleX || 1) * multiplier,
2002
- scaleY: (source.scaleY || 1) * multiplier,
2003
- angle: source.angle || 0,
2004
- selectable: false,
2005
- evented: false,
2006
- });
2007
- clone.setCoords();
2008
- tempCanvas.add(clone);
2009
- }
2010
-
2011
- tempCanvas.renderAll();
2012
- const blob = await tempCanvas.toBlob({ format, multiplier: 1 });
2013
- if (!blob) {
2014
- throw new Error("image-export-failed");
2015
- }
2016
-
2017
- return {
2018
- url: URL.createObjectURL(blob),
2019
- width,
2020
- height,
2021
- multiplier,
2022
- format,
2023
- imageIds: (sourceObjects as any[])
2024
- .map((obj: any) => obj?.data?.id)
2025
- .filter((id: any): id is string => typeof id === "string"),
2026
- };
2027
- } finally {
2028
- tempCanvas.dispose();
2029
- }
2030
- }
2031
-
2032
- private async exportUserCroppedImage(
2033
- options: ExportUserCroppedImageOptions = {},
2034
- ): Promise<ExportUserCroppedImageResult> {
2035
- if (!this.canvasService) {
2036
- throw new Error("CanvasService not initialized");
2037
- }
2038
-
2039
- await this.updateImagesAsync();
2040
- this.syncToolActiveFromWorkbench();
2041
-
2042
- const imageIds =
2043
- options.imageIds && options.imageIds.length > 0
2044
- ? options.imageIds
2045
- : (this.isToolActive ? this.workingItems : this.items).map(
2046
- (item) => item.id,
2047
- );
2048
- if (!imageIds.length) {
2049
- throw new Error("no-images-to-export");
2050
- }
2051
-
2052
- return await this.exportCroppedImageByIds(imageIds, options);
2053
- }
2054
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ ToolSessionService,
9
+ WorkbenchService,
10
+ } from "@pooder/core";
11
+ import {
12
+ Canvas as FabricCanvas,
13
+ Image as FabricImage,
14
+ Pattern,
15
+ Point,
16
+ } from "fabric";
17
+ import { CanvasService, RenderLayoutRect, RenderObjectSpec } from "../services";
18
+ import { isDielineShape, normalizeShapeStyle } from "./dielineShape";
19
+ import type { DielineShape, DielineShapeStyle } from "./dielineShape";
20
+ import { generateDielinePath, getPathBounds } from "./geometry";
21
+ import {
22
+ buildSceneGeometry,
23
+ computeSceneLayout,
24
+ readSizeState,
25
+ } from "./sceneLayoutModel";
26
+
27
+ export interface ImageItem {
28
+ id: string;
29
+ url: string;
30
+ opacity: number;
31
+ scale?: number;
32
+ angle?: number;
33
+ left?: number;
34
+ top?: number;
35
+ sourceUrl?: string;
36
+ committedUrl?: string;
37
+ }
38
+
39
+ interface FrameRect {
40
+ left: number;
41
+ top: number;
42
+ width: number;
43
+ height: number;
44
+ }
45
+
46
+ interface SourceSize {
47
+ width: number;
48
+ height: number;
49
+ }
50
+
51
+ interface RenderImageState {
52
+ src: string;
53
+ left: number;
54
+ top: number;
55
+ scale: number;
56
+ angle: number;
57
+ opacity: number;
58
+ }
59
+
60
+ interface FrameVisualConfig {
61
+ strokeColor: string;
62
+ strokeWidth: number;
63
+ strokeStyle: "solid" | "dashed" | "hidden";
64
+ dashLength: number;
65
+ innerBackground: string;
66
+ outerBackground: string;
67
+ }
68
+
69
+ type ShapeOverlayShape = Exclude<DielineShape, "custom">;
70
+
71
+ interface SceneGeometryLike {
72
+ shape: DielineShape;
73
+ shapeStyle: DielineShapeStyle;
74
+ radius: number;
75
+ offset: number;
76
+ }
77
+
78
+ interface UpsertImageOptions {
79
+ id?: string;
80
+ mode?: "replace" | "add";
81
+ addOptions?: Partial<ImageItem>;
82
+ fitOnAdd?: boolean;
83
+ }
84
+
85
+ interface DielineFitArea {
86
+ width: number;
87
+ height: number;
88
+ left: number;
89
+ top: number;
90
+ }
91
+
92
+ interface UpdateImageOptions {
93
+ target?: "auto" | "config" | "working";
94
+ }
95
+
96
+ interface ExportCroppedImageOptions {
97
+ multiplier?: number;
98
+ format?: "png" | "jpeg";
99
+ }
100
+
101
+ interface ExportUserCroppedImageOptions extends ExportCroppedImageOptions {
102
+ imageIds?: string[];
103
+ }
104
+
105
+ interface ExportUserCroppedImageResult {
106
+ url: string;
107
+ width: number;
108
+ height: number;
109
+ multiplier: number;
110
+ format: "png" | "jpeg";
111
+ imageIds: string[];
112
+ }
113
+
114
+ const IMAGE_OBJECT_LAYER_ID = "image.user";
115
+ const IMAGE_OVERLAY_LAYER_ID = "image-overlay";
116
+
117
+ export class ImageTool implements Extension {
118
+ id = "pooder.kit.image";
119
+
120
+ metadata = {
121
+ name: "ImageTool",
122
+ };
123
+
124
+ private items: ImageItem[] = [];
125
+ private workingItems: ImageItem[] = [];
126
+ private hasWorkingChanges = false;
127
+ private loadResolvers: Map<string, () => void> = new Map();
128
+ private sourceSizeBySrc: Map<string, SourceSize> = new Map();
129
+ private canvasService?: CanvasService;
130
+ private context?: ExtensionContext;
131
+ private isUpdatingConfig = false;
132
+ private isToolActive = false;
133
+ private isImageSelectionActive = false;
134
+ private focusedImageId: string | null = null;
135
+ private renderSeq = 0;
136
+ private dirtyTrackerDisposable?: { dispose(): void };
137
+ private cropShapeHatchPattern?: Pattern;
138
+ private cropShapeHatchPatternColor?: string;
139
+ private cropShapeHatchPatternKey?: string;
140
+ private imageSpecs: RenderObjectSpec[] = [];
141
+ private overlaySpecs: RenderObjectSpec[] = [];
142
+ private renderProducerDisposable?: { dispose: () => void };
143
+
144
+ activate(context: ExtensionContext) {
145
+ this.context = context;
146
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
147
+ if (!this.canvasService) {
148
+ console.warn("CanvasService not found for ImageTool");
149
+ return;
150
+ }
151
+ this.renderProducerDisposable?.dispose();
152
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(
153
+ this.id,
154
+ () => ({
155
+ passes: [
156
+ {
157
+ id: IMAGE_OBJECT_LAYER_ID,
158
+ stack: 500,
159
+ order: 0,
160
+ visibility: {
161
+ op: "not",
162
+ expr: {
163
+ op: "sessionActive",
164
+ toolId: "pooder.kit.white-ink",
165
+ },
166
+ },
167
+ objects: this.imageSpecs,
168
+ },
169
+ {
170
+ id: IMAGE_OVERLAY_LAYER_ID,
171
+ stack: 800,
172
+ order: 0,
173
+ visibility: {
174
+ op: "not",
175
+ expr: {
176
+ op: "sessionActive",
177
+ toolId: "pooder.kit.white-ink",
178
+ },
179
+ },
180
+ objects: this.overlaySpecs,
181
+ },
182
+ ],
183
+ }),
184
+ { priority: 300 },
185
+ );
186
+
187
+ context.eventBus.on("tool:activated", this.onToolActivated);
188
+ context.eventBus.on("object:modified", this.onObjectModified);
189
+ context.eventBus.on("selection:created", this.onSelectionChanged);
190
+ context.eventBus.on("selection:updated", this.onSelectionChanged);
191
+ context.eventBus.on("selection:cleared", this.onSelectionCleared);
192
+ context.eventBus.on("scene:layout:change", this.onSceneLayoutChanged);
193
+ context.eventBus.on("scene:geometry:change", this.onSceneGeometryChanged);
194
+
195
+ const configService = context.services.get<ConfigurationService>(
196
+ "ConfigurationService",
197
+ );
198
+ if (configService) {
199
+ this.items = this.normalizeItems(
200
+ configService.get("image.items", []) || [],
201
+ );
202
+ this.workingItems = this.cloneItems(this.items);
203
+ this.hasWorkingChanges = false;
204
+
205
+ configService.onAnyChange((e: { key: string; value: any }) => {
206
+ if (this.isUpdatingConfig) return;
207
+
208
+ if (e.key === "image.items") {
209
+ this.items = this.normalizeItems(e.value || []);
210
+ if (!this.isToolActive || !this.hasWorkingChanges) {
211
+ this.workingItems = this.cloneItems(this.items);
212
+ this.hasWorkingChanges = false;
213
+ }
214
+ this.updateImages();
215
+ return;
216
+ }
217
+
218
+ if (e.key.startsWith("size.") || e.key.startsWith("image.frame.")) {
219
+ this.updateImages();
220
+ }
221
+ });
222
+ }
223
+
224
+ const toolSessionService =
225
+ context.services.get<ToolSessionService>("ToolSessionService");
226
+ this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(
227
+ this.id,
228
+ () => this.hasWorkingChanges,
229
+ );
230
+
231
+ this.updateImages();
232
+ }
233
+
234
+ deactivate(context: ExtensionContext) {
235
+ context.eventBus.off("tool:activated", this.onToolActivated);
236
+ context.eventBus.off("object:modified", this.onObjectModified);
237
+ context.eventBus.off("selection:created", this.onSelectionChanged);
238
+ context.eventBus.off("selection:updated", this.onSelectionChanged);
239
+ context.eventBus.off("selection:cleared", this.onSelectionCleared);
240
+ context.eventBus.off("scene:layout:change", this.onSceneLayoutChanged);
241
+ context.eventBus.off("scene:geometry:change", this.onSceneGeometryChanged);
242
+ this.dirtyTrackerDisposable?.dispose();
243
+ this.dirtyTrackerDisposable = undefined;
244
+ this.cropShapeHatchPattern = undefined;
245
+ this.cropShapeHatchPatternColor = undefined;
246
+ this.cropShapeHatchPatternKey = undefined;
247
+ this.imageSpecs = [];
248
+ this.overlaySpecs = [];
249
+
250
+ this.clearRenderedImages();
251
+ this.renderProducerDisposable?.dispose();
252
+ this.renderProducerDisposable = undefined;
253
+ if (this.canvasService) {
254
+ void this.canvasService.flushRenderFromProducers();
255
+ this.canvasService = undefined;
256
+ }
257
+ this.context = undefined;
258
+ }
259
+
260
+ private onToolActivated = (event: {
261
+ id: string | null;
262
+ previous?: string | null;
263
+ reason?: string;
264
+ }) => {
265
+ const before = this.isToolActive;
266
+ this.syncToolActiveFromWorkbench(event.id);
267
+ if (!this.isToolActive) {
268
+ this.setImageFocus(null, {
269
+ syncCanvasSelection: true,
270
+ skipRender: true,
271
+ });
272
+ }
273
+ this.debug("tool:activated", {
274
+ id: event.id,
275
+ previous: event.previous,
276
+ reason: event.reason,
277
+ before,
278
+ isToolActive: this.isToolActive,
279
+ focusedImageId: this.focusedImageId,
280
+ });
281
+ if (!this.isToolActive && this.isDebugEnabled()) {
282
+ console.trace("[ImageTool] tool deactivated trace");
283
+ }
284
+ this.updateImages();
285
+ };
286
+
287
+ private onSelectionChanged = (e: any) => {
288
+ const list: any[] = [];
289
+ if (Array.isArray(e?.selected)) {
290
+ list.push(...e.selected);
291
+ }
292
+ if (Array.isArray(e?.target?._objects)) {
293
+ list.push(...e.target._objects);
294
+ }
295
+ if (e?.target && !Array.isArray(e?.target?._objects)) {
296
+ list.push(e.target);
297
+ }
298
+
299
+ const selectedImage = list.find(
300
+ (obj: any) => obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID,
301
+ );
302
+ this.isImageSelectionActive = !!selectedImage;
303
+ if (selectedImage?.data?.id) {
304
+ this.focusedImageId = selectedImage.data.id;
305
+ } else if (list.length > 0) {
306
+ this.focusedImageId = null;
307
+ }
308
+ this.debug("selection:changed", {
309
+ listSize: list.length,
310
+ isImageSelectionActive: this.isImageSelectionActive,
311
+ focusedImageId: this.focusedImageId,
312
+ });
313
+ this.updateImages();
314
+ };
315
+
316
+ private onSelectionCleared = () => {
317
+ this.setImageFocus(null, {
318
+ syncCanvasSelection: false,
319
+ skipRender: true,
320
+ });
321
+ this.debug("selection:cleared applied");
322
+ this.updateImages();
323
+ };
324
+
325
+ private onSceneLayoutChanged = () => {
326
+ this.updateImages();
327
+ };
328
+
329
+ private onSceneGeometryChanged = () => {
330
+ this.updateImages();
331
+ };
332
+
333
+ private syncToolActiveFromWorkbench(fallbackId?: string | null) {
334
+ const wb = this.context?.services.get<WorkbenchService>("WorkbenchService");
335
+ const activeId = wb?.activeToolId;
336
+ if (typeof activeId === "string" || activeId === null) {
337
+ this.isToolActive = activeId === this.id;
338
+ return;
339
+ }
340
+ this.isToolActive = fallbackId === this.id;
341
+ }
342
+
343
+ private isImageEditingVisible(): boolean {
344
+ return (
345
+ this.isToolActive || this.isImageSelectionActive || !!this.focusedImageId
346
+ );
347
+ }
348
+
349
+ private isDebugEnabled(): boolean {
350
+ return !!this.getConfig<boolean>("image.debug", false);
351
+ }
352
+
353
+ private debug(message: string, payload?: any) {
354
+ if (!this.isDebugEnabled()) return;
355
+ if (payload === undefined) {
356
+ console.log(`[ImageTool] ${message}`);
357
+ return;
358
+ }
359
+ console.log(`[ImageTool] ${message}`, payload);
360
+ }
361
+
362
+ contribute() {
363
+ return {
364
+ [ContributionPointIds.TOOLS]: [
365
+ {
366
+ id: this.id,
367
+ name: "Image",
368
+ interaction: "session",
369
+ commands: {
370
+ begin: "resetWorkingImages",
371
+ commit: "completeImages",
372
+ rollback: "resetWorkingImages",
373
+ },
374
+ session: {
375
+ autoBegin: true,
376
+ leavePolicy: "block",
377
+ },
378
+ },
379
+ ],
380
+ [ContributionPointIds.CONFIGURATIONS]: [
381
+ {
382
+ id: "image.items",
383
+ type: "array",
384
+ label: "Images",
385
+ default: [],
386
+ },
387
+ {
388
+ id: "image.debug",
389
+ type: "boolean",
390
+ label: "Image Debug Log",
391
+ default: false,
392
+ },
393
+ {
394
+ id: "image.frame.strokeColor",
395
+ type: "color",
396
+ label: "Image Frame Stroke Color",
397
+ default: "#808080",
398
+ },
399
+ {
400
+ id: "image.frame.strokeWidth",
401
+ type: "number",
402
+ label: "Image Frame Stroke Width",
403
+ min: 0,
404
+ max: 20,
405
+ step: 0.5,
406
+ default: 2,
407
+ },
408
+ {
409
+ id: "image.frame.strokeStyle",
410
+ type: "select",
411
+ label: "Image Frame Stroke Style",
412
+ options: ["solid", "dashed", "hidden"],
413
+ default: "dashed",
414
+ },
415
+ {
416
+ id: "image.frame.dashLength",
417
+ type: "number",
418
+ label: "Image Frame Dash Length",
419
+ min: 1,
420
+ max: 40,
421
+ step: 1,
422
+ default: 8,
423
+ },
424
+ {
425
+ id: "image.frame.innerBackground",
426
+ type: "color",
427
+ label: "Image Frame Inner Background",
428
+ default: "rgba(0,0,0,0)",
429
+ },
430
+ {
431
+ id: "image.frame.outerBackground",
432
+ type: "color",
433
+ label: "Image Frame Outer Background",
434
+ default: "#f5f5f5",
435
+ },
436
+ ] as ConfigurationContribution[],
437
+ [ContributionPointIds.COMMANDS]: [
438
+ {
439
+ command: "addImage",
440
+ title: "Add Image",
441
+ handler: async (url: string, options?: Partial<ImageItem>) => {
442
+ const result = await this.upsertImageEntry(url, {
443
+ mode: "add",
444
+ addOptions: options,
445
+ });
446
+ return result.id;
447
+ },
448
+ },
449
+ {
450
+ command: "upsertImage",
451
+ title: "Upsert Image",
452
+ handler: async (url: string, options: UpsertImageOptions = {}) => {
453
+ return await this.upsertImageEntry(url, options);
454
+ },
455
+ },
456
+ {
457
+ command: "getWorkingImages",
458
+ title: "Get Working Images",
459
+ handler: () => {
460
+ return this.cloneItems(this.workingItems);
461
+ },
462
+ },
463
+ {
464
+ command: "setWorkingImage",
465
+ title: "Set Working Image",
466
+ handler: (id: string, updates: Partial<ImageItem>) => {
467
+ this.updateImageInWorking(id, updates);
468
+ },
469
+ },
470
+ {
471
+ command: "resetWorkingImages",
472
+ title: "Reset Working Images",
473
+ handler: () => {
474
+ this.workingItems = this.cloneItems(this.items);
475
+ this.hasWorkingChanges = false;
476
+ this.updateImages();
477
+ this.emitWorkingChange();
478
+ },
479
+ },
480
+ {
481
+ command: "completeImages",
482
+ title: "Complete Images",
483
+ handler: async () => {
484
+ return await this.commitWorkingImagesAsCropped();
485
+ },
486
+ },
487
+ {
488
+ command: "exportUserCroppedImage",
489
+ title: "Export User Cropped Image",
490
+ handler: async (options: ExportUserCroppedImageOptions = {}) => {
491
+ return await this.exportUserCroppedImage(options);
492
+ },
493
+ },
494
+ {
495
+ command: "fitImageToArea",
496
+ title: "Fit Image to Area",
497
+ handler: async (
498
+ id: string,
499
+ area: {
500
+ width: number;
501
+ height: number;
502
+ left?: number;
503
+ top?: number;
504
+ },
505
+ ) => {
506
+ await this.fitImageToArea(id, area);
507
+ },
508
+ },
509
+ {
510
+ command: "fitImageToDefaultArea",
511
+ title: "Fit Image to Default Area",
512
+ handler: async (id: string) => {
513
+ await this.fitImageToDefaultArea(id);
514
+ },
515
+ },
516
+ {
517
+ command: "focusImage",
518
+ title: "Focus Image",
519
+ handler: (
520
+ id: string | null,
521
+ options: { syncCanvasSelection?: boolean } = {},
522
+ ) => {
523
+ return this.setImageFocus(id, options);
524
+ },
525
+ },
526
+ {
527
+ command: "removeImage",
528
+ title: "Remove Image",
529
+ handler: (id: string) => {
530
+ const removed = this.items.find((item) => item.id === id);
531
+ const next = this.items.filter((item) => item.id !== id);
532
+ if (next.length !== this.items.length) {
533
+ this.purgeSourceSizeCacheForItem(removed);
534
+ if (this.focusedImageId === id) {
535
+ this.setImageFocus(null, {
536
+ syncCanvasSelection: true,
537
+ skipRender: true,
538
+ });
539
+ }
540
+ this.updateConfig(next);
541
+ }
542
+ },
543
+ },
544
+ {
545
+ command: "updateImage",
546
+ title: "Update Image",
547
+ handler: async (
548
+ id: string,
549
+ updates: Partial<ImageItem>,
550
+ options: UpdateImageOptions = {},
551
+ ) => {
552
+ await this.updateImage(id, updates, options);
553
+ },
554
+ },
555
+ {
556
+ command: "clearImages",
557
+ title: "Clear Images",
558
+ handler: () => {
559
+ this.sourceSizeBySrc.clear();
560
+ this.setImageFocus(null, {
561
+ syncCanvasSelection: true,
562
+ skipRender: true,
563
+ });
564
+ this.updateConfig([]);
565
+ },
566
+ },
567
+ {
568
+ command: "bringToFront",
569
+ title: "Bring Image to Front",
570
+ handler: (id: string) => {
571
+ const index = this.items.findIndex((item) => item.id === id);
572
+ if (index !== -1 && index < this.items.length - 1) {
573
+ const next = [...this.items];
574
+ const [item] = next.splice(index, 1);
575
+ next.push(item);
576
+ this.updateConfig(next);
577
+ }
578
+ },
579
+ },
580
+ {
581
+ command: "sendToBack",
582
+ title: "Send Image to Back",
583
+ handler: (id: string) => {
584
+ const index = this.items.findIndex((item) => item.id === id);
585
+ if (index > 0) {
586
+ const next = [...this.items];
587
+ const [item] = next.splice(index, 1);
588
+ next.unshift(item);
589
+ this.updateConfig(next);
590
+ }
591
+ },
592
+ },
593
+ ] as CommandContribution[],
594
+ };
595
+ }
596
+
597
+ private normalizeItem(item: ImageItem): ImageItem {
598
+ const url = typeof item.url === "string" ? item.url : "";
599
+ const sourceUrl =
600
+ typeof item.sourceUrl === "string" && item.sourceUrl.length > 0
601
+ ? item.sourceUrl
602
+ : url;
603
+ const committedUrl =
604
+ typeof item.committedUrl === "string" && item.committedUrl.length > 0
605
+ ? item.committedUrl
606
+ : undefined;
607
+
608
+ return {
609
+ ...item,
610
+ url: url || sourceUrl,
611
+ sourceUrl,
612
+ committedUrl,
613
+ opacity: Number.isFinite(item.opacity as any) ? item.opacity : 1,
614
+ scale: Number.isFinite(item.scale as any) ? item.scale : 1,
615
+ angle: Number.isFinite(item.angle as any) ? item.angle : 0,
616
+ left: Number.isFinite(item.left as any) ? item.left : 0.5,
617
+ top: Number.isFinite(item.top as any) ? item.top : 0.5,
618
+ };
619
+ }
620
+
621
+ private normalizeItems(items: ImageItem[]): ImageItem[] {
622
+ return (items || []).map((item) => this.normalizeItem(item));
623
+ }
624
+
625
+ private cloneItems(items: ImageItem[]): ImageItem[] {
626
+ return this.normalizeItems((items || []).map((i) => ({ ...i })));
627
+ }
628
+
629
+ private emitWorkingChange(changedId: string | null = null) {
630
+ this.context?.eventBus.emit("image:working:change", {
631
+ changedId,
632
+ items: this.cloneItems(this.workingItems),
633
+ });
634
+ }
635
+
636
+ private generateId(): string {
637
+ return Math.random().toString(36).substring(2, 9);
638
+ }
639
+
640
+ private hasImageItem(id: string): boolean {
641
+ return (
642
+ this.items.some((item) => item.id === id) ||
643
+ this.workingItems.some((item) => item.id === id)
644
+ );
645
+ }
646
+
647
+ private setImageFocus(
648
+ id: string | null,
649
+ options: { syncCanvasSelection?: boolean; skipRender?: boolean } = {},
650
+ ) {
651
+ const syncCanvasSelection = options.syncCanvasSelection !== false;
652
+
653
+ if (id && !this.hasImageItem(id)) {
654
+ return { ok: false, reason: "image-not-found" as const };
655
+ }
656
+
657
+ this.focusedImageId = id;
658
+ this.isImageSelectionActive = !!id;
659
+
660
+ if (syncCanvasSelection && this.canvasService) {
661
+ const canvas = this.canvasService.canvas;
662
+ if (!id) {
663
+ canvas.discardActiveObject();
664
+ } else {
665
+ const obj = this.getImageObject(id);
666
+ if (obj) {
667
+ obj.set({
668
+ selectable: true,
669
+ evented: true,
670
+ hasControls: true,
671
+ hasBorders: true,
672
+ });
673
+ canvas.setActiveObject(obj);
674
+ }
675
+ }
676
+ this.canvasService.requestRenderAll();
677
+ }
678
+
679
+ if (!options.skipRender) {
680
+ this.updateImages();
681
+ }
682
+
683
+ return { ok: true, id };
684
+ }
685
+
686
+ private async addImageEntry(
687
+ url: string,
688
+ options?: Partial<ImageItem>,
689
+ fitOnAdd = true,
690
+ ): Promise<string> {
691
+ const id = this.generateId();
692
+ const newItem = this.normalizeItem({
693
+ id,
694
+ url,
695
+ opacity: 1,
696
+ ...options,
697
+ } as ImageItem);
698
+
699
+ const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
700
+ const waitLoaded = this.waitImageLoaded(id, true);
701
+ this.updateConfig([...this.items, newItem]);
702
+ this.addItemToWorkingSessionIfNeeded(newItem, sessionDirtyBeforeAdd);
703
+ const loaded = await waitLoaded;
704
+ if (loaded && fitOnAdd) {
705
+ await this.fitImageToDefaultArea(id);
706
+ }
707
+ if (loaded) {
708
+ this.setImageFocus(id);
709
+ }
710
+ return id;
711
+ }
712
+
713
+ private async upsertImageEntry(
714
+ url: string,
715
+ options: UpsertImageOptions = {},
716
+ ): Promise<{ id: string; mode: "replace" | "add" }> {
717
+ const mode = options.mode || (options.id ? "replace" : "add");
718
+ const fitOnAdd = options.fitOnAdd !== false;
719
+ if (mode === "replace") {
720
+ if (!options.id) {
721
+ throw new Error("replace-target-id-required");
722
+ }
723
+ const targetId = options.id;
724
+ if (!this.hasImageItem(targetId)) {
725
+ throw new Error("replace-target-not-found");
726
+ }
727
+ await this.updateImageInConfig(targetId, { url });
728
+ return { id: targetId, mode: "replace" };
729
+ }
730
+
731
+ const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
732
+ return { id, mode: "add" };
733
+ }
734
+
735
+ private addItemToWorkingSessionIfNeeded(
736
+ item: ImageItem,
737
+ sessionDirtyBeforeAdd: boolean,
738
+ ) {
739
+ if (!sessionDirtyBeforeAdd || !this.isToolActive) return;
740
+ if (this.workingItems.some((existing) => existing.id === item.id)) return;
741
+ this.workingItems = this.cloneItems([...this.workingItems, item]);
742
+ this.updateImages();
743
+ this.emitWorkingChange(item.id);
744
+ }
745
+
746
+ private async updateImage(
747
+ id: string,
748
+ updates: Partial<ImageItem>,
749
+ options: UpdateImageOptions = {},
750
+ ) {
751
+ this.syncToolActiveFromWorkbench();
752
+ const target = options.target || "auto";
753
+
754
+ if (target === "working" || (target === "auto" && this.isToolActive)) {
755
+ this.updateImageInWorking(id, updates);
756
+ return;
757
+ }
758
+
759
+ await this.updateImageInConfig(id, updates);
760
+ }
761
+
762
+ private getConfig<T>(key: string, fallback?: T): T | undefined {
763
+ if (!this.context) return fallback;
764
+ const configService = this.context.services.get<ConfigurationService>(
765
+ "ConfigurationService",
766
+ );
767
+ if (!configService) return fallback;
768
+ return (configService.get(key, fallback) as T) ?? fallback;
769
+ }
770
+
771
+ private updateConfig(newItems: ImageItem[], skipCanvasUpdate = false) {
772
+ if (!this.context) return;
773
+
774
+ this.isUpdatingConfig = true;
775
+ this.items = this.normalizeItems(newItems);
776
+ if (!this.isToolActive || !this.hasWorkingChanges) {
777
+ this.workingItems = this.cloneItems(this.items);
778
+ this.hasWorkingChanges = false;
779
+ }
780
+
781
+ const configService = this.context.services.get<ConfigurationService>(
782
+ "ConfigurationService",
783
+ );
784
+ configService?.update("image.items", this.items);
785
+
786
+ if (!skipCanvasUpdate) {
787
+ this.updateImages();
788
+ }
789
+
790
+ setTimeout(() => {
791
+ this.isUpdatingConfig = false;
792
+ }, 50);
793
+ }
794
+
795
+ private getFrameRect(): FrameRect {
796
+ if (!this.canvasService) {
797
+ return { left: 0, top: 0, width: 0, height: 0 };
798
+ }
799
+ const configService = this.context?.services.get<ConfigurationService>(
800
+ "ConfigurationService",
801
+ );
802
+ if (!configService) {
803
+ return { left: 0, top: 0, width: 0, height: 0 };
804
+ }
805
+
806
+ const sizeState = readSizeState(configService);
807
+ const layout = computeSceneLayout(this.canvasService, sizeState);
808
+ if (!layout) {
809
+ return { left: 0, top: 0, width: 0, height: 0 };
810
+ }
811
+
812
+ return this.canvasService.toSceneRect({
813
+ left: layout.cutRect.left,
814
+ top: layout.cutRect.top,
815
+ width: layout.cutRect.width,
816
+ height: layout.cutRect.height,
817
+ });
818
+ }
819
+
820
+ private getFrameRectScreen(frame?: FrameRect): FrameRect {
821
+ if (!this.canvasService) {
822
+ return { left: 0, top: 0, width: 0, height: 0 };
823
+ }
824
+ return this.canvasService.toScreenRect(frame || this.getFrameRect());
825
+ }
826
+
827
+ private toLayoutSceneRect(rect: FrameRect): RenderLayoutRect {
828
+ return {
829
+ left: rect.left,
830
+ top: rect.top,
831
+ width: rect.width,
832
+ height: rect.height,
833
+ space: "scene",
834
+ };
835
+ }
836
+
837
+ private async resolveDefaultFitArea(): Promise<DielineFitArea | null> {
838
+ if (!this.canvasService) return null;
839
+ const frame = this.getFrameRect();
840
+ if (frame.width <= 0 || frame.height <= 0) return null;
841
+ return {
842
+ width: Math.max(1, frame.width),
843
+ height: Math.max(1, frame.height),
844
+ left: frame.left + frame.width / 2,
845
+ top: frame.top + frame.height / 2,
846
+ };
847
+ }
848
+
849
+ private async fitImageToDefaultArea(id: string) {
850
+ if (!this.canvasService) return;
851
+ const area = await this.resolveDefaultFitArea();
852
+
853
+ if (area) {
854
+ await this.fitImageToArea(id, area);
855
+ return;
856
+ }
857
+
858
+ const viewport = this.canvasService.getSceneViewportRect();
859
+ const canvasW = Math.max(1, viewport.width || 0);
860
+ const canvasH = Math.max(1, viewport.height || 0);
861
+ await this.fitImageToArea(id, {
862
+ width: canvasW,
863
+ height: canvasH,
864
+ left: viewport.left + canvasW / 2,
865
+ top: viewport.top + canvasH / 2,
866
+ });
867
+ }
868
+
869
+ private getImageObjects(): any[] {
870
+ if (!this.canvasService) return [];
871
+ return this.canvasService.canvas.getObjects().filter((obj: any) => {
872
+ return obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID;
873
+ }) as any[];
874
+ }
875
+
876
+ private getOverlayObjects(): any[] {
877
+ if (!this.canvasService) return [];
878
+ return this.canvasService.getPassObjects(IMAGE_OVERLAY_LAYER_ID) as any[];
879
+ }
880
+
881
+ private getImageObject(id: string): any | undefined {
882
+ return this.getImageObjects().find((obj: any) => obj?.data?.id === id);
883
+ }
884
+
885
+ private clearRenderedImages() {
886
+ if (!this.canvasService) return;
887
+ this.imageSpecs = [];
888
+ this.overlaySpecs = [];
889
+ this.canvasService.requestRenderFromProducers();
890
+ }
891
+
892
+ private purgeSourceSizeCacheForItem(item?: ImageItem) {
893
+ if (!item) return;
894
+ const sources = [item.url, item.sourceUrl, item.committedUrl].filter(
895
+ (value): value is string => typeof value === "string" && value.length > 0,
896
+ );
897
+ sources.forEach((src) => this.sourceSizeBySrc.delete(src));
898
+ }
899
+
900
+ private rememberSourceSize(src: string, obj: any) {
901
+ const width = Number(obj?.width || 0);
902
+ const height = Number(obj?.height || 0);
903
+ if (src && width > 0 && height > 0) {
904
+ this.sourceSizeBySrc.set(src, { width, height });
905
+ }
906
+ }
907
+
908
+ private getSourceSize(src: string, obj?: any): SourceSize {
909
+ const cached = src ? this.sourceSizeBySrc.get(src) : undefined;
910
+ if (cached) return cached;
911
+
912
+ const width = Number(obj?.width || 0);
913
+ const height = Number(obj?.height || 0);
914
+ if (src && width > 0 && height > 0) {
915
+ const size = { width, height };
916
+ this.sourceSizeBySrc.set(src, size);
917
+ return size;
918
+ }
919
+
920
+ return { width: 1, height: 1 };
921
+ }
922
+
923
+ private async ensureSourceSize(src: string): Promise<SourceSize | null> {
924
+ if (!src) return null;
925
+ const cached = this.sourceSizeBySrc.get(src);
926
+ if (cached) return cached;
927
+
928
+ try {
929
+ const image = await FabricImage.fromURL(src, {
930
+ crossOrigin: "anonymous",
931
+ });
932
+ const width = Number(image?.width || 0);
933
+ const height = Number(image?.height || 0);
934
+ if (width > 0 && height > 0) {
935
+ const size = { width, height };
936
+ this.sourceSizeBySrc.set(src, size);
937
+ return size;
938
+ }
939
+ } catch (error) {
940
+ this.debug("image:size:load-failed", {
941
+ src,
942
+ error: error instanceof Error ? error.message : String(error),
943
+ });
944
+ }
945
+
946
+ return null;
947
+ }
948
+
949
+ private getCoverScale(frame: FrameRect, size: SourceSize): number {
950
+ const sw = Math.max(1, size.width);
951
+ const sh = Math.max(1, size.height);
952
+ const fw = Math.max(1, frame.width);
953
+ const fh = Math.max(1, frame.height);
954
+ return Math.max(fw / sw, fh / sh);
955
+ }
956
+
957
+ private getFrameVisualConfig(): FrameVisualConfig {
958
+ const strokeStyleRaw = (this.getConfig<string>(
959
+ "image.frame.strokeStyle",
960
+ "dashed",
961
+ ) || "dashed") as string;
962
+ const strokeStyle: "solid" | "dashed" | "hidden" =
963
+ strokeStyleRaw === "dashed" || strokeStyleRaw === "hidden"
964
+ ? strokeStyleRaw
965
+ : "dashed";
966
+
967
+ const strokeWidth = Number(
968
+ this.getConfig<number>("image.frame.strokeWidth", 2) ?? 2,
969
+ );
970
+ const dashLength = Number(
971
+ this.getConfig<number>("image.frame.dashLength", 8) ?? 8,
972
+ );
973
+
974
+ return {
975
+ strokeColor:
976
+ this.getConfig<string>("image.frame.strokeColor", "#808080") ||
977
+ "#808080",
978
+ strokeWidth: Number.isFinite(strokeWidth) ? Math.max(0, strokeWidth) : 2,
979
+ strokeStyle,
980
+ dashLength: Number.isFinite(dashLength) ? Math.max(1, dashLength) : 8,
981
+ innerBackground:
982
+ this.getConfig<string>(
983
+ "image.frame.innerBackground",
984
+ "rgba(0,0,0,0)",
985
+ ) || "rgba(0,0,0,0)",
986
+ outerBackground:
987
+ this.getConfig<string>("image.frame.outerBackground", "#f5f5f5") ||
988
+ "#f5f5f5",
989
+ };
990
+ }
991
+
992
+ private toSceneGeometryLike(raw: any): SceneGeometryLike | null {
993
+ const shape = raw?.shape;
994
+ if (!isDielineShape(shape)) {
995
+ return null;
996
+ }
997
+
998
+ const radiusRaw = Number(raw?.radius);
999
+ const offsetRaw = Number(raw?.offset);
1000
+ const unit = typeof raw?.unit === "string" ? raw.unit : "px";
1001
+ const radius =
1002
+ unit === "scene" || !this.canvasService
1003
+ ? radiusRaw
1004
+ : this.canvasService.toSceneLength(radiusRaw);
1005
+ const offset =
1006
+ unit === "scene" || !this.canvasService
1007
+ ? offsetRaw
1008
+ : this.canvasService.toSceneLength(offsetRaw);
1009
+ return {
1010
+ shape,
1011
+ shapeStyle: normalizeShapeStyle(raw?.shapeStyle),
1012
+ radius: Number.isFinite(radius) ? radius : 0,
1013
+ offset: Number.isFinite(offset) ? offset : 0,
1014
+ };
1015
+ }
1016
+
1017
+ private async resolveSceneGeometryForOverlay(): Promise<SceneGeometryLike | null> {
1018
+ if (!this.context) return null;
1019
+ const commandService = this.context.services.get<any>("CommandService");
1020
+ if (commandService) {
1021
+ try {
1022
+ const raw = await Promise.resolve(
1023
+ commandService.executeCommand("getSceneGeometry"),
1024
+ );
1025
+ const geometry = this.toSceneGeometryLike(raw);
1026
+ if (geometry) {
1027
+ this.debug("overlay:sceneGeometry:command", geometry);
1028
+ return geometry;
1029
+ }
1030
+ this.debug("overlay:sceneGeometry:command:invalid", { raw });
1031
+ } catch (error) {
1032
+ this.debug("overlay:sceneGeometry:command:error", {
1033
+ error: error instanceof Error ? error.message : String(error),
1034
+ });
1035
+ }
1036
+ }
1037
+
1038
+ if (!this.canvasService) return null;
1039
+ const configService = this.context.services.get<ConfigurationService>(
1040
+ "ConfigurationService",
1041
+ );
1042
+ if (!configService) return null;
1043
+
1044
+ const sizeState = readSizeState(configService);
1045
+ const layout = computeSceneLayout(this.canvasService, sizeState);
1046
+ if (!layout) {
1047
+ this.debug("overlay:sceneGeometry:fallback:missing-layout");
1048
+ return null;
1049
+ }
1050
+
1051
+ const geometry = this.toSceneGeometryLike(
1052
+ buildSceneGeometry(configService, layout),
1053
+ );
1054
+ if (geometry) {
1055
+ this.debug("overlay:sceneGeometry:fallback", geometry);
1056
+ }
1057
+ return geometry;
1058
+ }
1059
+
1060
+ private resolveCutShapeRadius(
1061
+ geometry: SceneGeometryLike,
1062
+ frame: FrameRect,
1063
+ ): number {
1064
+ const visualRadius = Number.isFinite(geometry.radius)
1065
+ ? Math.max(0, geometry.radius)
1066
+ : 0;
1067
+ const visualOffset = Number.isFinite(geometry.offset) ? geometry.offset : 0;
1068
+ const rawCutRadius =
1069
+ visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
1070
+ const maxRadius = Math.max(0, Math.min(frame.width, frame.height) / 2);
1071
+ return Math.max(0, Math.min(maxRadius, rawCutRadius));
1072
+ }
1073
+
1074
+ private getCropShapeHatchPattern(
1075
+ color = "rgba(255, 0, 0, 0.6)",
1076
+ ): Pattern | undefined {
1077
+ if (typeof document === "undefined") return undefined;
1078
+ const sceneScale = this.canvasService?.getSceneScale() || 1;
1079
+ const cacheKey = `${color}::${sceneScale.toFixed(6)}`;
1080
+ if (
1081
+ this.cropShapeHatchPattern &&
1082
+ this.cropShapeHatchPatternColor === color &&
1083
+ this.cropShapeHatchPatternKey === cacheKey
1084
+ ) {
1085
+ return this.cropShapeHatchPattern;
1086
+ }
1087
+
1088
+ const size = 16;
1089
+ const patternCanvas = document.createElement("canvas");
1090
+ patternCanvas.width = size;
1091
+ patternCanvas.height = size;
1092
+ const ctx = patternCanvas.getContext("2d");
1093
+ if (!ctx) return undefined;
1094
+
1095
+ ctx.clearRect(0, 0, size, size);
1096
+ ctx.fillStyle = "rgba(255, 0, 0, 0.08)";
1097
+ ctx.fillRect(0, 0, size, size);
1098
+ ctx.strokeStyle = color;
1099
+ ctx.lineWidth = 1.5;
1100
+ ctx.beginPath();
1101
+ ctx.moveTo(-size, size);
1102
+ ctx.lineTo(size, -size);
1103
+ ctx.moveTo(-size / 2, size + size / 2);
1104
+ ctx.lineTo(size + size / 2, -size / 2);
1105
+ ctx.moveTo(0, size);
1106
+ ctx.lineTo(size, 0);
1107
+ ctx.moveTo(size / 2, size + size / 2);
1108
+ ctx.lineTo(size + size + size / 2, -size / 2);
1109
+ ctx.stroke();
1110
+
1111
+ const pattern = new Pattern({
1112
+ source: patternCanvas,
1113
+ // @ts-ignore: Fabric Pattern accepts canvas source here.
1114
+ repetition: "repeat",
1115
+ });
1116
+ // Scene specs are scaled to screen by CanvasService; keep hatch density in screen pixels.
1117
+ (pattern as any).patternTransform = [
1118
+ 1 / sceneScale,
1119
+ 0,
1120
+ 0,
1121
+ 1 / sceneScale,
1122
+ 0,
1123
+ 0,
1124
+ ];
1125
+ this.cropShapeHatchPattern = pattern;
1126
+ this.cropShapeHatchPatternColor = color;
1127
+ this.cropShapeHatchPatternKey = cacheKey;
1128
+ return pattern;
1129
+ }
1130
+
1131
+ private buildCropShapeOverlaySpecs(
1132
+ frame: FrameRect,
1133
+ sceneGeometry: SceneGeometryLike | null,
1134
+ ): RenderObjectSpec[] {
1135
+ if (!sceneGeometry) {
1136
+ this.debug("overlay:shape:skip", { reason: "scene-geometry-missing" });
1137
+ return [];
1138
+ }
1139
+ if (sceneGeometry.shape === "custom") {
1140
+ this.debug("overlay:shape:skip", { reason: "shape-custom" });
1141
+ return [];
1142
+ }
1143
+
1144
+ const shape = sceneGeometry.shape as ShapeOverlayShape;
1145
+ const shapeStyle = sceneGeometry.shapeStyle;
1146
+ const inset = 0;
1147
+ const shapeWidth = Math.max(1, frame.width);
1148
+ const shapeHeight = Math.max(1, frame.height);
1149
+ const radius = this.resolveCutShapeRadius(sceneGeometry, frame);
1150
+
1151
+ this.debug("overlay:shape:geometry", {
1152
+ shape,
1153
+ frameWidth: frame.width,
1154
+ frameHeight: frame.height,
1155
+ offset: sceneGeometry.offset,
1156
+ shapeStyle,
1157
+ inset,
1158
+ shapeWidth,
1159
+ shapeHeight,
1160
+ baseRadius: sceneGeometry.radius,
1161
+ radius,
1162
+ });
1163
+
1164
+ const isSameAsFrame =
1165
+ Math.abs(shapeWidth - frame.width) <= 0.0001 &&
1166
+ Math.abs(shapeHeight - frame.height) <= 0.0001;
1167
+ if (shape === "rect" && radius <= 0.0001 && isSameAsFrame) {
1168
+ this.debug("overlay:shape:skip", {
1169
+ reason: "shape-rect-no-radius",
1170
+ });
1171
+ return [];
1172
+ }
1173
+
1174
+ const baseOptions = {
1175
+ shape,
1176
+ width: shapeWidth,
1177
+ height: shapeHeight,
1178
+ radius,
1179
+ x: frame.width / 2,
1180
+ y: frame.height / 2,
1181
+ features: [],
1182
+ shapeStyle,
1183
+ canvasWidth: frame.width,
1184
+ canvasHeight: frame.height,
1185
+ };
1186
+
1187
+ try {
1188
+ const shapePathData = generateDielinePath(baseOptions);
1189
+ const outerRectPathData = `M 0 0 L ${frame.width} 0 L ${frame.width} ${frame.height} L 0 ${frame.height} Z`;
1190
+ const hatchPathData = `${outerRectPathData} ${shapePathData}`;
1191
+ if (!shapePathData || !hatchPathData) {
1192
+ this.debug("overlay:shape:skip", {
1193
+ reason: "path-generation-empty",
1194
+ shape,
1195
+ radius,
1196
+ });
1197
+ return [];
1198
+ }
1199
+
1200
+ const patternFill = this.getCropShapeHatchPattern();
1201
+ const hatchFill = patternFill || "rgba(255, 0, 0, 0.22)";
1202
+ const shapeBounds = getPathBounds(shapePathData);
1203
+ const hatchBounds = getPathBounds(hatchPathData);
1204
+ const frameRect = this.toLayoutSceneRect(frame);
1205
+ const hatchPathLength = hatchPathData.length;
1206
+ const shapePathLength = shapePathData.length;
1207
+ const specs: RenderObjectSpec[] = [
1208
+ {
1209
+ id: "image.cropShapeHatch",
1210
+ type: "path",
1211
+ data: { id: "image.cropShapeHatch", zIndex: 5 },
1212
+ layout: {
1213
+ reference: "custom",
1214
+ referenceRect: frameRect,
1215
+ alignX: "start",
1216
+ alignY: "start",
1217
+ offsetX: hatchBounds.x,
1218
+ offsetY: hatchBounds.y,
1219
+ },
1220
+ props: {
1221
+ pathData: hatchPathData,
1222
+ originX: "left",
1223
+ originY: "top",
1224
+ fill: hatchFill,
1225
+ opacity: patternFill ? 1 : 0.8,
1226
+ stroke: null,
1227
+ fillRule: "evenodd",
1228
+ selectable: false,
1229
+ evented: false,
1230
+ excludeFromExport: true,
1231
+ objectCaching: false,
1232
+ },
1233
+ },
1234
+ {
1235
+ id: "image.cropShapePath",
1236
+ type: "path",
1237
+ data: { id: "image.cropShapePath", zIndex: 6 },
1238
+ layout: {
1239
+ reference: "custom",
1240
+ referenceRect: frameRect,
1241
+ alignX: "start",
1242
+ alignY: "start",
1243
+ offsetX: shapeBounds.x,
1244
+ offsetY: shapeBounds.y,
1245
+ },
1246
+ props: {
1247
+ pathData: shapePathData,
1248
+ originX: "left",
1249
+ originY: "top",
1250
+ fill: "rgba(0,0,0,0)",
1251
+ stroke: "rgba(255, 0, 0, 0.9)",
1252
+ strokeWidth: this.canvasService?.toSceneLength(1) ?? 1,
1253
+ selectable: false,
1254
+ evented: false,
1255
+ excludeFromExport: true,
1256
+ objectCaching: false,
1257
+ },
1258
+ },
1259
+ ];
1260
+ this.debug("overlay:shape:built", {
1261
+ shape,
1262
+ radius,
1263
+ inset,
1264
+ shapeWidth,
1265
+ shapeHeight,
1266
+ fillRule: "evenodd",
1267
+ shapePathLength,
1268
+ hatchPathLength,
1269
+ shapeBounds,
1270
+ hatchBounds,
1271
+ hatchFillType:
1272
+ hatchFill && typeof hatchFill === "object" ? "pattern" : "color",
1273
+ ids: specs.map((spec) => spec.id),
1274
+ });
1275
+ return specs;
1276
+ } catch (error) {
1277
+ this.debug("overlay:shape:error", {
1278
+ shape,
1279
+ radius,
1280
+ error: error instanceof Error ? error.message : String(error),
1281
+ });
1282
+ return [];
1283
+ }
1284
+ }
1285
+
1286
+ private resolveRenderImageState(item: ImageItem): RenderImageState {
1287
+ const active = this.isToolActive;
1288
+ const sourceUrl = item.sourceUrl || item.url;
1289
+ const committedUrl = item.committedUrl;
1290
+
1291
+ if (!active && committedUrl) {
1292
+ return {
1293
+ src: committedUrl,
1294
+ left: 0.5,
1295
+ top: 0.5,
1296
+ scale: 1,
1297
+ angle: 0,
1298
+ opacity: item.opacity,
1299
+ };
1300
+ }
1301
+
1302
+ return {
1303
+ src: sourceUrl || item.url,
1304
+ left: Number.isFinite(item.left as any) ? (item.left as number) : 0.5,
1305
+ top: Number.isFinite(item.top as any) ? (item.top as number) : 0.5,
1306
+ scale: Math.max(0.05, item.scale ?? 1),
1307
+ angle: Number.isFinite(item.angle as any) ? (item.angle as number) : 0,
1308
+ opacity: item.opacity,
1309
+ };
1310
+ }
1311
+
1312
+ private computeCanvasProps(
1313
+ render: RenderImageState,
1314
+ size: SourceSize,
1315
+ frame: FrameRect,
1316
+ ) {
1317
+ const left = render.left;
1318
+ const top = render.top;
1319
+ const zoom = render.scale;
1320
+ const angle = render.angle;
1321
+
1322
+ const centerX = frame.left + left * frame.width;
1323
+ const centerY = frame.top + top * frame.height;
1324
+ const scale = this.getCoverScale(frame, size) * zoom;
1325
+
1326
+ return {
1327
+ left: centerX,
1328
+ top: centerY,
1329
+ scaleX: scale,
1330
+ scaleY: scale,
1331
+ angle,
1332
+ originX: "center" as const,
1333
+ originY: "center" as const,
1334
+ uniformScaling: true,
1335
+ lockScalingFlip: true,
1336
+ selectable: this.isImageEditingVisible(),
1337
+ evented: this.isImageEditingVisible(),
1338
+ hasControls: this.isImageEditingVisible(),
1339
+ hasBorders: this.isImageEditingVisible(),
1340
+ opacity: render.opacity,
1341
+ };
1342
+ }
1343
+
1344
+ private toSceneObjectScale(value: number): number {
1345
+ if (!this.canvasService) return value;
1346
+ return value / this.canvasService.getSceneScale();
1347
+ }
1348
+
1349
+ private getCurrentSrc(obj: any): string | undefined {
1350
+ if (!obj) return undefined;
1351
+ if (typeof obj.getSrc === "function") return obj.getSrc();
1352
+ return obj?._originalElement?.src;
1353
+ }
1354
+
1355
+ private async buildImageSpecs(
1356
+ items: ImageItem[],
1357
+ frame: FrameRect,
1358
+ ): Promise<RenderObjectSpec[]> {
1359
+ const specs: RenderObjectSpec[] = [];
1360
+
1361
+ for (const item of items) {
1362
+ const render = this.resolveRenderImageState(item);
1363
+ if (!render.src) continue;
1364
+
1365
+ const ensured = await this.ensureSourceSize(render.src);
1366
+ const sourceSize = ensured || this.getSourceSize(render.src);
1367
+ const props = this.computeCanvasProps(render, sourceSize, frame);
1368
+
1369
+ specs.push({
1370
+ id: item.id,
1371
+ type: "image",
1372
+ src: render.src,
1373
+ data: {
1374
+ id: item.id,
1375
+ layerId: IMAGE_OBJECT_LAYER_ID,
1376
+ type: "image-item",
1377
+ },
1378
+ props,
1379
+ });
1380
+ }
1381
+
1382
+ return specs;
1383
+ }
1384
+
1385
+ private buildOverlaySpecs(
1386
+ frame: FrameRect,
1387
+ sceneGeometry: SceneGeometryLike | null,
1388
+ ): RenderObjectSpec[] {
1389
+ const visible = this.isImageEditingVisible();
1390
+ if (
1391
+ !visible ||
1392
+ frame.width <= 0 ||
1393
+ frame.height <= 0 ||
1394
+ !this.canvasService
1395
+ ) {
1396
+ this.debug("overlay:hidden", {
1397
+ visible,
1398
+ frame,
1399
+ isToolActive: this.isToolActive,
1400
+ isImageSelectionActive: this.isImageSelectionActive,
1401
+ focusedImageId: this.focusedImageId,
1402
+ });
1403
+ return [];
1404
+ }
1405
+
1406
+ const viewport = this.canvasService.getSceneViewportRect();
1407
+ const canvasW = viewport.width || 0;
1408
+ const canvasH = viewport.height || 0;
1409
+ const canvasLeft = viewport.left || 0;
1410
+ const canvasTop = viewport.top || 0;
1411
+ const visual = this.getFrameVisualConfig();
1412
+ const strokeWidthScene = this.canvasService.toSceneLength(
1413
+ visual.strokeWidth,
1414
+ );
1415
+ const dashLengthScene = this.canvasService.toSceneLength(visual.dashLength);
1416
+
1417
+ const frameLeft = Math.max(
1418
+ canvasLeft,
1419
+ Math.min(canvasLeft + canvasW, frame.left),
1420
+ );
1421
+ const frameTop = Math.max(
1422
+ canvasTop,
1423
+ Math.min(canvasTop + canvasH, frame.top),
1424
+ );
1425
+ const frameRight = Math.max(
1426
+ frameLeft,
1427
+ Math.min(canvasLeft + canvasW, frame.left + frame.width),
1428
+ );
1429
+ const frameBottom = Math.max(
1430
+ frameTop,
1431
+ Math.min(canvasTop + canvasH, frame.top + frame.height),
1432
+ );
1433
+ const visibleFrameH = Math.max(0, frameBottom - frameTop);
1434
+
1435
+ const topH = Math.max(0, frameTop - canvasTop);
1436
+ const bottomH = Math.max(0, canvasTop + canvasH - frameBottom);
1437
+ const leftW = Math.max(0, frameLeft - canvasLeft);
1438
+ const rightW = Math.max(0, canvasLeft + canvasW - frameRight);
1439
+ const viewportRect = this.toLayoutSceneRect({
1440
+ left: canvasLeft,
1441
+ top: canvasTop,
1442
+ width: canvasW,
1443
+ height: canvasH,
1444
+ });
1445
+ const visibleFrameBandRect = this.toLayoutSceneRect({
1446
+ left: canvasLeft,
1447
+ top: frameTop,
1448
+ width: canvasW,
1449
+ height: visibleFrameH,
1450
+ });
1451
+ const frameRect = this.toLayoutSceneRect(frame);
1452
+ const shapeOverlay = this.buildCropShapeOverlaySpecs(frame, sceneGeometry);
1453
+
1454
+ const mask: RenderObjectSpec[] = [
1455
+ {
1456
+ id: "image.cropMask.top",
1457
+ type: "rect",
1458
+ data: { id: "image.cropMask.top", zIndex: 1 },
1459
+ layout: {
1460
+ reference: "custom",
1461
+ referenceRect: viewportRect,
1462
+ alignX: "start",
1463
+ alignY: "start",
1464
+ width: "100%",
1465
+ height: topH,
1466
+ },
1467
+ props: {
1468
+ originX: "left",
1469
+ originY: "top",
1470
+ fill: visual.outerBackground,
1471
+ selectable: false,
1472
+ evented: false,
1473
+ },
1474
+ },
1475
+ {
1476
+ id: "image.cropMask.bottom",
1477
+ type: "rect",
1478
+ data: { id: "image.cropMask.bottom", zIndex: 2 },
1479
+ layout: {
1480
+ reference: "custom",
1481
+ referenceRect: viewportRect,
1482
+ alignX: "start",
1483
+ alignY: "end",
1484
+ width: "100%",
1485
+ height: bottomH,
1486
+ },
1487
+ props: {
1488
+ originX: "left",
1489
+ originY: "top",
1490
+ fill: visual.outerBackground,
1491
+ selectable: false,
1492
+ evented: false,
1493
+ },
1494
+ },
1495
+ {
1496
+ id: "image.cropMask.left",
1497
+ type: "rect",
1498
+ data: { id: "image.cropMask.left", zIndex: 3 },
1499
+ layout: {
1500
+ reference: "custom",
1501
+ referenceRect: visibleFrameBandRect,
1502
+ alignX: "start",
1503
+ alignY: "start",
1504
+ width: leftW,
1505
+ height: "100%",
1506
+ },
1507
+ props: {
1508
+ originX: "left",
1509
+ originY: "top",
1510
+ fill: visual.outerBackground,
1511
+ selectable: false,
1512
+ evented: false,
1513
+ },
1514
+ },
1515
+ {
1516
+ id: "image.cropMask.right",
1517
+ type: "rect",
1518
+ data: { id: "image.cropMask.right", zIndex: 4 },
1519
+ layout: {
1520
+ reference: "custom",
1521
+ referenceRect: visibleFrameBandRect,
1522
+ alignX: "end",
1523
+ alignY: "start",
1524
+ width: rightW,
1525
+ height: "100%",
1526
+ },
1527
+ props: {
1528
+ originX: "left",
1529
+ originY: "top",
1530
+ fill: visual.outerBackground,
1531
+ selectable: false,
1532
+ evented: false,
1533
+ },
1534
+ },
1535
+ ];
1536
+
1537
+ const frameSpec: RenderObjectSpec = {
1538
+ id: "image.cropFrame",
1539
+ type: "rect",
1540
+ data: { id: "image.cropFrame", zIndex: 7 },
1541
+ layout: {
1542
+ reference: "custom",
1543
+ referenceRect: frameRect,
1544
+ alignX: "start",
1545
+ alignY: "start",
1546
+ width: "100%",
1547
+ height: "100%",
1548
+ },
1549
+ props: {
1550
+ originX: "left",
1551
+ originY: "top",
1552
+ fill: visual.innerBackground,
1553
+ stroke:
1554
+ visual.strokeStyle === "hidden"
1555
+ ? "rgba(0,0,0,0)"
1556
+ : visual.strokeColor,
1557
+ strokeWidth: visual.strokeStyle === "hidden" ? 0 : strokeWidthScene,
1558
+ strokeDashArray:
1559
+ visual.strokeStyle === "dashed"
1560
+ ? [dashLengthScene, dashLengthScene]
1561
+ : undefined,
1562
+ selectable: false,
1563
+ evented: false,
1564
+ },
1565
+ };
1566
+
1567
+ const specs =
1568
+ shapeOverlay.length > 0
1569
+ ? [...mask, ...shapeOverlay]
1570
+ : [...mask, ...shapeOverlay, frameSpec];
1571
+ this.debug("overlay:built", {
1572
+ frame,
1573
+ shape: sceneGeometry?.shape,
1574
+ overlayIds: specs.map((spec) => ({
1575
+ id: spec.id,
1576
+ zIndex: spec.data?.zIndex,
1577
+ })),
1578
+ });
1579
+ return specs;
1580
+ }
1581
+
1582
+ private updateImages() {
1583
+ void this.updateImagesAsync();
1584
+ }
1585
+
1586
+ private async updateImagesAsync() {
1587
+ if (!this.canvasService) return;
1588
+ this.syncToolActiveFromWorkbench();
1589
+ const seq = ++this.renderSeq;
1590
+
1591
+ const renderItems = this.isToolActive ? this.workingItems : this.items;
1592
+ const frame = this.getFrameRect();
1593
+ const desiredIds = new Set(renderItems.map((item) => item.id));
1594
+ if (this.focusedImageId && !desiredIds.has(this.focusedImageId)) {
1595
+ this.setImageFocus(null, {
1596
+ syncCanvasSelection: false,
1597
+ skipRender: true,
1598
+ });
1599
+ }
1600
+
1601
+ const imageSpecs = await this.buildImageSpecs(renderItems, frame);
1602
+ if (seq !== this.renderSeq) return;
1603
+
1604
+ const sceneGeometry = await this.resolveSceneGeometryForOverlay();
1605
+ if (seq !== this.renderSeq) return;
1606
+
1607
+ this.imageSpecs = imageSpecs;
1608
+ this.overlaySpecs = this.buildOverlaySpecs(frame, sceneGeometry);
1609
+ await this.canvasService.flushRenderFromProducers();
1610
+ if (seq !== this.renderSeq) return;
1611
+
1612
+ renderItems.forEach((item) => {
1613
+ if (!this.getImageObject(item.id)) return;
1614
+ const resolver = this.loadResolvers.get(item.id);
1615
+ if (!resolver) return;
1616
+ resolver();
1617
+ this.loadResolvers.delete(item.id);
1618
+ });
1619
+
1620
+ if (this.focusedImageId && this.isToolActive) {
1621
+ this.setImageFocus(this.focusedImageId, {
1622
+ syncCanvasSelection: true,
1623
+ skipRender: true,
1624
+ });
1625
+ }
1626
+
1627
+ const overlayCanvasCount = this.getOverlayObjects().length;
1628
+
1629
+ this.debug("render:done", {
1630
+ seq,
1631
+ renderCount: renderItems.length,
1632
+ overlayCount: this.overlaySpecs.length,
1633
+ overlayCanvasCount,
1634
+ isToolActive: this.isToolActive,
1635
+ isImageSelectionActive: this.isImageSelectionActive,
1636
+ focusedImageId: this.focusedImageId,
1637
+ });
1638
+ this.canvasService.requestRenderAll();
1639
+ }
1640
+
1641
+ private clampNormalized(value: number): number {
1642
+ return Math.max(-1, Math.min(2, value));
1643
+ }
1644
+
1645
+ private onObjectModified = (e: any) => {
1646
+ if (!this.isToolActive) return;
1647
+ const target = e?.target;
1648
+ const id = target?.data?.id;
1649
+ const layerId = target?.data?.layerId;
1650
+ if (typeof id !== "string" || layerId !== IMAGE_OBJECT_LAYER_ID) return;
1651
+
1652
+ const frame = this.getFrameRect();
1653
+ if (!frame.width || !frame.height) return;
1654
+
1655
+ const center = target.getCenterPoint
1656
+ ? target.getCenterPoint()
1657
+ : new Point(target.left ?? 0, target.top ?? 0);
1658
+ const centerScene = this.canvasService
1659
+ ? this.canvasService.toScenePoint({ x: center.x, y: center.y })
1660
+ : { x: center.x, y: center.y };
1661
+
1662
+ const objectScale = Number.isFinite(target?.scaleX) ? target.scaleX : 1;
1663
+ const objectScaleScene = this.toSceneObjectScale(objectScale || 1);
1664
+
1665
+ const workingItem = this.workingItems.find((item) => item.id === id);
1666
+ const sourceKey = workingItem?.sourceUrl || workingItem?.url || "";
1667
+ const sourceSize = this.getSourceSize(sourceKey, target);
1668
+ const coverScale = this.getCoverScale(frame, sourceSize);
1669
+
1670
+ const updates: Partial<ImageItem> = {
1671
+ left: this.clampNormalized((centerScene.x - frame.left) / frame.width),
1672
+ top: this.clampNormalized((centerScene.y - frame.top) / frame.height),
1673
+ angle: Number.isFinite(target.angle) ? target.angle : 0,
1674
+ scale: Math.max(0.05, objectScaleScene / coverScale),
1675
+ };
1676
+
1677
+ this.focusedImageId = id;
1678
+ this.updateImageInWorking(id, updates);
1679
+ };
1680
+
1681
+ private updateImageInWorking(id: string, updates: Partial<ImageItem>) {
1682
+ const index = this.workingItems.findIndex((item) => item.id === id);
1683
+ if (index < 0) return;
1684
+
1685
+ const next = [...this.workingItems];
1686
+ next[index] = this.normalizeItem({ ...next[index], ...updates });
1687
+ this.workingItems = next;
1688
+ this.hasWorkingChanges = true;
1689
+ this.setImageFocus(id, {
1690
+ syncCanvasSelection: false,
1691
+ skipRender: true,
1692
+ });
1693
+ if (this.isToolActive) {
1694
+ this.updateImages();
1695
+ }
1696
+ this.emitWorkingChange(id);
1697
+ }
1698
+
1699
+ private async updateImageInConfig(id: string, updates: Partial<ImageItem>) {
1700
+ const index = this.items.findIndex((item) => item.id === id);
1701
+ if (index < 0) return;
1702
+
1703
+ const replacingSource =
1704
+ typeof updates.url === "string" && updates.url.length > 0;
1705
+ const next = [...this.items];
1706
+ const base = next[index];
1707
+ const replacingUrl = replacingSource ? (updates.url as string) : undefined;
1708
+
1709
+ next[index] = this.normalizeItem({
1710
+ ...base,
1711
+ ...updates,
1712
+ ...(replacingSource
1713
+ ? {
1714
+ url: replacingUrl,
1715
+ sourceUrl: replacingUrl,
1716
+ committedUrl: undefined,
1717
+ scale: updates.scale ?? 1,
1718
+ angle: updates.angle ?? 0,
1719
+ left: updates.left ?? 0.5,
1720
+ top: updates.top ?? 0.5,
1721
+ }
1722
+ : {}),
1723
+ });
1724
+
1725
+ this.updateConfig(next);
1726
+
1727
+ if (replacingSource) {
1728
+ this.debug("replace:image:begin", { id, replacingUrl });
1729
+ this.purgeSourceSizeCacheForItem(base);
1730
+ const loaded = await this.waitImageLoaded(id, true);
1731
+ this.debug("replace:image:loaded", { id, loaded });
1732
+ if (loaded) {
1733
+ await this.refitImageToFrame(id);
1734
+ this.setImageFocus(id);
1735
+ }
1736
+ }
1737
+ }
1738
+
1739
+ private waitImageLoaded(id: string, forceWait = false): Promise<boolean> {
1740
+ if (!forceWait && this.getImageObject(id)) {
1741
+ return Promise.resolve(true);
1742
+ }
1743
+
1744
+ return new Promise<boolean>((resolve) => {
1745
+ const timeout = setTimeout(() => {
1746
+ this.loadResolvers.delete(id);
1747
+ resolve(false);
1748
+ }, 4000);
1749
+
1750
+ this.loadResolvers.set(id, () => {
1751
+ clearTimeout(timeout);
1752
+ resolve(true);
1753
+ });
1754
+ });
1755
+ }
1756
+
1757
+ private async refitImageToFrame(id: string) {
1758
+ const obj = this.getImageObject(id);
1759
+ if (!obj || !this.canvasService) return;
1760
+ const current = this.items.find((item) => item.id === id);
1761
+ if (!current) return;
1762
+ const render = this.resolveRenderImageState(current);
1763
+
1764
+ this.rememberSourceSize(render.src, obj);
1765
+ const source = this.getSourceSize(render.src, obj);
1766
+ const frame = this.getFrameRect();
1767
+ const coverScale = this.getCoverScale(frame, source);
1768
+
1769
+ const currentScale = this.toSceneObjectScale(obj.scaleX || 1);
1770
+ const zoom = Math.max(0.05, currentScale / coverScale);
1771
+
1772
+ const updated: Partial<ImageItem> = {
1773
+ scale: Number.isFinite(zoom) ? zoom : 1,
1774
+ angle: 0,
1775
+ left: 0.5,
1776
+ top: 0.5,
1777
+ };
1778
+
1779
+ const index = this.items.findIndex((item) => item.id === id);
1780
+ if (index < 0) return;
1781
+
1782
+ const next = [...this.items];
1783
+ next[index] = this.normalizeItem({ ...next[index], ...updated });
1784
+ this.updateConfig(next);
1785
+ this.workingItems = this.cloneItems(next);
1786
+ this.hasWorkingChanges = false;
1787
+ this.updateImages();
1788
+ this.emitWorkingChange(id);
1789
+ }
1790
+
1791
+ private async fitImageToArea(
1792
+ id: string,
1793
+ area: { width: number; height: number; left?: number; top?: number },
1794
+ ) {
1795
+ if (!this.canvasService) return;
1796
+
1797
+ const loaded = await this.waitImageLoaded(id, false);
1798
+ if (!loaded) return;
1799
+
1800
+ const obj = this.getImageObject(id);
1801
+ if (!obj) return;
1802
+ const renderItems = this.isToolActive ? this.workingItems : this.items;
1803
+ const current = renderItems.find((item) => item.id === id);
1804
+ if (!current) return;
1805
+ const render = this.resolveRenderImageState(current);
1806
+
1807
+ this.rememberSourceSize(render.src, obj);
1808
+ const source = this.getSourceSize(render.src, obj);
1809
+ const frame = this.getFrameRect();
1810
+ const baseCover = this.getCoverScale(frame, source);
1811
+
1812
+ const desiredScale = Math.max(
1813
+ Math.max(1, area.width) / Math.max(1, source.width),
1814
+ Math.max(1, area.height) / Math.max(1, source.height),
1815
+ );
1816
+
1817
+ const viewport = this.canvasService.getSceneViewportRect();
1818
+ const canvasW = viewport.width || 1;
1819
+ const canvasH = viewport.height || 1;
1820
+
1821
+ const areaLeftInput = area.left ?? 0.5;
1822
+ const areaTopInput = area.top ?? 0.5;
1823
+
1824
+ const areaLeftPx =
1825
+ areaLeftInput <= 1.5
1826
+ ? viewport.left + areaLeftInput * canvasW
1827
+ : areaLeftInput;
1828
+ const areaTopPx =
1829
+ areaTopInput <= 1.5
1830
+ ? viewport.top + areaTopInput * canvasH
1831
+ : areaTopInput;
1832
+
1833
+ const updates: Partial<ImageItem> = {
1834
+ scale: Math.max(0.05, desiredScale / baseCover),
1835
+ left: this.clampNormalized(
1836
+ (areaLeftPx - frame.left) / Math.max(1, frame.width),
1837
+ ),
1838
+ top: this.clampNormalized(
1839
+ (areaTopPx - frame.top) / Math.max(1, frame.height),
1840
+ ),
1841
+ };
1842
+
1843
+ if (this.isToolActive) {
1844
+ this.updateImageInWorking(id, updates);
1845
+ return;
1846
+ }
1847
+
1848
+ await this.updateImageInConfig(id, updates);
1849
+ }
1850
+
1851
+ private async commitWorkingImagesAsCropped() {
1852
+ if (!this.canvasService) {
1853
+ return { ok: false, reason: "canvas-not-ready" };
1854
+ }
1855
+
1856
+ await this.updateImagesAsync();
1857
+
1858
+ const frame = this.getFrameRect();
1859
+ if (!frame.width || !frame.height) {
1860
+ return { ok: false, reason: "frame-not-ready" };
1861
+ }
1862
+
1863
+ const next: ImageItem[] = [];
1864
+ for (const item of this.workingItems) {
1865
+ const exported = await this.exportCroppedImageByIds([item.id], {
1866
+ multiplier: 2,
1867
+ format: "png",
1868
+ });
1869
+ const url = exported.url;
1870
+
1871
+ const sourceUrl = item.sourceUrl || item.url;
1872
+ const previousCommitted = item.committedUrl;
1873
+ next.push(
1874
+ this.normalizeItem({
1875
+ ...item,
1876
+ url,
1877
+ // Keep original source for next image-tool session editing,
1878
+ // and use committedUrl as non-image-tools render source.
1879
+ sourceUrl,
1880
+ committedUrl: url,
1881
+ }),
1882
+ );
1883
+ if (previousCommitted && previousCommitted !== url) {
1884
+ this.sourceSizeBySrc.delete(previousCommitted);
1885
+ }
1886
+ }
1887
+
1888
+ this.hasWorkingChanges = false;
1889
+ this.workingItems = this.cloneItems(next);
1890
+ this.updateConfig(next);
1891
+ this.emitWorkingChange(this.focusedImageId);
1892
+ return { ok: true };
1893
+ }
1894
+
1895
+ private async exportCroppedImageByIds(
1896
+ imageIds: string[],
1897
+ options: ExportCroppedImageOptions,
1898
+ ): Promise<ExportUserCroppedImageResult> {
1899
+ if (!this.canvasService) {
1900
+ throw new Error("CanvasService not initialized");
1901
+ }
1902
+
1903
+ const normalizedIds = [...new Set(imageIds)].filter(
1904
+ (id): id is string => typeof id === "string" && id.length > 0,
1905
+ );
1906
+ if (!normalizedIds.length) {
1907
+ throw new Error("image-ids-required");
1908
+ }
1909
+
1910
+ const frameScene = this.getFrameRect();
1911
+ const frame = this.getFrameRectScreen(frameScene);
1912
+ const multiplier = Math.max(1, options.multiplier ?? 2);
1913
+ const format: "png" | "jpeg" = options.format === "jpeg" ? "jpeg" : "png";
1914
+
1915
+ const width = Math.max(1, Math.round(frame.width * multiplier));
1916
+ const height = Math.max(1, Math.round(frame.height * multiplier));
1917
+
1918
+ const el = document.createElement("canvas");
1919
+ const tempCanvas = new FabricCanvas(el, {
1920
+ renderOnAddRemove: false,
1921
+ selection: false,
1922
+ enableRetinaScaling: false,
1923
+ preserveObjectStacking: true,
1924
+ } as any);
1925
+ tempCanvas.setDimensions({ width, height });
1926
+
1927
+ try {
1928
+ const idSet = new Set(normalizedIds);
1929
+ const sourceObjects = this.canvasService.canvas
1930
+ .getObjects()
1931
+ .filter((obj: any) => {
1932
+ return (
1933
+ obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID &&
1934
+ typeof obj?.data?.id === "string" &&
1935
+ idSet.has(obj.data.id)
1936
+ );
1937
+ });
1938
+
1939
+ if (!sourceObjects.length) {
1940
+ throw new Error("image-objects-not-found");
1941
+ }
1942
+
1943
+ for (const source of sourceObjects as any[]) {
1944
+ const clone = await source.clone();
1945
+ const center = source.getCenterPoint
1946
+ ? source.getCenterPoint()
1947
+ : new Point(source.left ?? 0, source.top ?? 0);
1948
+
1949
+ clone.set({
1950
+ originX: "center",
1951
+ originY: "center",
1952
+ left: (center.x - frame.left) * multiplier,
1953
+ top: (center.y - frame.top) * multiplier,
1954
+ scaleX: (source.scaleX || 1) * multiplier,
1955
+ scaleY: (source.scaleY || 1) * multiplier,
1956
+ angle: source.angle || 0,
1957
+ selectable: false,
1958
+ evented: false,
1959
+ });
1960
+ clone.setCoords();
1961
+ tempCanvas.add(clone);
1962
+ }
1963
+
1964
+ tempCanvas.renderAll();
1965
+ const blob = await tempCanvas.toBlob({ format, multiplier: 1 });
1966
+ if (!blob) {
1967
+ throw new Error("image-export-failed");
1968
+ }
1969
+
1970
+ return {
1971
+ url: URL.createObjectURL(blob),
1972
+ width,
1973
+ height,
1974
+ multiplier,
1975
+ format,
1976
+ imageIds: (sourceObjects as any[])
1977
+ .map((obj: any) => obj?.data?.id)
1978
+ .filter((id: any): id is string => typeof id === "string"),
1979
+ };
1980
+ } finally {
1981
+ tempCanvas.dispose();
1982
+ }
1983
+ }
1984
+
1985
+ private async exportUserCroppedImage(
1986
+ options: ExportUserCroppedImageOptions = {},
1987
+ ): Promise<ExportUserCroppedImageResult> {
1988
+ if (!this.canvasService) {
1989
+ throw new Error("CanvasService not initialized");
1990
+ }
1991
+
1992
+ await this.updateImagesAsync();
1993
+ this.syncToolActiveFromWorkbench();
1994
+
1995
+ const imageIds =
1996
+ options.imageIds && options.imageIds.length > 0
1997
+ ? options.imageIds
1998
+ : (this.isToolActive ? this.workingItems : this.items).map(
1999
+ (item) => item.id,
2000
+ );
2001
+ if (!imageIds.length) {
2002
+ throw new Error("no-images-to-export");
2003
+ }
2004
+
2005
+ return await this.exportCroppedImageByIds(imageIds, options);
2006
+ }
2007
+ }