@pooder/kit 6.0.1 → 6.1.1

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 (91) hide show
  1. package/.test-dist/src/extensions/background/BackgroundTool.js +524 -0
  2. package/.test-dist/src/extensions/background/index.js +17 -0
  3. package/.test-dist/src/extensions/dieline/DielineTool.js +748 -0
  4. package/.test-dist/src/extensions/dieline/commands.js +127 -0
  5. package/.test-dist/src/extensions/dieline/config.js +107 -0
  6. package/.test-dist/src/extensions/dieline/index.js +21 -0
  7. package/.test-dist/src/extensions/dieline/model.js +2 -0
  8. package/.test-dist/src/extensions/dieline/renderer.js +2 -0
  9. package/.test-dist/src/extensions/feature/FeatureTool.js +914 -0
  10. package/.test-dist/src/extensions/feature/index.js +17 -0
  11. package/.test-dist/src/extensions/film/FilmTool.js +207 -0
  12. package/.test-dist/src/extensions/film/index.js +17 -0
  13. package/.test-dist/src/extensions/image/ImageTool.js +1499 -0
  14. package/.test-dist/src/extensions/image/commands.js +162 -0
  15. package/.test-dist/src/extensions/image/config.js +129 -0
  16. package/.test-dist/src/extensions/image/index.js +21 -0
  17. package/.test-dist/src/extensions/image/model.js +2 -0
  18. package/.test-dist/src/extensions/image/renderer.js +5 -0
  19. package/.test-dist/src/extensions/mirror/MirrorTool.js +104 -0
  20. package/.test-dist/src/extensions/mirror/index.js +17 -0
  21. package/.test-dist/src/extensions/ruler/RulerTool.js +442 -0
  22. package/.test-dist/src/extensions/ruler/index.js +17 -0
  23. package/.test-dist/src/extensions/sceneLayout.js +2 -93
  24. package/.test-dist/src/extensions/sceneLayoutModel.js +15 -200
  25. package/.test-dist/src/extensions/size/SizeTool.js +332 -0
  26. package/.test-dist/src/extensions/size/index.js +17 -0
  27. package/.test-dist/src/extensions/white-ink/WhiteInkTool.js +1003 -0
  28. package/.test-dist/src/extensions/white-ink/commands.js +148 -0
  29. package/.test-dist/src/extensions/white-ink/config.js +31 -0
  30. package/.test-dist/src/extensions/white-ink/index.js +21 -0
  31. package/.test-dist/src/extensions/white-ink/model.js +2 -0
  32. package/.test-dist/src/extensions/white-ink/renderer.js +5 -0
  33. package/.test-dist/src/services/SceneLayoutService.js +96 -0
  34. package/.test-dist/src/services/index.js +1 -0
  35. package/.test-dist/src/shared/constants/layers.js +25 -0
  36. package/.test-dist/src/shared/imaging/sourceSizeCache.js +82 -0
  37. package/.test-dist/src/shared/index.js +22 -0
  38. package/.test-dist/src/shared/runtime/sessionState.js +74 -0
  39. package/.test-dist/src/shared/runtime/subscriptions.js +30 -0
  40. package/.test-dist/src/shared/scene/frame.js +34 -0
  41. package/.test-dist/src/shared/scene/sceneLayoutModel.js +202 -0
  42. package/.test-dist/tests/run.js +116 -0
  43. package/CHANGELOG.md +14 -0
  44. package/dist/index.d.mts +390 -367
  45. package/dist/index.d.ts +390 -367
  46. package/dist/index.js +5138 -4927
  47. package/dist/index.mjs +1149 -1977
  48. package/dist/tracer-PO7CRBYY.mjs +1016 -0
  49. package/package.json +2 -2
  50. package/src/extensions/{background.ts → background/BackgroundTool.ts} +33 -50
  51. package/src/extensions/background/index.ts +1 -0
  52. package/src/extensions/{dieline.ts → dieline/DielineTool.ts} +14 -218
  53. package/src/extensions/dieline/commands.ts +109 -0
  54. package/src/extensions/dieline/config.ts +106 -0
  55. package/src/extensions/dieline/index.ts +5 -0
  56. package/src/extensions/dieline/model.ts +1 -0
  57. package/src/extensions/dieline/renderer.ts +1 -0
  58. package/src/extensions/{feature.ts → feature/FeatureTool.ts} +27 -21
  59. package/src/extensions/feature/index.ts +1 -0
  60. package/src/extensions/{film.ts → film/FilmTool.ts} +36 -48
  61. package/src/extensions/film/index.ts +1 -0
  62. package/src/extensions/{image.ts → image/ImageTool.ts} +123 -402
  63. package/src/extensions/image/commands.ts +176 -0
  64. package/src/extensions/image/config.ts +128 -0
  65. package/src/extensions/image/index.ts +5 -0
  66. package/src/extensions/image/model.ts +1 -0
  67. package/src/extensions/image/renderer.ts +1 -0
  68. package/src/extensions/{mirror.ts → mirror/MirrorTool.ts} +1 -1
  69. package/src/extensions/mirror/index.ts +1 -0
  70. package/src/extensions/{ruler.ts → ruler/RulerTool.ts} +4 -5
  71. package/src/extensions/ruler/index.ts +1 -0
  72. package/src/extensions/sceneLayout.ts +1 -140
  73. package/src/extensions/sceneLayoutModel.ts +1 -364
  74. package/src/extensions/{size.ts → size/SizeTool.ts} +7 -6
  75. package/src/extensions/size/index.ts +1 -0
  76. package/src/extensions/{white-ink.ts → white-ink/WhiteInkTool.ts} +130 -317
  77. package/src/extensions/white-ink/commands.ts +157 -0
  78. package/src/extensions/white-ink/config.ts +30 -0
  79. package/src/extensions/white-ink/index.ts +5 -0
  80. package/src/extensions/white-ink/model.ts +1 -0
  81. package/src/extensions/white-ink/renderer.ts +1 -0
  82. package/src/services/SceneLayoutService.ts +139 -0
  83. package/src/services/index.ts +1 -0
  84. package/src/shared/constants/layers.ts +23 -0
  85. package/src/shared/imaging/sourceSizeCache.ts +103 -0
  86. package/src/shared/index.ts +6 -0
  87. package/src/shared/runtime/sessionState.ts +105 -0
  88. package/src/shared/runtime/subscriptions.ts +45 -0
  89. package/src/shared/scene/frame.ts +46 -0
  90. package/src/shared/scene/sceneLayoutModel.ts +367 -0
  91. package/tests/run.ts +146 -0
