@pooder/kit 4.3.0 → 5.0.0

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