@@ -0,0 +1,1499 @@
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 dielineShape_1 = require("../dielineShape");
7
+ const geometry_1 = require("../geometry");
8
+ const sceneLayoutModel_1 = require("../../shared/scene/sceneLayoutModel");
9
+ const frame_1 = require("../../shared/scene/frame");
10
+ const sourceSizeCache_1 = require("../../shared/imaging/sourceSizeCache");
11
+ const subscriptions_1 = require("../../shared/runtime/subscriptions");
12
+ const sessionState_1 = require("../../shared/runtime/sessionState");
13
+ const layers_1 = require("../../shared/constants/layers");
14
+ const commands_1 = require("./commands");
15
+ const config_1 = require("./config");
16
+ const IMAGE_DEFAULT_CONTROL_CAPABILITIES = [
17
+ "rotate",
18
+ "scale",
19
+ ];
20
+ const IMAGE_CONTROL_DESCRIPTORS = [
21
+ {
22
+ key: "tl",
23
+ capability: "rotate",
24
+ create: () => new fabric_1.Control({
25
+ x: -0.5,
26
+ y: -0.5,
27
+ actionName: "rotate",
28
+ actionHandler: fabric_1.controlsUtils.rotationWithSnapping,
29
+ cursorStyleHandler: fabric_1.controlsUtils.rotationStyleHandler,
30
+ }),
31
+ },
32
+ {
33
+ key: "br",
34
+ capability: "scale",
35
+ create: () => new fabric_1.Control({
36
+ x: 0.5,
37
+ y: 0.5,
38
+ actionName: "scale",
39
+ actionHandler: fabric_1.controlsUtils.scalingEqually,
40
+ cursorStyleHandler: fabric_1.controlsUtils.scaleCursorStyleHandler,
41
+ }),
42
+ },
43
+ ];
44
+ class ImageTool {
45
+ constructor() {
46
+ this.id = "pooder.kit.image";
47
+ this.metadata = {
48
+ name: "ImageTool",
49
+ };
50
+ this.items = [];
51
+ this.workingItems = [];
52
+ this.hasWorkingChanges = false;
53
+ this.loadResolvers = new Map();
54
+ this.sourceSizeCache = (0, sourceSizeCache_1.createSourceSizeCache)((src) => this.loadImageSize(src));
55
+ this.isUpdatingConfig = false;
56
+ this.isToolActive = false;
57
+ this.isImageSelectionActive = false;
58
+ this.focusedImageId = null;
59
+ this.renderSeq = 0;
60
+ this.imageSpecs = [];
61
+ this.overlaySpecs = [];
62
+ this.subscriptions = new subscriptions_1.SubscriptionBag();
63
+ this.imageControlsByCapabilityKey = new Map();
64
+ this.onToolActivated = (event) => {
65
+ const before = this.isToolActive;
66
+ this.syncToolActiveFromWorkbench(event.id);
67
+ if (!this.isToolActive) {
68
+ this.setImageFocus(null, {
69
+ syncCanvasSelection: true,
70
+ skipRender: true,
71
+ });
72
+ }
73
+ this.debug("tool:activated", {
74
+ id: event.id,
75
+ previous: event.previous,
76
+ reason: event.reason,
77
+ before,
78
+ isToolActive: this.isToolActive,
79
+ focusedImageId: this.focusedImageId,
80
+ });
81
+ if (!this.isToolActive && this.isDebugEnabled()) {
82
+ console.trace("[ImageTool] tool deactivated trace");
83
+ }
84
+ this.updateImages();
85
+ };
86
+ this.onSelectionChanged = (e) => {
87
+ const list = [];
88
+ if (Array.isArray(e?.selected)) {
89
+ list.push(...e.selected);
90
+ }
91
+ if (Array.isArray(e?.target?._objects)) {
92
+ list.push(...e.target._objects);
93
+ }
94
+ if (e?.target && !Array.isArray(e?.target?._objects)) {
95
+ list.push(e.target);
96
+ }
97
+ const selectedImage = list.find((obj) => obj?.data?.layerId === layers_1.IMAGE_OBJECT_LAYER_ID);
98
+ this.isImageSelectionActive = !!selectedImage;
99
+ if (selectedImage?.data?.id) {
100
+ this.focusedImageId = selectedImage.data.id;
101
+ }
102
+ else if (list.length > 0) {
103
+ this.focusedImageId = null;
104
+ }
105
+ this.debug("selection:changed", {
106
+ listSize: list.length,
107
+ isImageSelectionActive: this.isImageSelectionActive,
108
+ focusedImageId: this.focusedImageId,
109
+ });
110
+ this.updateImages();
111
+ };
112
+ this.onSelectionCleared = () => {
113
+ this.setImageFocus(null, {
114
+ syncCanvasSelection: false,
115
+ skipRender: true,
116
+ });
117
+ this.debug("selection:cleared applied");
118
+ this.updateImages();
119
+ };
120
+ this.onSceneLayoutChanged = () => {
121
+ this.updateImages();
122
+ };
123
+ this.onSceneGeometryChanged = () => {
124
+ this.updateImages();
125
+ };
126
+ this.onObjectModified = (e) => {
127
+ if (!this.isToolActive)
128
+ return;
129
+ const target = e?.target;
130
+ const id = target?.data?.id;
131
+ const layerId = target?.data?.layerId;
132
+ if (typeof id !== "string" || layerId !== layers_1.IMAGE_OBJECT_LAYER_ID)
133
+ return;
134
+ const frame = this.getFrameRect();
135
+ if (!frame.width || !frame.height)
136
+ return;
137
+ const center = target.getCenterPoint
138
+ ? target.getCenterPoint()
139
+ : new fabric_1.Point(target.left ?? 0, target.top ?? 0);
140
+ const centerScene = this.canvasService
141
+ ? this.canvasService.toScenePoint({ x: center.x, y: center.y })
142
+ : { x: center.x, y: center.y };
143
+ const objectScale = Number.isFinite(target?.scaleX) ? target.scaleX : 1;
144
+ const objectScaleScene = this.toSceneObjectScale(objectScale || 1);
145
+ const workingItem = this.workingItems.find((item) => item.id === id);
146
+ const sourceKey = workingItem?.sourceUrl || workingItem?.url || "";
147
+ const sourceSize = this.getSourceSize(sourceKey, target);
148
+ const coverScale = this.getCoverScale(frame, sourceSize);
149
+ const updates = {
150
+ left: this.clampNormalized((centerScene.x - frame.left) / frame.width),
151
+ top: this.clampNormalized((centerScene.y - frame.top) / frame.height),
152
+ angle: Number.isFinite(target.angle) ? target.angle : 0,
153
+ scale: Math.max(0.05, objectScaleScene / coverScale),
154
+ };
155
+ this.focusedImageId = id;
156
+ this.updateImageInWorking(id, updates);
157
+ };
158
+ }
159
+ activate(context) {
160
+ this.subscriptions.disposeAll();
161
+ this.context = context;
162
+ this.canvasService = context.services.get("CanvasService");
163
+ if (!this.canvasService) {
164
+ console.warn("CanvasService not found for ImageTool");
165
+ return;
166
+ }
167
+ this.renderProducerDisposable?.dispose();
168
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(this.id, () => ({
169
+ passes: [
170
+ {
171
+ id: layers_1.IMAGE_OBJECT_LAYER_ID,
172
+ stack: 500,
173
+ order: 0,
174
+ visibility: {
175
+ op: "not",
176
+ expr: {
177
+ op: "sessionActive",
178
+ toolId: "pooder.kit.white-ink",
179
+ },
180
+ },
181
+ objects: this.imageSpecs,
182
+ },
183
+ {
184
+ id: layers_1.IMAGE_OVERLAY_LAYER_ID,
185
+ stack: 800,
186
+ order: 0,
187
+ visibility: {
188
+ op: "not",
189
+ expr: {
190
+ op: "sessionActive",
191
+ toolId: "pooder.kit.white-ink",
192
+ },
193
+ },
194
+ objects: this.overlaySpecs,
195
+ },
196
+ ],
197
+ }), { priority: 300 });
198
+ this.subscriptions.on(context.eventBus, "tool:activated", this.onToolActivated);
199
+ this.subscriptions.on(context.eventBus, "object:modified", this.onObjectModified);
200
+ this.subscriptions.on(context.eventBus, "selection:created", this.onSelectionChanged);
201
+ this.subscriptions.on(context.eventBus, "selection:updated", this.onSelectionChanged);
202
+ this.subscriptions.on(context.eventBus, "selection:cleared", this.onSelectionCleared);
203
+ this.subscriptions.on(context.eventBus, "scene:layout:change", this.onSceneLayoutChanged);
204
+ this.subscriptions.on(context.eventBus, "scene:geometry:change", this.onSceneGeometryChanged);
205
+ const configService = context.services.get("ConfigurationService");
206
+ if (configService) {
207
+ this.applyCommittedItems(configService.get("image.items", []) || []);
208
+ this.subscriptions.onConfigChange(configService, (e) => {
209
+ if (this.isUpdatingConfig)
210
+ return;
211
+ if (e.key === "image.items") {
212
+ this.applyCommittedItems(e.value || []);
213
+ this.updateImages();
214
+ return;
215
+ }
216
+ if (e.key.startsWith("size.") ||
217
+ e.key.startsWith("image.frame.") ||
218
+ e.key.startsWith("image.control.")) {
219
+ if (e.key.startsWith("image.control.")) {
220
+ this.imageControlsByCapabilityKey.clear();
221
+ }
222
+ this.updateImages();
223
+ }
224
+ });
225
+ }
226
+ const toolSessionService = context.services.get("ToolSessionService");
227
+ this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(this.id, () => this.hasWorkingChanges);
228
+ this.updateImages();
229
+ }
230
+ deactivate(context) {
231
+ this.subscriptions.disposeAll();
232
+ this.dirtyTrackerDisposable?.dispose();
233
+ this.dirtyTrackerDisposable = undefined;
234
+ this.cropShapeHatchPattern = undefined;
235
+ this.cropShapeHatchPatternColor = undefined;
236
+ this.cropShapeHatchPatternKey = undefined;
237
+ this.sourceSizeCache.clear();
238
+ this.imageSpecs = [];
239
+ this.overlaySpecs = [];
240
+ this.imageControlsByCapabilityKey.clear();
241
+ this.clearRenderedImages();
242
+ this.renderProducerDisposable?.dispose();
243
+ this.renderProducerDisposable = undefined;
244
+ if (this.canvasService) {
245
+ void this.canvasService.flushRenderFromProducers();
246
+ this.canvasService = undefined;
247
+ }
248
+ this.context = undefined;
249
+ }
250
+ syncToolActiveFromWorkbench(fallbackId) {
251
+ const wb = this.context?.services.get("WorkbenchService");
252
+ const activeId = wb?.activeToolId;
253
+ if (typeof activeId === "string" || activeId === null) {
254
+ this.isToolActive = activeId === this.id;
255
+ return;
256
+ }
257
+ this.isToolActive = fallbackId === this.id;
258
+ }
259
+ isImageEditingVisible() {
260
+ return (this.isToolActive || this.isImageSelectionActive || !!this.focusedImageId);
261
+ }
262
+ getEnabledImageControlCapabilities() {
263
+ return IMAGE_DEFAULT_CONTROL_CAPABILITIES;
264
+ }
265
+ getImageControls(capabilities) {
266
+ const normalized = [...new Set(capabilities)].sort();
267
+ const cacheKey = normalized.join("|");
268
+ const cached = this.imageControlsByCapabilityKey.get(cacheKey);
269
+ if (cached) {
270
+ return cached;
271
+ }
272
+ const enabled = new Set(normalized);
273
+ const controls = {};
274
+ IMAGE_CONTROL_DESCRIPTORS.forEach((descriptor) => {
275
+ if (!enabled.has(descriptor.capability))
276
+ return;
277
+ controls[descriptor.key] = descriptor.create();
278
+ });
279
+ this.imageControlsByCapabilityKey.set(cacheKey, controls);
280
+ return controls;
281
+ }
282
+ getImageControlVisualConfig() {
283
+ const cornerSizeRaw = Number(this.getConfig("image.control.cornerSize", 14) ?? 14);
284
+ const touchCornerSizeRaw = Number(this.getConfig("image.control.touchCornerSize", 24) ?? 24);
285
+ const borderScaleFactorRaw = Number(this.getConfig("image.control.borderScaleFactor", 1.5) ?? 1.5);
286
+ const paddingRaw = Number(this.getConfig("image.control.padding", 0) ?? 0);
287
+ const cornerStyleRaw = (this.getConfig("image.control.cornerStyle", "circle") || "circle");
288
+ const cornerStyle = cornerStyleRaw === "rect" ? "rect" : "circle";
289
+ return {
290
+ cornerSize: Number.isFinite(cornerSizeRaw)
291
+ ? Math.max(4, Math.min(64, cornerSizeRaw))
292
+ : 14,
293
+ touchCornerSize: Number.isFinite(touchCornerSizeRaw)
294
+ ? Math.max(8, Math.min(96, touchCornerSizeRaw))
295
+ : 24,
296
+ cornerStyle,
297
+ cornerColor: this.getConfig("image.control.cornerColor", "#ffffff") ||
298
+ "#ffffff",
299
+ cornerStrokeColor: this.getConfig("image.control.cornerStrokeColor", "#1677ff") ||
300
+ "#1677ff",
301
+ transparentCorners: !!this.getConfig("image.control.transparentCorners", false),
302
+ borderColor: this.getConfig("image.control.borderColor", "#1677ff") ||
303
+ "#1677ff",
304
+ borderScaleFactor: Number.isFinite(borderScaleFactorRaw)
305
+ ? Math.max(0.5, Math.min(8, borderScaleFactorRaw))
306
+ : 1.5,
307
+ padding: Number.isFinite(paddingRaw)
308
+ ? Math.max(0, Math.min(64, paddingRaw))
309
+ : 0,
310
+ };
311
+ }
312
+ applyImageObjectInteractionState(obj) {
313
+ if (!obj)
314
+ return;
315
+ const visible = this.isImageEditingVisible();
316
+ const visual = this.getImageControlVisualConfig();
317
+ obj.set({
318
+ selectable: visible,
319
+ evented: visible,
320
+ hasControls: visible,
321
+ hasBorders: visible,
322
+ lockScalingFlip: true,
323
+ cornerSize: visual.cornerSize,
324
+ touchCornerSize: visual.touchCornerSize,
325
+ cornerStyle: visual.cornerStyle,
326
+ cornerColor: visual.cornerColor,
327
+ cornerStrokeColor: visual.cornerStrokeColor,
328
+ transparentCorners: visual.transparentCorners,
329
+ borderColor: visual.borderColor,
330
+ borderScaleFactor: visual.borderScaleFactor,
331
+ padding: visual.padding,
332
+ });
333
+ obj.controls = this.getImageControls(this.getEnabledImageControlCapabilities());
334
+ obj.setCoords?.();
335
+ }
336
+ refreshImageObjectInteractionState() {
337
+ this.getImageObjects().forEach((obj) => this.applyImageObjectInteractionState(obj));
338
+ }
339
+ isDebugEnabled() {
340
+ return !!this.getConfig("image.debug", false);
341
+ }
342
+ debug(message, payload) {
343
+ if (!this.isDebugEnabled())
344
+ return;
345
+ if (payload === undefined) {
346
+ console.log(`[ImageTool] ${message}`);
347
+ return;
348
+ }
349
+ console.log(`[ImageTool] ${message}`, payload);
350
+ }
351
+ contribute() {
352
+ return {
353
+ [core_1.ContributionPointIds.TOOLS]: [
354
+ {
355
+ id: this.id,
356
+ name: "Image",
357
+ interaction: "session",
358
+ commands: {
359
+ begin: "resetWorkingImages",
360
+ commit: "completeImages",
361
+ rollback: "resetWorkingImages",
362
+ },
363
+ session: {
364
+ autoBegin: true,
365
+ leavePolicy: "block",
366
+ },
367
+ },
368
+ ],
369
+ [core_1.ContributionPointIds.CONFIGURATIONS]: (0, config_1.createImageConfigurations)(),
370
+ [core_1.ContributionPointIds.COMMANDS]: (0, commands_1.createImageCommands)(this),
371
+ };
372
+ }
373
+ normalizeItem(item) {
374
+ const url = typeof item.url === "string" ? item.url : "";
375
+ const sourceUrl = typeof item.sourceUrl === "string" && item.sourceUrl.length > 0
376
+ ? item.sourceUrl
377
+ : url;
378
+ const committedUrl = typeof item.committedUrl === "string" && item.committedUrl.length > 0
379
+ ? item.committedUrl
380
+ : undefined;
381
+ return {
382
+ ...item,
383
+ url: url || sourceUrl,
384
+ sourceUrl,
385
+ committedUrl,
386
+ opacity: Number.isFinite(item.opacity) ? item.opacity : 1,
387
+ scale: Number.isFinite(item.scale) ? item.scale : 1,
388
+ angle: Number.isFinite(item.angle) ? item.angle : 0,
389
+ left: Number.isFinite(item.left) ? item.left : 0.5,
390
+ top: Number.isFinite(item.top) ? item.top : 0.5,
391
+ };
392
+ }
393
+ normalizeItems(items) {
394
+ return (items || []).map((item) => this.normalizeItem(item));
395
+ }
396
+ cloneItems(items) {
397
+ return this.normalizeItems((items || []).map((i) => ({ ...i })));
398
+ }
399
+ emitWorkingChange(changedId = null) {
400
+ this.context?.eventBus.emit("image:working:change", {
401
+ changedId,
402
+ items: this.cloneItems(this.workingItems),
403
+ });
404
+ }
405
+ generateId() {
406
+ return Math.random().toString(36).substring(2, 9);
407
+ }
408
+ hasImageItem(id) {
409
+ return (this.items.some((item) => item.id === id) ||
410
+ this.workingItems.some((item) => item.id === id));
411
+ }
412
+ setImageFocus(id, options = {}) {
413
+ const syncCanvasSelection = options.syncCanvasSelection !== false;
414
+ if (id && !this.hasImageItem(id)) {
415
+ return { ok: false, reason: "image-not-found" };
416
+ }
417
+ this.focusedImageId = id;
418
+ this.isImageSelectionActive = !!id;
419
+ if (syncCanvasSelection && this.canvasService) {
420
+ const canvas = this.canvasService.canvas;
421
+ if (!id) {
422
+ canvas.discardActiveObject();
423
+ }
424
+ else {
425
+ const obj = this.getImageObject(id);
426
+ if (obj) {
427
+ this.applyImageObjectInteractionState(obj);
428
+ canvas.setActiveObject(obj);
429
+ }
430
+ }
431
+ this.canvasService.requestRenderAll();
432
+ }
433
+ if (!options.skipRender) {
434
+ this.updateImages();
435
+ }
436
+ return { ok: true, id };
437
+ }
438
+ async addImageEntry(url, options, fitOnAdd = true) {
439
+ const id = this.generateId();
440
+ const newItem = this.normalizeItem({
441
+ id,
442
+ url,
443
+ opacity: 1,
444
+ ...options,
445
+ });
446
+ const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
447
+ const waitLoaded = this.waitImageLoaded(id, true);
448
+ this.updateConfig([...this.items, newItem]);
449
+ this.addItemToWorkingSessionIfNeeded(newItem, sessionDirtyBeforeAdd);
450
+ const loaded = await waitLoaded;
451
+ if (loaded && fitOnAdd) {
452
+ await this.fitImageToDefaultArea(id);
453
+ }
454
+ if (loaded) {
455
+ this.setImageFocus(id);
456
+ }
457
+ return id;
458
+ }
459
+ async upsertImageEntry(url, options = {}) {
460
+ const mode = options.mode || (options.id ? "replace" : "add");
461
+ const fitOnAdd = options.fitOnAdd !== false;
462
+ if (mode === "replace") {
463
+ if (!options.id) {
464
+ throw new Error("replace-target-id-required");
465
+ }
466
+ const targetId = options.id;
467
+ if (!this.hasImageItem(targetId)) {
468
+ throw new Error("replace-target-not-found");
469
+ }
470
+ await this.updateImageInConfig(targetId, { url });
471
+ return { id: targetId, mode: "replace" };
472
+ }
473
+ const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
474
+ return { id, mode: "add" };
475
+ }
476
+ addItemToWorkingSessionIfNeeded(item, sessionDirtyBeforeAdd) {
477
+ if (!sessionDirtyBeforeAdd || !this.isToolActive)
478
+ return;
479
+ if (this.workingItems.some((existing) => existing.id === item.id))
480
+ return;
481
+ this.workingItems = this.cloneItems([...this.workingItems, item]);
482
+ this.updateImages();
483
+ this.emitWorkingChange(item.id);
484
+ }
485
+ async updateImage(id, updates, options = {}) {
486
+ this.syncToolActiveFromWorkbench();
487
+ const target = options.target || "auto";
488
+ if (target === "working" || (target === "auto" && this.isToolActive)) {
489
+ this.updateImageInWorking(id, updates);
490
+ return;
491
+ }
492
+ await this.updateImageInConfig(id, updates);
493
+ }
494
+ getConfig(key, fallback) {
495
+ if (!this.context)
496
+ return fallback;
497
+ const configService = this.context.services.get("ConfigurationService");
498
+ if (!configService)
499
+ return fallback;
500
+ return configService.get(key, fallback) ?? fallback;
501
+ }
502
+ applyCommittedItems(nextItems) {
503
+ const session = {
504
+ committed: this.items,
505
+ working: this.workingItems,
506
+ hasWorkingChanges: this.hasWorkingChanges,
507
+ };
508
+ (0, sessionState_1.applyCommittedSnapshot)(session, this.normalizeItems(nextItems), {
509
+ clone: (items) => this.cloneItems(items),
510
+ toolActive: this.isToolActive,
511
+ preserveDirtyWorking: true,
512
+ });
513
+ this.items = session.committed;
514
+ this.workingItems = session.working;
515
+ this.hasWorkingChanges = session.hasWorkingChanges;
516
+ }
517
+ updateConfig(newItems, skipCanvasUpdate = false) {
518
+ if (!this.context)
519
+ return;
520
+ this.applyCommittedItems(newItems);
521
+ (0, sessionState_1.runDeferredConfigUpdate)(this, () => {
522
+ const configService = this.context?.services.get("ConfigurationService");
523
+ configService?.update("image.items", this.items);
524
+ if (!skipCanvasUpdate) {
525
+ this.updateImages();
526
+ }
527
+ }, 50);
528
+ }
529
+ getFrameRect() {
530
+ const configService = this.context?.services.get("ConfigurationService");
531
+ return (0, frame_1.resolveCutFrameRect)(this.canvasService, configService);
532
+ }
533
+ getFrameRectScreen(frame) {
534
+ if (!this.canvasService) {
535
+ return { left: 0, top: 0, width: 0, height: 0 };
536
+ }
537
+ return this.canvasService.toScreenRect(frame || this.getFrameRect());
538
+ }
539
+ toLayoutSceneRect(rect) {
540
+ return (0, frame_1.toLayoutSceneRect)(rect);
541
+ }
542
+ async resolveDefaultFitArea() {
543
+ if (!this.canvasService)
544
+ return null;
545
+ const frame = this.getFrameRect();
546
+ if (frame.width <= 0 || frame.height <= 0)
547
+ return null;
548
+ return {
549
+ width: Math.max(1, frame.width),
550
+ height: Math.max(1, frame.height),
551
+ left: frame.left + frame.width / 2,
552
+ top: frame.top + frame.height / 2,
553
+ };
554
+ }
555
+ async fitImageToDefaultArea(id) {
556
+ if (!this.canvasService)
557
+ return;
558
+ const area = await this.resolveDefaultFitArea();
559
+ if (area) {
560
+ await this.fitImageToArea(id, area);
561
+ return;
562
+ }
563
+ const viewport = this.canvasService.getSceneViewportRect();
564
+ const canvasW = Math.max(1, viewport.width || 0);
565
+ const canvasH = Math.max(1, viewport.height || 0);
566
+ await this.fitImageToArea(id, {
567
+ width: canvasW,
568
+ height: canvasH,
569
+ left: viewport.left + canvasW / 2,
570
+ top: viewport.top + canvasH / 2,
571
+ });
572
+ }
573
+ getImageObjects() {
574
+ if (!this.canvasService)
575
+ return [];
576
+ return this.canvasService.canvas.getObjects().filter((obj) => {
577
+ return obj?.data?.layerId === layers_1.IMAGE_OBJECT_LAYER_ID;
578
+ });
579
+ }
580
+ getOverlayObjects() {
581
+ if (!this.canvasService)
582
+ return [];
583
+ return this.canvasService.getPassObjects(layers_1.IMAGE_OVERLAY_LAYER_ID);
584
+ }
585
+ getImageObject(id) {
586
+ return this.getImageObjects().find((obj) => obj?.data?.id === id);
587
+ }
588
+ clearRenderedImages() {
589
+ if (!this.canvasService)
590
+ return;
591
+ this.imageSpecs = [];
592
+ this.overlaySpecs = [];
593
+ this.canvasService.requestRenderFromProducers();
594
+ }
595
+ purgeSourceSizeCacheForItem(item) {
596
+ if (!item)
597
+ return;
598
+ const sources = [item.url, item.sourceUrl, item.committedUrl].filter((value) => typeof value === "string" && value.length > 0);
599
+ sources.forEach((src) => this.sourceSizeCache.deleteSourceSize(src));
600
+ }
601
+ rememberSourceSize(src, obj) {
602
+ const width = Number(obj?.width || 0);
603
+ const height = Number(obj?.height || 0);
604
+ if (src && width > 0 && height > 0) {
605
+ this.sourceSizeCache.rememberSourceSize(src, { width, height });
606
+ }
607
+ }
608
+ getSourceSize(src, obj) {
609
+ const cached = src ? this.sourceSizeCache.getSourceSize(src) : undefined;
610
+ if (cached)
611
+ return cached;
612
+ const width = Number(obj?.width || 0);
613
+ const height = Number(obj?.height || 0);
614
+ if (src && width > 0 && height > 0) {
615
+ const size = { width, height };
616
+ this.sourceSizeCache.rememberSourceSize(src, size);
617
+ return size;
618
+ }
619
+ return { width: 1, height: 1 };
620
+ }
621
+ async ensureSourceSize(src) {
622
+ return this.sourceSizeCache.ensureImageSize(src);
623
+ }
624
+ async loadImageSize(src) {
625
+ try {
626
+ const image = await fabric_1.Image.fromURL(src, {
627
+ crossOrigin: "anonymous",
628
+ });
629
+ const width = Number(image?.width || 0);
630
+ const height = Number(image?.height || 0);
631
+ if (width > 0 && height > 0) {
632
+ return { width, height };
633
+ }
634
+ }
635
+ catch (error) {
636
+ this.debug("image:size:load-failed", {
637
+ src,
638
+ error: error instanceof Error ? error.message : String(error),
639
+ });
640
+ }
641
+ return null;
642
+ }
643
+ getCoverScale(frame, size) {
644
+ return (0, sourceSizeCache_1.getCoverScale)(frame, size);
645
+ }
646
+ getFrameVisualConfig() {
647
+ const strokeStyleRaw = (this.getConfig("image.frame.strokeStyle", "dashed") || "dashed");
648
+ const strokeStyle = strokeStyleRaw === "dashed" || strokeStyleRaw === "hidden"
649
+ ? strokeStyleRaw
650
+ : "dashed";
651
+ const strokeWidth = Number(this.getConfig("image.frame.strokeWidth", 2) ?? 2);
652
+ const dashLength = Number(this.getConfig("image.frame.dashLength", 8) ?? 8);
653
+ return {
654
+ strokeColor: this.getConfig("image.frame.strokeColor", "#808080") ||
655
+ "#808080",
656
+ strokeWidth: Number.isFinite(strokeWidth) ? Math.max(0, strokeWidth) : 2,
657
+ strokeStyle,
658
+ dashLength: Number.isFinite(dashLength) ? Math.max(1, dashLength) : 8,
659
+ innerBackground: this.getConfig("image.frame.innerBackground", "rgba(0,0,0,0)") || "rgba(0,0,0,0)",
660
+ outerBackground: this.getConfig("image.frame.outerBackground", "#f5f5f5") ||
661
+ "#f5f5f5",
662
+ };
663
+ }
664
+ toSceneGeometryLike(raw) {
665
+ const shape = raw?.shape;
666
+ if (!(0, dielineShape_1.isDielineShape)(shape)) {
667
+ return null;
668
+ }
669
+ const radiusRaw = Number(raw?.radius);
670
+ const offsetRaw = Number(raw?.offset);
671
+ const unit = typeof raw?.unit === "string" ? raw.unit : "px";
672
+ const radius = unit === "scene" || !this.canvasService
673
+ ? radiusRaw
674
+ : this.canvasService.toSceneLength(radiusRaw);
675
+ const offset = unit === "scene" || !this.canvasService
676
+ ? offsetRaw
677
+ : this.canvasService.toSceneLength(offsetRaw);
678
+ return {
679
+ shape,
680
+ shapeStyle: (0, dielineShape_1.normalizeShapeStyle)(raw?.shapeStyle),
681
+ radius: Number.isFinite(radius) ? radius : 0,
682
+ offset: Number.isFinite(offset) ? offset : 0,
683
+ };
684
+ }
685
+ async resolveSceneGeometryForOverlay() {
686
+ if (!this.context)
687
+ return null;
688
+ const commandService = this.context.services.get("CommandService");
689
+ if (commandService) {
690
+ try {
691
+ const raw = await Promise.resolve(commandService.executeCommand("getSceneGeometry"));
692
+ const geometry = this.toSceneGeometryLike(raw);
693
+ if (geometry) {
694
+ this.debug("overlay:sceneGeometry:command", geometry);
695
+ return geometry;
696
+ }
697
+ this.debug("overlay:sceneGeometry:command:invalid", { raw });
698
+ }
699
+ catch (error) {
700
+ this.debug("overlay:sceneGeometry:command:error", {
701
+ error: error instanceof Error ? error.message : String(error),
702
+ });
703
+ }
704
+ }
705
+ if (!this.canvasService)
706
+ return null;
707
+ const configService = this.context.services.get("ConfigurationService");
708
+ if (!configService)
709
+ return null;
710
+ const sizeState = (0, sceneLayoutModel_1.readSizeState)(configService);
711
+ const layout = (0, sceneLayoutModel_1.computeSceneLayout)(this.canvasService, sizeState);
712
+ if (!layout) {
713
+ this.debug("overlay:sceneGeometry:fallback:missing-layout");
714
+ return null;
715
+ }
716
+ const geometry = this.toSceneGeometryLike((0, sceneLayoutModel_1.buildSceneGeometry)(configService, layout));
717
+ if (geometry) {
718
+ this.debug("overlay:sceneGeometry:fallback", geometry);
719
+ }
720
+ return geometry;
721
+ }
722
+ resolveCutShapeRadius(geometry, frame) {
723
+ const visualRadius = Number.isFinite(geometry.radius)
724
+ ? Math.max(0, geometry.radius)
725
+ : 0;
726
+ const visualOffset = Number.isFinite(geometry.offset) ? geometry.offset : 0;
727
+ const rawCutRadius = visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
728
+ const maxRadius = Math.max(0, Math.min(frame.width, frame.height) / 2);
729
+ return Math.max(0, Math.min(maxRadius, rawCutRadius));
730
+ }
731
+ getCropShapeHatchPattern(color = "rgba(255, 0, 0, 0.6)") {
732
+ if (typeof document === "undefined")
733
+ return undefined;
734
+ const sceneScale = this.canvasService?.getSceneScale() || 1;
735
+ const cacheKey = `${color}::${sceneScale.toFixed(6)}`;
736
+ if (this.cropShapeHatchPattern &&
737
+ this.cropShapeHatchPatternColor === color &&
738
+ this.cropShapeHatchPatternKey === cacheKey) {
739
+ return this.cropShapeHatchPattern;
740
+ }
741
+ const size = 16;
742
+ const patternCanvas = document.createElement("canvas");
743
+ patternCanvas.width = size;
744
+ patternCanvas.height = size;
745
+ const ctx = patternCanvas.getContext("2d");
746
+ if (!ctx)
747
+ return undefined;
748
+ ctx.clearRect(0, 0, size, size);
749
+ ctx.fillStyle = "rgba(255, 0, 0, 0.08)";
750
+ ctx.fillRect(0, 0, size, size);
751
+ ctx.strokeStyle = color;
752
+ ctx.lineWidth = 1.5;
753
+ ctx.beginPath();
754
+ ctx.moveTo(-size, size);
755
+ ctx.lineTo(size, -size);
756
+ ctx.moveTo(-size / 2, size + size / 2);
757
+ ctx.lineTo(size + size / 2, -size / 2);
758
+ ctx.moveTo(0, size);
759
+ ctx.lineTo(size, 0);
760
+ ctx.moveTo(size / 2, size + size / 2);
761
+ ctx.lineTo(size + size + size / 2, -size / 2);
762
+ ctx.stroke();
763
+ const pattern = new fabric_1.Pattern({
764
+ source: patternCanvas,
765
+ // @ts-ignore: Fabric Pattern accepts canvas source here.
766
+ repetition: "repeat",
767
+ });
768
+ // Scene specs are scaled to screen by CanvasService; keep hatch density in screen pixels.
769
+ pattern.patternTransform = [
770
+ 1 / sceneScale,
771
+ 0,
772
+ 0,
773
+ 1 / sceneScale,
774
+ 0,
775
+ 0,
776
+ ];
777
+ this.cropShapeHatchPattern = pattern;
778
+ this.cropShapeHatchPatternColor = color;
779
+ this.cropShapeHatchPatternKey = cacheKey;
780
+ return pattern;
781
+ }
782
+ buildCropShapeOverlaySpecs(frame, sceneGeometry) {
783
+ if (!sceneGeometry) {
784
+ this.debug("overlay:shape:skip", { reason: "scene-geometry-missing" });
785
+ return [];
786
+ }
787
+ if (sceneGeometry.shape === "custom") {
788
+ this.debug("overlay:shape:skip", { reason: "shape-custom" });
789
+ return [];
790
+ }
791
+ const shape = sceneGeometry.shape;
792
+ const shapeStyle = sceneGeometry.shapeStyle;
793
+ const inset = 0;
794
+ const shapeWidth = Math.max(1, frame.width);
795
+ const shapeHeight = Math.max(1, frame.height);
796
+ const radius = this.resolveCutShapeRadius(sceneGeometry, frame);
797
+ this.debug("overlay:shape:geometry", {
798
+ shape,
799
+ frameWidth: frame.width,
800
+ frameHeight: frame.height,
801
+ offset: sceneGeometry.offset,
802
+ shapeStyle,
803
+ inset,
804
+ shapeWidth,
805
+ shapeHeight,
806
+ baseRadius: sceneGeometry.radius,
807
+ radius,
808
+ });
809
+ const isSameAsFrame = Math.abs(shapeWidth - frame.width) <= 0.0001 &&
810
+ Math.abs(shapeHeight - frame.height) <= 0.0001;
811
+ if (shape === "rect" && radius <= 0.0001 && isSameAsFrame) {
812
+ this.debug("overlay:shape:skip", {
813
+ reason: "shape-rect-no-radius",
814
+ });
815
+ return [];
816
+ }
817
+ const baseOptions = {
818
+ shape,
819
+ width: shapeWidth,
820
+ height: shapeHeight,
821
+ radius,
822
+ x: frame.width / 2,
823
+ y: frame.height / 2,
824
+ features: [],
825
+ shapeStyle,
826
+ canvasWidth: frame.width,
827
+ canvasHeight: frame.height,
828
+ };
829
+ try {
830
+ const shapePathData = (0, geometry_1.generateDielinePath)(baseOptions);
831
+ const outerRectPathData = `M 0 0 L ${frame.width} 0 L ${frame.width} ${frame.height} L 0 ${frame.height} Z`;
832
+ const hatchPathData = `${outerRectPathData} ${shapePathData}`;
833
+ if (!shapePathData || !hatchPathData) {
834
+ this.debug("overlay:shape:skip", {
835
+ reason: "path-generation-empty",
836
+ shape,
837
+ radius,
838
+ });
839
+ return [];
840
+ }
841
+ const patternFill = this.getCropShapeHatchPattern();
842
+ const hatchFill = patternFill || "rgba(255, 0, 0, 0.22)";
843
+ const shapeBounds = (0, geometry_1.getPathBounds)(shapePathData);
844
+ const hatchBounds = (0, geometry_1.getPathBounds)(hatchPathData);
845
+ const frameRect = this.toLayoutSceneRect(frame);
846
+ const hatchPathLength = hatchPathData.length;
847
+ const shapePathLength = shapePathData.length;
848
+ const specs = [
849
+ {
850
+ id: "image.cropShapeHatch",
851
+ type: "path",
852
+ data: { id: "image.cropShapeHatch", zIndex: 5 },
853
+ layout: {
854
+ reference: "custom",
855
+ referenceRect: frameRect,
856
+ alignX: "start",
857
+ alignY: "start",
858
+ offsetX: hatchBounds.x,
859
+ offsetY: hatchBounds.y,
860
+ },
861
+ props: {
862
+ pathData: hatchPathData,
863
+ originX: "left",
864
+ originY: "top",
865
+ fill: hatchFill,
866
+ opacity: patternFill ? 1 : 0.8,
867
+ stroke: null,
868
+ fillRule: "evenodd",
869
+ selectable: false,
870
+ evented: false,
871
+ excludeFromExport: true,
872
+ objectCaching: false,
873
+ },
874
+ },
875
+ {
876
+ id: "image.cropShapePath",
877
+ type: "path",
878
+ data: { id: "image.cropShapePath", zIndex: 6 },
879
+ layout: {
880
+ reference: "custom",
881
+ referenceRect: frameRect,
882
+ alignX: "start",
883
+ alignY: "start",
884
+ offsetX: shapeBounds.x,
885
+ offsetY: shapeBounds.y,
886
+ },
887
+ props: {
888
+ pathData: shapePathData,
889
+ originX: "left",
890
+ originY: "top",
891
+ fill: "rgba(0,0,0,0)",
892
+ stroke: "rgba(255, 0, 0, 0.9)",
893
+ strokeWidth: this.canvasService?.toSceneLength(1) ?? 1,
894
+ selectable: false,
895
+ evented: false,
896
+ excludeFromExport: true,
897
+ objectCaching: false,
898
+ },
899
+ },
900
+ ];
901
+ this.debug("overlay:shape:built", {
902
+ shape,
903
+ radius,
904
+ inset,
905
+ shapeWidth,
906
+ shapeHeight,
907
+ fillRule: "evenodd",
908
+ shapePathLength,
909
+ hatchPathLength,
910
+ shapeBounds,
911
+ hatchBounds,
912
+ hatchFillType: hatchFill && typeof hatchFill === "object" ? "pattern" : "color",
913
+ ids: specs.map((spec) => spec.id),
914
+ });
915
+ return specs;
916
+ }
917
+ catch (error) {
918
+ this.debug("overlay:shape:error", {
919
+ shape,
920
+ radius,
921
+ error: error instanceof Error ? error.message : String(error),
922
+ });
923
+ return [];
924
+ }
925
+ }
926
+ resolveRenderImageState(item) {
927
+ const active = this.isToolActive;
928
+ const sourceUrl = item.sourceUrl || item.url;
929
+ const committedUrl = item.committedUrl;
930
+ if (!active && committedUrl) {
931
+ return {
932
+ src: committedUrl,
933
+ left: 0.5,
934
+ top: 0.5,
935
+ scale: 1,
936
+ angle: 0,
937
+ opacity: item.opacity,
938
+ };
939
+ }
940
+ return {
941
+ src: sourceUrl || item.url,
942
+ left: Number.isFinite(item.left) ? item.left : 0.5,
943
+ top: Number.isFinite(item.top) ? item.top : 0.5,
944
+ scale: Math.max(0.05, item.scale ?? 1),
945
+ angle: Number.isFinite(item.angle) ? item.angle : 0,
946
+ opacity: item.opacity,
947
+ };
948
+ }
949
+ computeCanvasProps(render, size, frame) {
950
+ const left = render.left;
951
+ const top = render.top;
952
+ const zoom = render.scale;
953
+ const angle = render.angle;
954
+ const centerX = frame.left + left * frame.width;
955
+ const centerY = frame.top + top * frame.height;
956
+ const scale = this.getCoverScale(frame, size) * zoom;
957
+ return {
958
+ left: centerX,
959
+ top: centerY,
960
+ scaleX: scale,
961
+ scaleY: scale,
962
+ angle,
963
+ originX: "center",
964
+ originY: "center",
965
+ uniformScaling: true,
966
+ lockScalingFlip: true,
967
+ selectable: this.isImageEditingVisible(),
968
+ evented: this.isImageEditingVisible(),
969
+ hasControls: this.isImageEditingVisible(),
970
+ hasBorders: this.isImageEditingVisible(),
971
+ opacity: render.opacity,
972
+ };
973
+ }
974
+ toSceneObjectScale(value) {
975
+ if (!this.canvasService)
976
+ return value;
977
+ return value / this.canvasService.getSceneScale();
978
+ }
979
+ getCurrentSrc(obj) {
980
+ if (!obj)
981
+ return undefined;
982
+ if (typeof obj.getSrc === "function")
983
+ return obj.getSrc();
984
+ return obj?._originalElement?.src;
985
+ }
986
+ async buildImageSpecs(items, frame) {
987
+ const specs = [];
988
+ for (const item of items) {
989
+ const render = this.resolveRenderImageState(item);
990
+ if (!render.src)
991
+ continue;
992
+ const ensured = await this.ensureSourceSize(render.src);
993
+ const sourceSize = ensured || this.getSourceSize(render.src);
994
+ const props = this.computeCanvasProps(render, sourceSize, frame);
995
+ specs.push({
996
+ id: item.id,
997
+ type: "image",
998
+ src: render.src,
999
+ data: {
1000
+ id: item.id,
1001
+ layerId: layers_1.IMAGE_OBJECT_LAYER_ID,
1002
+ type: "image-item",
1003
+ },
1004
+ props,
1005
+ });
1006
+ }
1007
+ return specs;
1008
+ }
1009
+ buildOverlaySpecs(frame, sceneGeometry) {
1010
+ const visible = this.isImageEditingVisible();
1011
+ if (!visible ||
1012
+ frame.width <= 0 ||
1013
+ frame.height <= 0 ||
1014
+ !this.canvasService) {
1015
+ this.debug("overlay:hidden", {
1016
+ visible,
1017
+ frame,
1018
+ isToolActive: this.isToolActive,
1019
+ isImageSelectionActive: this.isImageSelectionActive,
1020
+ focusedImageId: this.focusedImageId,
1021
+ });
1022
+ return [];
1023
+ }
1024
+ const viewport = this.canvasService.getSceneViewportRect();
1025
+ const canvasW = viewport.width || 0;
1026
+ const canvasH = viewport.height || 0;
1027
+ const canvasLeft = viewport.left || 0;
1028
+ const canvasTop = viewport.top || 0;
1029
+ const visual = this.getFrameVisualConfig();
1030
+ const strokeWidthScene = this.canvasService.toSceneLength(visual.strokeWidth);
1031
+ const dashLengthScene = this.canvasService.toSceneLength(visual.dashLength);
1032
+ const frameLeft = Math.max(canvasLeft, Math.min(canvasLeft + canvasW, frame.left));
1033
+ const frameTop = Math.max(canvasTop, Math.min(canvasTop + canvasH, frame.top));
1034
+ const frameRight = Math.max(frameLeft, Math.min(canvasLeft + canvasW, frame.left + frame.width));
1035
+ const frameBottom = Math.max(frameTop, Math.min(canvasTop + canvasH, frame.top + frame.height));
1036
+ const visibleFrameH = Math.max(0, frameBottom - frameTop);
1037
+ const topH = Math.max(0, frameTop - canvasTop);
1038
+ const bottomH = Math.max(0, canvasTop + canvasH - frameBottom);
1039
+ const leftW = Math.max(0, frameLeft - canvasLeft);
1040
+ const rightW = Math.max(0, canvasLeft + canvasW - frameRight);
1041
+ const viewportRect = this.toLayoutSceneRect({
1042
+ left: canvasLeft,
1043
+ top: canvasTop,
1044
+ width: canvasW,
1045
+ height: canvasH,
1046
+ });
1047
+ const visibleFrameBandRect = this.toLayoutSceneRect({
1048
+ left: canvasLeft,
1049
+ top: frameTop,
1050
+ width: canvasW,
1051
+ height: visibleFrameH,
1052
+ });
1053
+ const frameRect = this.toLayoutSceneRect(frame);
1054
+ const shapeOverlay = this.buildCropShapeOverlaySpecs(frame, sceneGeometry);
1055
+ const mask = [
1056
+ {
1057
+ id: "image.cropMask.top",
1058
+ type: "rect",
1059
+ data: { id: "image.cropMask.top", zIndex: 1 },
1060
+ layout: {
1061
+ reference: "custom",
1062
+ referenceRect: viewportRect,
1063
+ alignX: "start",
1064
+ alignY: "start",
1065
+ width: "100%",
1066
+ height: topH,
1067
+ },
1068
+ props: {
1069
+ originX: "left",
1070
+ originY: "top",
1071
+ fill: visual.outerBackground,
1072
+ selectable: false,
1073
+ evented: false,
1074
+ },
1075
+ },
1076
+ {
1077
+ id: "image.cropMask.bottom",
1078
+ type: "rect",
1079
+ data: { id: "image.cropMask.bottom", zIndex: 2 },
1080
+ layout: {
1081
+ reference: "custom",
1082
+ referenceRect: viewportRect,
1083
+ alignX: "start",
1084
+ alignY: "end",
1085
+ width: "100%",
1086
+ height: bottomH,
1087
+ },
1088
+ props: {
1089
+ originX: "left",
1090
+ originY: "top",
1091
+ fill: visual.outerBackground,
1092
+ selectable: false,
1093
+ evented: false,
1094
+ },
1095
+ },
1096
+ {
1097
+ id: "image.cropMask.left",
1098
+ type: "rect",
1099
+ data: { id: "image.cropMask.left", zIndex: 3 },
1100
+ layout: {
1101
+ reference: "custom",
1102
+ referenceRect: visibleFrameBandRect,
1103
+ alignX: "start",
1104
+ alignY: "start",
1105
+ width: leftW,
1106
+ height: "100%",
1107
+ },
1108
+ props: {
1109
+ originX: "left",
1110
+ originY: "top",
1111
+ fill: visual.outerBackground,
1112
+ selectable: false,
1113
+ evented: false,
1114
+ },
1115
+ },
1116
+ {
1117
+ id: "image.cropMask.right",
1118
+ type: "rect",
1119
+ data: { id: "image.cropMask.right", zIndex: 4 },
1120
+ layout: {
1121
+ reference: "custom",
1122
+ referenceRect: visibleFrameBandRect,
1123
+ alignX: "end",
1124
+ alignY: "start",
1125
+ width: rightW,
1126
+ height: "100%",
1127
+ },
1128
+ props: {
1129
+ originX: "left",
1130
+ originY: "top",
1131
+ fill: visual.outerBackground,
1132
+ selectable: false,
1133
+ evented: false,
1134
+ },
1135
+ },
1136
+ ];
1137
+ const frameSpec = {
1138
+ id: "image.cropFrame",
1139
+ type: "rect",
1140
+ data: { id: "image.cropFrame", zIndex: 7 },
1141
+ layout: {
1142
+ reference: "custom",
1143
+ referenceRect: frameRect,
1144
+ alignX: "start",
1145
+ alignY: "start",
1146
+ width: "100%",
1147
+ height: "100%",
1148
+ },
1149
+ props: {
1150
+ originX: "left",
1151
+ originY: "top",
1152
+ fill: visual.innerBackground,
1153
+ stroke: visual.strokeStyle === "hidden"
1154
+ ? "rgba(0,0,0,0)"
1155
+ : visual.strokeColor,
1156
+ strokeWidth: visual.strokeStyle === "hidden" ? 0 : strokeWidthScene,
1157
+ strokeDashArray: visual.strokeStyle === "dashed"
1158
+ ? [dashLengthScene, dashLengthScene]
1159
+ : undefined,
1160
+ selectable: false,
1161
+ evented: false,
1162
+ },
1163
+ };
1164
+ const specs = shapeOverlay.length > 0
1165
+ ? [...mask, ...shapeOverlay]
1166
+ : [...mask, ...shapeOverlay, frameSpec];
1167
+ this.debug("overlay:built", {
1168
+ frame,
1169
+ shape: sceneGeometry?.shape,
1170
+ overlayIds: specs.map((spec) => ({
1171
+ id: spec.id,
1172
+ zIndex: spec.data?.zIndex,
1173
+ })),
1174
+ });
1175
+ return specs;
1176
+ }
1177
+ updateImages() {
1178
+ void this.updateImagesAsync();
1179
+ }
1180
+ async updateImagesAsync() {
1181
+ if (!this.canvasService)
1182
+ return;
1183
+ this.syncToolActiveFromWorkbench();
1184
+ const seq = ++this.renderSeq;
1185
+ const renderItems = this.isToolActive ? this.workingItems : this.items;
1186
+ const frame = this.getFrameRect();
1187
+ const desiredIds = new Set(renderItems.map((item) => item.id));
1188
+ if (this.focusedImageId && !desiredIds.has(this.focusedImageId)) {
1189
+ this.setImageFocus(null, {
1190
+ syncCanvasSelection: false,
1191
+ skipRender: true,
1192
+ });
1193
+ }
1194
+ const imageSpecs = await this.buildImageSpecs(renderItems, frame);
1195
+ if (seq !== this.renderSeq)
1196
+ return;
1197
+ const sceneGeometry = await this.resolveSceneGeometryForOverlay();
1198
+ if (seq !== this.renderSeq)
1199
+ return;
1200
+ this.imageSpecs = imageSpecs;
1201
+ this.overlaySpecs = this.buildOverlaySpecs(frame, sceneGeometry);
1202
+ await this.canvasService.flushRenderFromProducers();
1203
+ if (seq !== this.renderSeq)
1204
+ return;
1205
+ this.refreshImageObjectInteractionState();
1206
+ renderItems.forEach((item) => {
1207
+ if (!this.getImageObject(item.id))
1208
+ return;
1209
+ const resolver = this.loadResolvers.get(item.id);
1210
+ if (!resolver)
1211
+ return;
1212
+ resolver();
1213
+ this.loadResolvers.delete(item.id);
1214
+ });
1215
+ if (this.focusedImageId && this.isToolActive) {
1216
+ this.setImageFocus(this.focusedImageId, {
1217
+ syncCanvasSelection: true,
1218
+ skipRender: true,
1219
+ });
1220
+ }
1221
+ const overlayCanvasCount = this.getOverlayObjects().length;
1222
+ this.debug("render:done", {
1223
+ seq,
1224
+ renderCount: renderItems.length,
1225
+ overlayCount: this.overlaySpecs.length,
1226
+ overlayCanvasCount,
1227
+ isToolActive: this.isToolActive,
1228
+ isImageSelectionActive: this.isImageSelectionActive,
1229
+ focusedImageId: this.focusedImageId,
1230
+ });
1231
+ this.canvasService.requestRenderAll();
1232
+ }
1233
+ clampNormalized(value) {
1234
+ return Math.max(-1, Math.min(2, value));
1235
+ }
1236
+ updateImageInWorking(id, updates) {
1237
+ const index = this.workingItems.findIndex((item) => item.id === id);
1238
+ if (index < 0)
1239
+ return;
1240
+ const next = [...this.workingItems];
1241
+ next[index] = this.normalizeItem({ ...next[index], ...updates });
1242
+ this.workingItems = next;
1243
+ this.hasWorkingChanges = true;
1244
+ this.setImageFocus(id, {
1245
+ syncCanvasSelection: false,
1246
+ skipRender: true,
1247
+ });
1248
+ if (this.isToolActive) {
1249
+ this.updateImages();
1250
+ }
1251
+ this.emitWorkingChange(id);
1252
+ }
1253
+ async updateImageInConfig(id, updates) {
1254
+ const index = this.items.findIndex((item) => item.id === id);
1255
+ if (index < 0)
1256
+ return;
1257
+ const replacingSource = typeof updates.url === "string" && updates.url.length > 0;
1258
+ const next = [...this.items];
1259
+ const base = next[index];
1260
+ const replacingUrl = replacingSource ? updates.url : undefined;
1261
+ next[index] = this.normalizeItem({
1262
+ ...base,
1263
+ ...updates,
1264
+ ...(replacingSource
1265
+ ? {
1266
+ url: replacingUrl,
1267
+ sourceUrl: replacingUrl,
1268
+ committedUrl: undefined,
1269
+ scale: updates.scale ?? 1,
1270
+ angle: updates.angle ?? 0,
1271
+ left: updates.left ?? 0.5,
1272
+ top: updates.top ?? 0.5,
1273
+ }
1274
+ : {}),
1275
+ });
1276
+ this.updateConfig(next);
1277
+ if (replacingSource) {
1278
+ this.debug("replace:image:begin", { id, replacingUrl });
1279
+ this.purgeSourceSizeCacheForItem(base);
1280
+ const loaded = await this.waitImageLoaded(id, true);
1281
+ this.debug("replace:image:loaded", { id, loaded });
1282
+ if (loaded) {
1283
+ await this.refitImageToFrame(id);
1284
+ this.setImageFocus(id);
1285
+ }
1286
+ }
1287
+ }
1288
+ waitImageLoaded(id, forceWait = false) {
1289
+ if (!forceWait && this.getImageObject(id)) {
1290
+ return Promise.resolve(true);
1291
+ }
1292
+ return new Promise((resolve) => {
1293
+ const timeout = setTimeout(() => {
1294
+ this.loadResolvers.delete(id);
1295
+ resolve(false);
1296
+ }, 4000);
1297
+ this.loadResolvers.set(id, () => {
1298
+ clearTimeout(timeout);
1299
+ resolve(true);
1300
+ });
1301
+ });
1302
+ }
1303
+ async refitImageToFrame(id) {
1304
+ const obj = this.getImageObject(id);
1305
+ if (!obj || !this.canvasService)
1306
+ return;
1307
+ const current = this.items.find((item) => item.id === id);
1308
+ if (!current)
1309
+ return;
1310
+ const render = this.resolveRenderImageState(current);
1311
+ this.rememberSourceSize(render.src, obj);
1312
+ const source = this.getSourceSize(render.src, obj);
1313
+ const frame = this.getFrameRect();
1314
+ const coverScale = this.getCoverScale(frame, source);
1315
+ const currentScale = this.toSceneObjectScale(obj.scaleX || 1);
1316
+ const zoom = Math.max(0.05, currentScale / coverScale);
1317
+ const updated = {
1318
+ scale: Number.isFinite(zoom) ? zoom : 1,
1319
+ angle: 0,
1320
+ left: 0.5,
1321
+ top: 0.5,
1322
+ };
1323
+ const index = this.items.findIndex((item) => item.id === id);
1324
+ if (index < 0)
1325
+ return;
1326
+ const next = [...this.items];
1327
+ next[index] = this.normalizeItem({ ...next[index], ...updated });
1328
+ this.updateConfig(next);
1329
+ this.workingItems = this.cloneItems(next);
1330
+ this.hasWorkingChanges = false;
1331
+ this.updateImages();
1332
+ this.emitWorkingChange(id);
1333
+ }
1334
+ async fitImageToArea(id, area) {
1335
+ if (!this.canvasService)
1336
+ return;
1337
+ const loaded = await this.waitImageLoaded(id, false);
1338
+ if (!loaded)
1339
+ return;
1340
+ const obj = this.getImageObject(id);
1341
+ if (!obj)
1342
+ return;
1343
+ const renderItems = this.isToolActive ? this.workingItems : this.items;
1344
+ const current = renderItems.find((item) => item.id === id);
1345
+ if (!current)
1346
+ return;
1347
+ const render = this.resolveRenderImageState(current);
1348
+ this.rememberSourceSize(render.src, obj);
1349
+ const source = this.getSourceSize(render.src, obj);
1350
+ const frame = this.getFrameRect();
1351
+ const baseCover = this.getCoverScale(frame, source);
1352
+ const desiredScale = Math.max(Math.max(1, area.width) / Math.max(1, source.width), Math.max(1, area.height) / Math.max(1, source.height));
1353
+ const viewport = this.canvasService.getSceneViewportRect();
1354
+ const canvasW = viewport.width || 1;
1355
+ const canvasH = viewport.height || 1;
1356
+ const areaLeftInput = area.left ?? 0.5;
1357
+ const areaTopInput = area.top ?? 0.5;
1358
+ const areaLeftPx = areaLeftInput <= 1.5
1359
+ ? viewport.left + areaLeftInput * canvasW
1360
+ : areaLeftInput;
1361
+ const areaTopPx = areaTopInput <= 1.5
1362
+ ? viewport.top + areaTopInput * canvasH
1363
+ : areaTopInput;
1364
+ const updates = {
1365
+ scale: Math.max(0.05, desiredScale / baseCover),
1366
+ left: this.clampNormalized((areaLeftPx - frame.left) / Math.max(1, frame.width)),
1367
+ top: this.clampNormalized((areaTopPx - frame.top) / Math.max(1, frame.height)),
1368
+ };
1369
+ if (this.isToolActive) {
1370
+ this.updateImageInWorking(id, updates);
1371
+ return;
1372
+ }
1373
+ await this.updateImageInConfig(id, updates);
1374
+ }
1375
+ async commitWorkingImagesAsCropped() {
1376
+ if (!this.canvasService) {
1377
+ return { ok: false, reason: "canvas-not-ready" };
1378
+ }
1379
+ await this.updateImagesAsync();
1380
+ const frame = this.getFrameRect();
1381
+ if (!frame.width || !frame.height) {
1382
+ return { ok: false, reason: "frame-not-ready" };
1383
+ }
1384
+ const next = [];
1385
+ for (const item of this.workingItems) {
1386
+ const exported = await this.exportCroppedImageByIds([item.id], {
1387
+ multiplier: 2,
1388
+ format: "png",
1389
+ });
1390
+ const url = exported.url;
1391
+ const sourceUrl = item.sourceUrl || item.url;
1392
+ const previousCommitted = item.committedUrl;
1393
+ next.push(this.normalizeItem({
1394
+ ...item,
1395
+ url,
1396
+ // Keep original source for next image-tool session editing,
1397
+ // and use committedUrl as non-image-tools render source.
1398
+ sourceUrl,
1399
+ committedUrl: url,
1400
+ }));
1401
+ if (previousCommitted && previousCommitted !== url) {
1402
+ this.sourceSizeCache.deleteSourceSize(previousCommitted);
1403
+ }
1404
+ }
1405
+ this.hasWorkingChanges = false;
1406
+ this.workingItems = this.cloneItems(next);
1407
+ this.updateConfig(next);
1408
+ this.emitWorkingChange(this.focusedImageId);
1409
+ return { ok: true };
1410
+ }
1411
+ async exportCroppedImageByIds(imageIds, options) {
1412
+ if (!this.canvasService) {
1413
+ throw new Error("CanvasService not initialized");
1414
+ }
1415
+ const normalizedIds = [...new Set(imageIds)].filter((id) => typeof id === "string" && id.length > 0);
1416
+ if (!normalizedIds.length) {
1417
+ throw new Error("image-ids-required");
1418
+ }
1419
+ const frameScene = this.getFrameRect();
1420
+ const frame = this.getFrameRectScreen(frameScene);
1421
+ const multiplier = Math.max(1, options.multiplier ?? 2);
1422
+ const format = options.format === "jpeg" ? "jpeg" : "png";
1423
+ const width = Math.max(1, Math.round(frame.width * multiplier));
1424
+ const height = Math.max(1, Math.round(frame.height * multiplier));
1425
+ const el = document.createElement("canvas");
1426
+ const tempCanvas = new fabric_1.Canvas(el, {
1427
+ renderOnAddRemove: false,
1428
+ selection: false,
1429
+ enableRetinaScaling: false,
1430
+ preserveObjectStacking: true,
1431
+ });
1432
+ tempCanvas.setDimensions({ width, height });
1433
+ try {
1434
+ const idSet = new Set(normalizedIds);
1435
+ const sourceObjects = this.canvasService.canvas
1436
+ .getObjects()
1437
+ .filter((obj) => {
1438
+ return (obj?.data?.layerId === layers_1.IMAGE_OBJECT_LAYER_ID &&
1439
+ typeof obj?.data?.id === "string" &&
1440
+ idSet.has(obj.data.id));
1441
+ });
1442
+ if (!sourceObjects.length) {
1443
+ throw new Error("image-objects-not-found");
1444
+ }
1445
+ for (const source of sourceObjects) {
1446
+ const clone = await source.clone();
1447
+ const center = source.getCenterPoint
1448
+ ? source.getCenterPoint()
1449
+ : new fabric_1.Point(source.left ?? 0, source.top ?? 0);
1450
+ clone.set({
1451
+ originX: "center",
1452
+ originY: "center",
1453
+ left: (center.x - frame.left) * multiplier,
1454
+ top: (center.y - frame.top) * multiplier,
1455
+ scaleX: (source.scaleX || 1) * multiplier,
1456
+ scaleY: (source.scaleY || 1) * multiplier,
1457
+ angle: source.angle || 0,
1458
+ selectable: false,
1459
+ evented: false,
1460
+ });
1461
+ clone.setCoords();
1462
+ tempCanvas.add(clone);
1463
+ }
1464
+ tempCanvas.renderAll();
1465
+ const blob = await tempCanvas.toBlob({ format, multiplier: 1 });
1466
+ if (!blob) {
1467
+ throw new Error("image-export-failed");
1468
+ }
1469
+ return {
1470
+ url: URL.createObjectURL(blob),
1471
+ width,
1472
+ height,
1473
+ multiplier,
1474
+ format,
1475
+ imageIds: sourceObjects
1476
+ .map((obj) => obj?.data?.id)
1477
+ .filter((id) => typeof id === "string"),
1478
+ };
1479
+ }
1480
+ finally {
1481
+ tempCanvas.dispose();
1482
+ }
1483
+ }
1484
+ async exportUserCroppedImage(options = {}) {
1485
+ if (!this.canvasService) {
1486
+ throw new Error("CanvasService not initialized");
1487
+ }
1488
+ await this.updateImagesAsync();
1489
+ this.syncToolActiveFromWorkbench();
1490
+ const imageIds = options.imageIds && options.imageIds.length > 0
1491
+ ? options.imageIds
1492
+ : (this.isToolActive ? this.workingItems : this.items).map((item) => item.id);
1493
+ if (!imageIds.length) {
1494
+ throw new Error("no-images-to-export");
1495
+ }
1496
+ return await this.exportCroppedImageByIds(imageIds, options);
1497
+ }
1498
+ }
1499
+ exports.ImageTool = ImageTool;