@pooder/kit 4.3.1 → 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 +11 -0
  30. package/dist/index.d.mts +339 -36
  31. package/dist/index.d.ts +339 -36
  32. package/dist/index.js +3572 -850
  33. package/dist/index.mjs +3565 -852
  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 -973
  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 +242 -84
  45. package/src/image.ts +1582 -512
  46. package/src/index.ts +14 -10
  47. package/src/maskOps.ts +326 -0
  48. package/src/mirror.ts +128 -128
  49. package/src/renderSpec.ts +18 -0
  50. package/src/ruler.ts +449 -508
  51. package/src/sceneLayout.ts +121 -0
  52. package/src/sceneLayoutModel.ts +335 -0
  53. package/src/sceneVisibility.ts +49 -0
  54. package/src/size.ts +379 -0
  55. package/src/tracer.ts +719 -570
  56. package/src/units.ts +27 -27
  57. package/src/white-ink.ts +1018 -373
  58. package/src/wrappedOffsets.ts +33 -0
  59. package/tests/run.ts +118 -0
  60. package/tsconfig.test.json +15 -15
package/src/white-ink.ts CHANGED
@@ -1,373 +1,1018 @@
1
- import {
2
- Extension,
3
- ExtensionContext,
4
- ContributionPointIds,
5
- CommandContribution,
6
- ConfigurationContribution,
7
- } from "@pooder/core";
8
- import { FabricImage as Image, filters } from "fabric";
9
- import CanvasService from "./CanvasService";
10
-
11
- export class WhiteInkTool implements Extension {
12
- id = "pooder.kit.white-ink";
13
-
14
- public metadata = {
15
- name: "WhiteInkTool",
16
- };
17
-
18
- private customMask: string = "";
19
- private opacity: number = 1;
20
- private enableClip: boolean = false;
21
-
22
- private canvasService?: CanvasService;
23
- private syncHandler: ((e: any) => void) | undefined;
24
- private _loadingUrl: string | null = null;
25
-
26
- constructor(
27
- options?: Partial<{
28
- customMask: string;
29
- opacity: number;
30
- enableClip: boolean;
31
- }>,
32
- ) {
33
- if (options) {
34
- Object.assign(this, options);
35
- }
36
- }
37
-
38
- activate(context: ExtensionContext) {
39
- this.canvasService = context.services.get<CanvasService>("CanvasService");
40
- if (!this.canvasService) {
41
- console.warn("CanvasService not found for WhiteInkTool");
42
- return;
43
- }
44
-
45
- const configService = context.services.get<any>("ConfigurationService");
46
- if (configService) {
47
- // Load initial config
48
- this.customMask = configService.get(
49
- "whiteInk.customMask",
50
- this.customMask,
51
- );
52
- this.opacity = configService.get("whiteInk.opacity", this.opacity);
53
- this.enableClip = configService.get(
54
- "whiteInk.enableClip",
55
- this.enableClip,
56
- );
57
-
58
- // Listen for changes
59
- configService.onAnyChange((e: { key: string; value: any }) => {
60
- if (e.key.startsWith("whiteInk.")) {
61
- const prop = e.key.split(".")[1];
62
- console.log(
63
- `[WhiteInkTool] Config change detected: ${e.key} -> ${e.value}`,
64
- );
65
- if (prop && prop in this) {
66
- (this as any)[prop] = e.value;
67
- this.updateWhiteInk();
68
- }
69
- }
70
- });
71
- }
72
-
73
- this.setup();
74
- this.updateWhiteInk();
75
- }
76
-
77
- deactivate(context: ExtensionContext) {
78
- this.teardown();
79
- this.canvasService = undefined;
80
- }
81
-
82
- contribute() {
83
- return {
84
- [ContributionPointIds.CONFIGURATIONS]: [
85
- {
86
- id: "whiteInk.customMask",
87
- type: "string",
88
- label: "Custom Mask URL",
89
- default: "",
90
- },
91
- {
92
- id: "whiteInk.opacity",
93
- type: "number",
94
- label: "Opacity",
95
- min: 0,
96
- max: 1,
97
- step: 0.01,
98
- default: 1,
99
- },
100
- {
101
- id: "whiteInk.enableClip",
102
- type: "boolean",
103
- label: "Enable Clip",
104
- default: false,
105
- },
106
- ] as ConfigurationContribution[],
107
- [ContributionPointIds.COMMANDS]: [
108
- {
109
- command: "setWhiteInkImage",
110
- title: "Set White Ink Image",
111
- handler: (
112
- customMask: string,
113
- opacity: number,
114
- enableClip: boolean = true,
115
- ) => {
116
- if (
117
- this.customMask === customMask &&
118
- this.opacity === opacity &&
119
- this.enableClip === enableClip
120
- )
121
- return true;
122
-
123
- this.customMask = customMask;
124
- this.opacity = opacity;
125
- this.enableClip = enableClip;
126
-
127
- this.updateWhiteInk();
128
- return true;
129
- },
130
- },
131
- ] as CommandContribution[],
132
- };
133
- }
134
-
135
- private setup() {
136
- if (!this.canvasService) return;
137
- const canvas = this.canvasService.canvas;
138
-
139
- let userLayer = this.canvasService.getLayer("user");
140
- if (!userLayer) {
141
- userLayer = this.canvasService.createLayer("user", {
142
- width: canvas.width,
143
- height: canvas.height,
144
- left: 0,
145
- top: 0,
146
- originX: "left",
147
- originY: "top",
148
- selectable: false,
149
- evented: true,
150
- subTargetCheck: true,
151
- interactive: true,
152
- });
153
- canvas.add(userLayer);
154
- }
155
-
156
- if (!this.syncHandler) {
157
- this.syncHandler = (e: any) => {
158
- const target = e.target;
159
- if (target && target.data?.id === "user-image") {
160
- this.syncWithUserImage();
161
- }
162
- };
163
-
164
- canvas.on("object:moving", this.syncHandler);
165
- canvas.on("object:scaling", this.syncHandler);
166
- canvas.on("object:rotating", this.syncHandler);
167
- canvas.on("object:modified", this.syncHandler);
168
- }
169
- }
170
-
171
- private teardown() {
172
- if (!this.canvasService) return;
173
- const canvas = this.canvasService.canvas;
174
-
175
- if (this.syncHandler) {
176
- canvas.off("object:moving", this.syncHandler);
177
- canvas.off("object:scaling", this.syncHandler);
178
- canvas.off("object:rotating", this.syncHandler);
179
- canvas.off("object:modified", this.syncHandler);
180
- this.syncHandler = undefined;
181
- }
182
-
183
- const layer = this.canvasService.getLayer("user");
184
- if (layer) {
185
- const whiteInk = this.canvasService.getObject("white-ink", "user");
186
- if (whiteInk) {
187
- layer.remove(whiteInk);
188
- }
189
- }
190
-
191
- const userImage = this.canvasService.getObject("user-image", "user") as any;
192
- if (userImage && userImage.clipPath) {
193
- userImage.set({ clipPath: undefined });
194
- }
195
-
196
- this.canvasService.requestRenderAll();
197
- }
198
-
199
- private updateWhiteInk() {
200
- if (!this.canvasService) return;
201
- const { customMask, opacity, enableClip } = this;
202
-
203
- const layer = this.canvasService.getLayer("user");
204
- if (!layer) {
205
- console.warn("[WhiteInkTool] User layer not found");
206
- return;
207
- }
208
-
209
- const whiteInk = this.canvasService.getObject("white-ink", "user") as any;
210
- const userImage = this.canvasService.getObject("user-image", "user") as any;
211
-
212
- if (!customMask) {
213
- if (whiteInk) {
214
- layer.remove(whiteInk);
215
- }
216
- if (userImage && userImage.clipPath) {
217
- userImage.set({ clipPath: undefined });
218
- }
219
- layer.dirty = true;
220
- this.canvasService.requestRenderAll();
221
- return;
222
- }
223
-
224
- // Check if we need to load/reload white ink backing
225
- if (whiteInk) {
226
- const currentSrc = whiteInk.getSrc?.() || whiteInk._element?.src;
227
- if (currentSrc !== customMask) {
228
- this.loadWhiteInk(layer, customMask, opacity, enableClip, whiteInk);
229
- } else {
230
- if (whiteInk.opacity !== opacity) {
231
- whiteInk.set({ opacity });
232
- layer.dirty = true;
233
- this.canvasService.requestRenderAll();
234
- }
235
- }
236
- } else {
237
- this.loadWhiteInk(layer, customMask, opacity, enableClip);
238
- }
239
-
240
- // Handle Clip Path Toggle
241
- if (userImage) {
242
- if (enableClip) {
243
- if (!userImage.clipPath) {
244
- this.applyClipPath(customMask);
245
- }
246
- } else {
247
- if (userImage.clipPath) {
248
- userImage.set({ clipPath: undefined });
249
- layer.dirty = true;
250
- this.canvasService.requestRenderAll();
251
- }
252
- }
253
- }
254
- }
255
-
256
- private loadWhiteInk(
257
- layer: any,
258
- url: string,
259
- opacity: number,
260
- enableClip: boolean,
261
- oldImage?: any,
262
- ) {
263
- if (!this.canvasService) return;
264
-
265
- if (this._loadingUrl === url) return;
266
- this._loadingUrl = url;
267
-
268
- Image.fromURL(url, { crossOrigin: "anonymous" })
269
- .then((image) => {
270
- if (this._loadingUrl !== url) return;
271
- this._loadingUrl = null;
272
-
273
- if (oldImage) {
274
- layer.remove(oldImage);
275
- }
276
-
277
- image.filters?.push(
278
- new filters.BlendColor({
279
- color: "#FFFFFF",
280
- mode: "add",
281
- }),
282
- );
283
- image.applyFilters();
284
-
285
- image.set({
286
- opacity,
287
- selectable: false,
288
- evented: false,
289
- data: {
290
- id: "white-ink",
291
- },
292
- });
293
-
294
- // Add to layer
295
- layer.add(image);
296
-
297
- // Ensure white-ink is behind user-image
298
- const userImage = this.canvasService!.getObject("user-image", "user");
299
- if (userImage) {
300
- // Re-adding moves it to the top of the stack
301
- layer.remove(userImage);
302
- layer.add(userImage);
303
- }
304
-
305
- // Apply clip path to user-image if enabled
306
- if (enableClip) {
307
- this.applyClipPath(url);
308
- } else if (userImage) {
309
- userImage.set({ clipPath: undefined });
310
- }
311
-
312
- // Sync position immediately
313
- this.syncWithUserImage();
314
-
315
- layer.dirty = true;
316
- this.canvasService!.requestRenderAll();
317
- })
318
- .catch((err) => {
319
- console.error("Failed to load white ink mask", url, err);
320
- this._loadingUrl = null;
321
- });
322
- }
323
-
324
- private applyClipPath(url: string) {
325
- if (!this.canvasService) return;
326
- const userImage = this.canvasService.getObject("user-image", "user") as any;
327
- if (!userImage) return;
328
-
329
- Image.fromURL(url, { crossOrigin: "anonymous" })
330
- .then((maskImage) => {
331
- // Configure clipPath
332
- maskImage.set({
333
- originX: "center",
334
- originY: "center",
335
- left: 0,
336
- top: 0,
337
- // Scale to fit userImage if dimensions differ
338
- scaleX: userImage.width / maskImage.width,
339
- scaleY: userImage.height / maskImage.height,
340
- });
341
-
342
- userImage.set({ clipPath: maskImage });
343
- const layer = this.canvasService!.getLayer("user");
344
- if (layer) layer.dirty = true;
345
- this.canvasService!.requestRenderAll();
346
- })
347
- .catch((err) => {
348
- console.error("Failed to load clip path", url, err);
349
- });
350
- }
351
-
352
- private syncWithUserImage() {
353
- if (!this.canvasService) return;
354
- const userImage = this.canvasService.getObject("user-image", "user");
355
- const whiteInk = this.canvasService.getObject("white-ink", "user");
356
-
357
- if (userImage && whiteInk) {
358
- whiteInk.set({
359
- left: userImage.left,
360
- top: userImage.top,
361
- scaleX: userImage.scaleX,
362
- scaleY: userImage.scaleY,
363
- angle: userImage.angle,
364
- skewX: userImage.skewX,
365
- skewY: userImage.skewY,
366
- flipX: userImage.flipX,
367
- flipY: userImage.flipY,
368
- originX: userImage.originX,
369
- originY: userImage.originY,
370
- });
371
- }
372
- }
373
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ ToolSessionService,
9
+ WorkbenchService,
10
+ } from "@pooder/core";
11
+ import { Image as FabricImage } from "fabric";
12
+ import CanvasService from "./CanvasService";
13
+ import { computeSceneLayout, readSizeState } from "./sceneLayoutModel";
14
+
15
+ export interface WhiteInkItem {
16
+ id: string;
17
+ sourceUrl?: string;
18
+ url?: string;
19
+ opacity: number;
20
+ }
21
+
22
+ interface SourceSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+
27
+ interface FrameRect {
28
+ left: number;
29
+ top: number;
30
+ width: number;
31
+ height: number;
32
+ }
33
+
34
+ interface RenderWhiteInkState {
35
+ src: string;
36
+ opacity: number;
37
+ }
38
+
39
+ interface UpsertWhiteInkOptions {
40
+ id?: string;
41
+ mode?: "auto" | "replace" | "add";
42
+ createIfMissing?: boolean;
43
+ addOptions?: Partial<WhiteInkItem>;
44
+ }
45
+
46
+ interface UpdateWhiteInkOptions {
47
+ target?: "auto" | "config" | "working";
48
+ }
49
+
50
+ const WHITE_INK_OBJECT_LAYER_ID = "white-ink.user";
51
+ const IMAGE_OBJECT_LAYER_ID = "image.user";
52
+ const WHITE_INK_DEBUG_KEY = "whiteInk.debug";
53
+ const WHITE_INK_PREVIEW_IMAGE_VISIBLE_KEY = "whiteInk.previewImageVisible";
54
+ const WHITE_INK_DEFAULT_OPACITY = 0.85;
55
+
56
+ export class WhiteInkTool implements Extension {
57
+ id = "pooder.kit.white-ink";
58
+
59
+ metadata = {
60
+ name: "WhiteInkTool",
61
+ };
62
+
63
+ private items: WhiteInkItem[] = [];
64
+ private workingItems: WhiteInkItem[] = [];
65
+ private hasWorkingChanges = false;
66
+ private sourceSizeBySrc: Map<string, SourceSize> = new Map();
67
+ private previewMaskBySource: Map<string, string> = new Map();
68
+ private pendingPreviewMaskBySource: Map<string, Promise<string | null>> =
69
+ new Map();
70
+ private canvasService?: CanvasService;
71
+ private context?: ExtensionContext;
72
+ private isUpdatingConfig = false;
73
+ private isToolActive = false;
74
+ private printWithWhiteInk = true;
75
+ private previewImageVisible = true;
76
+ private renderSeq = 0;
77
+ private dirtyTrackerDisposable?: { dispose(): void };
78
+
79
+ activate(context: ExtensionContext) {
80
+ this.context = context;
81
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
82
+ if (!this.canvasService) {
83
+ console.warn("CanvasService not found for WhiteInkTool");
84
+ return;
85
+ }
86
+
87
+ context.eventBus.on("tool:activated", this.onToolActivated);
88
+ context.eventBus.on("scene:layout:change", this.onSceneLayoutChanged);
89
+ context.eventBus.on("object:added", this.onObjectAdded);
90
+
91
+ const configService = context.services.get<ConfigurationService>(
92
+ "ConfigurationService",
93
+ );
94
+ if (configService) {
95
+ this.items = this.normalizeItems(
96
+ configService.get("whiteInk.items", []) || [],
97
+ );
98
+ this.workingItems = this.cloneItems(this.items);
99
+ this.hasWorkingChanges = false;
100
+ this.printWithWhiteInk = !!configService.get(
101
+ "whiteInk.printWithWhiteInk",
102
+ true,
103
+ );
104
+ this.previewImageVisible = !!configService.get(
105
+ WHITE_INK_PREVIEW_IMAGE_VISIBLE_KEY,
106
+ true,
107
+ );
108
+
109
+ this.migrateLegacyConfigIfNeeded(configService);
110
+
111
+ configService.onAnyChange((e: { key: string; value: any }) => {
112
+ if (this.isUpdatingConfig) return;
113
+
114
+ if (e.key === "whiteInk.items") {
115
+ this.items = this.normalizeItems(e.value || []);
116
+ if (!this.isToolActive || !this.hasWorkingChanges) {
117
+ this.workingItems = this.cloneItems(this.items);
118
+ this.hasWorkingChanges = false;
119
+ }
120
+ this.updateWhiteInks();
121
+ return;
122
+ }
123
+
124
+ if (e.key === "whiteInk.printWithWhiteInk") {
125
+ this.printWithWhiteInk = !!e.value;
126
+ this.updateWhiteInks();
127
+ return;
128
+ }
129
+
130
+ if (e.key === WHITE_INK_PREVIEW_IMAGE_VISIBLE_KEY) {
131
+ this.previewImageVisible = !!e.value;
132
+ this.updateWhiteInks();
133
+ return;
134
+ }
135
+
136
+ if (e.key === WHITE_INK_DEBUG_KEY) {
137
+ return;
138
+ }
139
+
140
+ if (e.key.startsWith("size.")) {
141
+ this.updateWhiteInks();
142
+ }
143
+ });
144
+ }
145
+
146
+ const toolSessionService =
147
+ context.services.get<ToolSessionService>("ToolSessionService");
148
+ this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(
149
+ this.id,
150
+ () => this.hasWorkingChanges,
151
+ );
152
+
153
+ this.updateWhiteInks();
154
+ }
155
+
156
+ deactivate(context: ExtensionContext) {
157
+ context.eventBus.off("tool:activated", this.onToolActivated);
158
+ context.eventBus.off("scene:layout:change", this.onSceneLayoutChanged);
159
+ context.eventBus.off("object:added", this.onObjectAdded);
160
+
161
+ this.dirtyTrackerDisposable?.dispose();
162
+ this.dirtyTrackerDisposable = undefined;
163
+ this.clearRenderedWhiteInks();
164
+ this.applyImagePreviewVisibility(false);
165
+ this.canvasService = undefined;
166
+ this.context = undefined;
167
+ }
168
+
169
+ contribute() {
170
+ return {
171
+ [ContributionPointIds.TOOLS]: [
172
+ {
173
+ id: this.id,
174
+ name: "White Ink",
175
+ interaction: "session",
176
+ commands: {
177
+ begin: "resetWorkingWhiteInks",
178
+ commit: "completeWhiteInks",
179
+ rollback: "resetWorkingWhiteInks",
180
+ },
181
+ session: {
182
+ autoBegin: true,
183
+ leavePolicy: "block",
184
+ },
185
+ },
186
+ ],
187
+ [ContributionPointIds.CONFIGURATIONS]: [
188
+ {
189
+ id: "whiteInk.items",
190
+ type: "array",
191
+ label: "White Ink Images",
192
+ default: [],
193
+ },
194
+ {
195
+ id: "whiteInk.printWithWhiteInk",
196
+ type: "boolean",
197
+ label: "Preview White Ink",
198
+ default: true,
199
+ },
200
+ {
201
+ id: WHITE_INK_PREVIEW_IMAGE_VISIBLE_KEY,
202
+ type: "boolean",
203
+ label: "Show Image During White Ink Preview",
204
+ default: true,
205
+ },
206
+ {
207
+ id: WHITE_INK_DEBUG_KEY,
208
+ type: "boolean",
209
+ label: "White Ink Debug Log",
210
+ default: false,
211
+ },
212
+ ] as ConfigurationContribution[],
213
+ [ContributionPointIds.COMMANDS]: [
214
+ {
215
+ command: "addWhiteInk",
216
+ title: "Add White Ink",
217
+ handler: async (url: string, options?: Partial<WhiteInkItem>) => {
218
+ return await this.addWhiteInkEntry(url, options);
219
+ },
220
+ },
221
+ {
222
+ command: "upsertWhiteInk",
223
+ title: "Upsert White Ink",
224
+ handler: async (url: string, options: UpsertWhiteInkOptions = {}) => {
225
+ return await this.upsertWhiteInkEntry(url, options);
226
+ },
227
+ },
228
+ {
229
+ command: "getWhiteInks",
230
+ title: "Get White Inks",
231
+ handler: () => this.cloneItems(this.items),
232
+ },
233
+ {
234
+ command: "getWhiteInkSettings",
235
+ title: "Get White Ink Settings",
236
+ handler: () => {
237
+ const first = this.items[0] || null;
238
+ const sourceUrl = this.resolveSourceUrl(first);
239
+ return {
240
+ id: first?.id || null,
241
+ url: sourceUrl,
242
+ sourceUrl,
243
+ opacity: first?.opacity ?? WHITE_INK_DEFAULT_OPACITY,
244
+ printWithWhiteInk: this.printWithWhiteInk,
245
+ previewImageVisible: this.previewImageVisible,
246
+ };
247
+ },
248
+ },
249
+ {
250
+ command: "setWhiteInkPrintEnabled",
251
+ title: "Set White Ink Preview Enabled",
252
+ handler: (enabled: boolean) => {
253
+ this.printWithWhiteInk = !!enabled;
254
+ const configService =
255
+ this.context?.services.get<ConfigurationService>(
256
+ "ConfigurationService",
257
+ );
258
+ configService?.update(
259
+ "whiteInk.printWithWhiteInk",
260
+ this.printWithWhiteInk,
261
+ );
262
+ this.updateWhiteInks();
263
+ return { ok: true };
264
+ },
265
+ },
266
+ {
267
+ command: "setWhiteInkPreviewImageVisible",
268
+ title: "Set White Ink Preview Image Visible",
269
+ handler: (visible: boolean) => {
270
+ this.previewImageVisible = !!visible;
271
+ const configService =
272
+ this.context?.services.get<ConfigurationService>(
273
+ "ConfigurationService",
274
+ );
275
+ configService?.update(
276
+ WHITE_INK_PREVIEW_IMAGE_VISIBLE_KEY,
277
+ this.previewImageVisible,
278
+ );
279
+ this.updateWhiteInks();
280
+ return { ok: true };
281
+ },
282
+ },
283
+ {
284
+ command: "setWhiteInkOpacity",
285
+ title: "Set White Ink Opacity",
286
+ handler: async (opacity: number) => {
287
+ const targetId = this.resolveReplaceTargetId(null);
288
+ if (!targetId) {
289
+ return { ok: false, reason: "no-white-ink-item" };
290
+ }
291
+ const nextOpacity = this.clampOpacity(opacity);
292
+ await this.updateWhiteInkItem(
293
+ targetId,
294
+ { opacity: nextOpacity },
295
+ { target: "config" },
296
+ );
297
+ return { ok: true, id: targetId, opacity: nextOpacity };
298
+ },
299
+ },
300
+ {
301
+ command: "getWorkingWhiteInks",
302
+ title: "Get Working White Inks",
303
+ handler: () => this.cloneItems(this.workingItems),
304
+ },
305
+ {
306
+ command: "setWorkingWhiteInk",
307
+ title: "Set Working White Ink",
308
+ handler: (id: string, updates: Partial<WhiteInkItem>) => {
309
+ this.updateWhiteInkInWorking(id, updates);
310
+ },
311
+ },
312
+ {
313
+ command: "updateWhiteInk",
314
+ title: "Update White Ink",
315
+ handler: async (
316
+ id: string,
317
+ updates: Partial<WhiteInkItem>,
318
+ options: UpdateWhiteInkOptions = {},
319
+ ) => {
320
+ await this.updateWhiteInkItem(id, updates, options);
321
+ },
322
+ },
323
+ {
324
+ command: "removeWhiteInk",
325
+ title: "Remove White Ink",
326
+ handler: (id: string) => {
327
+ this.removeWhiteInk(id);
328
+ },
329
+ },
330
+ {
331
+ command: "clearWhiteInks",
332
+ title: "Clear White Inks",
333
+ handler: () => {
334
+ this.clearWhiteInks();
335
+ },
336
+ },
337
+ {
338
+ command: "resetWorkingWhiteInks",
339
+ title: "Reset Working White Inks",
340
+ handler: () => {
341
+ this.workingItems = this.cloneItems(this.items);
342
+ this.hasWorkingChanges = false;
343
+ this.updateWhiteInks();
344
+ },
345
+ },
346
+ {
347
+ command: "completeWhiteInks",
348
+ title: "Complete White Inks",
349
+ handler: async () => {
350
+ return await this.completeWhiteInks();
351
+ },
352
+ },
353
+ {
354
+ command: "setWhiteInkImage",
355
+ title: "Set White Ink Image",
356
+ handler: async (url: string, opacity?: number) => {
357
+ if (!url) {
358
+ this.clearWhiteInks();
359
+ return { ok: true };
360
+ }
361
+
362
+ const resolvedOpacity = Number.isFinite(opacity as any)
363
+ ? this.clampOpacity(Number(opacity))
364
+ : WHITE_INK_DEFAULT_OPACITY;
365
+
366
+ const targetId = this.resolveReplaceTargetId(null);
367
+ const upsertResult = await this.upsertWhiteInkEntry(url, {
368
+ id: targetId || undefined,
369
+ mode: targetId ? "replace" : "add",
370
+ createIfMissing: true,
371
+ addOptions: {
372
+ opacity: resolvedOpacity,
373
+ },
374
+ });
375
+ await this.updateWhiteInkItem(
376
+ upsertResult.id,
377
+ { opacity: resolvedOpacity },
378
+ { target: "config" },
379
+ );
380
+ return { ok: true, id: upsertResult.id };
381
+ },
382
+ },
383
+ ] as CommandContribution[],
384
+ };
385
+ }
386
+
387
+ private onToolActivated = (event: {
388
+ id: string | null;
389
+ previous?: string | null;
390
+ }) => {
391
+ const before = this.isToolActive;
392
+ this.syncToolActiveFromWorkbench(event.id);
393
+ this.debug("tool:activated", {
394
+ id: event.id,
395
+ previous: event.previous,
396
+ before,
397
+ isToolActive: this.isToolActive,
398
+ });
399
+ this.updateWhiteInks();
400
+ };
401
+
402
+ private onSceneLayoutChanged = () => {
403
+ this.updateWhiteInks();
404
+ };
405
+
406
+ private onObjectAdded = () => {
407
+ this.applyImagePreviewVisibility(this.isPreviewActive());
408
+ };
409
+
410
+ private migrateLegacyConfigIfNeeded(configService: ConfigurationService) {
411
+ if (this.items.length > 0) return;
412
+ const legacyMask = configService.get("whiteInk.customMask", "");
413
+ if (typeof legacyMask !== "string" || legacyMask.length === 0) return;
414
+
415
+ const legacyOpacityRaw = configService.get(
416
+ "whiteInk.opacity",
417
+ WHITE_INK_DEFAULT_OPACITY,
418
+ );
419
+ const legacyOpacity = Number(legacyOpacityRaw);
420
+ const item = this.normalizeItem({
421
+ id: this.generateId(),
422
+ sourceUrl: legacyMask,
423
+ opacity: Number.isFinite(legacyOpacity)
424
+ ? legacyOpacity
425
+ : WHITE_INK_DEFAULT_OPACITY,
426
+ });
427
+
428
+ this.items = [item];
429
+ this.workingItems = this.cloneItems(this.items);
430
+ this.isUpdatingConfig = true;
431
+ configService.update("whiteInk.items", this.items);
432
+ setTimeout(() => {
433
+ this.isUpdatingConfig = false;
434
+ }, 0);
435
+ }
436
+
437
+ private syncToolActiveFromWorkbench(fallbackId?: string | null) {
438
+ const wb = this.context?.services.get<WorkbenchService>("WorkbenchService");
439
+ const activeId = wb?.activeToolId;
440
+ if (typeof activeId === "string" || activeId === null) {
441
+ this.isToolActive = activeId === this.id;
442
+ return;
443
+ }
444
+ this.isToolActive = fallbackId === this.id;
445
+ }
446
+
447
+ private isPreviewActive(): boolean {
448
+ return this.isToolActive && this.printWithWhiteInk;
449
+ }
450
+
451
+ private isDebugEnabled(): boolean {
452
+ return !!this.getConfig<boolean>(WHITE_INK_DEBUG_KEY, false);
453
+ }
454
+
455
+ private debug(message: string, payload?: any) {
456
+ if (!this.isDebugEnabled()) return;
457
+ if (payload === undefined) {
458
+ console.log(`[WhiteInkTool] ${message}`);
459
+ return;
460
+ }
461
+ console.log(`[WhiteInkTool] ${message}`, payload);
462
+ }
463
+
464
+ private resolveSourceUrl(item?: Partial<WhiteInkItem> | null): string {
465
+ if (!item) return "";
466
+ if (typeof item.sourceUrl === "string" && item.sourceUrl.length > 0) {
467
+ return item.sourceUrl;
468
+ }
469
+ if (typeof item.url === "string" && item.url.length > 0) {
470
+ return item.url;
471
+ }
472
+ return "";
473
+ }
474
+
475
+ private clampOpacity(value: number): number {
476
+ if (!Number.isFinite(value as any)) return WHITE_INK_DEFAULT_OPACITY;
477
+ return Math.max(0, Math.min(1, Number(value)));
478
+ }
479
+
480
+ private normalizeItem(item: Partial<WhiteInkItem>): WhiteInkItem {
481
+ const sourceUrl = this.resolveSourceUrl(item);
482
+ return {
483
+ id: String(item.id || this.generateId()),
484
+ sourceUrl,
485
+ url: sourceUrl,
486
+ opacity: this.clampOpacity(item.opacity as number),
487
+ };
488
+ }
489
+
490
+ private normalizeItems(items: WhiteInkItem[]): WhiteInkItem[] {
491
+ return (items || [])
492
+ .map((item) => this.normalizeItem(item))
493
+ .filter((item) => !!this.resolveSourceUrl(item));
494
+ }
495
+
496
+ private cloneItems(items: WhiteInkItem[]): WhiteInkItem[] {
497
+ return this.normalizeItems((items || []).map((item) => ({ ...item })));
498
+ }
499
+
500
+ private generateId(): string {
501
+ return `white-ink-${Math.random().toString(36).slice(2, 9)}`;
502
+ }
503
+
504
+ private getConfig<T>(key: string, fallback?: T): T | undefined {
505
+ if (!this.context) return fallback;
506
+ const configService = this.context.services.get<ConfigurationService>(
507
+ "ConfigurationService",
508
+ );
509
+ if (!configService) return fallback;
510
+ return (configService.get(key, fallback) as T) ?? fallback;
511
+ }
512
+
513
+ private resolveReplaceTargetId(explicitId?: string | null): string | null {
514
+ const has = (id: string | null | undefined) =>
515
+ !!id && this.items.some((item) => item.id === id);
516
+ if (has(explicitId)) return explicitId as string;
517
+ if (this.items.length >= 1) {
518
+ return this.items[0].id;
519
+ }
520
+ return null;
521
+ }
522
+
523
+ private updateConfig(newItems: WhiteInkItem[], skipCanvasUpdate = false) {
524
+ if (!this.context) return;
525
+
526
+ this.isUpdatingConfig = true;
527
+ this.items = this.normalizeItems(newItems);
528
+ if (!this.isToolActive || !this.hasWorkingChanges) {
529
+ this.workingItems = this.cloneItems(this.items);
530
+ this.hasWorkingChanges = false;
531
+ }
532
+
533
+ const configService = this.context.services.get<ConfigurationService>(
534
+ "ConfigurationService",
535
+ );
536
+ configService?.update("whiteInk.items", this.items);
537
+
538
+ if (!skipCanvasUpdate) {
539
+ this.updateWhiteInks();
540
+ }
541
+
542
+ setTimeout(() => {
543
+ this.isUpdatingConfig = false;
544
+ }, 50);
545
+ }
546
+
547
+ private async addWhiteInkEntry(
548
+ url: string,
549
+ options?: Partial<WhiteInkItem>,
550
+ ): Promise<string> {
551
+ const id = this.generateId();
552
+ const item = this.normalizeItem({
553
+ id,
554
+ sourceUrl: url,
555
+ opacity: WHITE_INK_DEFAULT_OPACITY,
556
+ ...options,
557
+ });
558
+
559
+ const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
560
+ this.updateConfig([...this.items, item]);
561
+ this.addItemToWorkingSessionIfNeeded(item, sessionDirtyBeforeAdd);
562
+ return id;
563
+ }
564
+
565
+ private async upsertWhiteInkEntry(
566
+ url: string,
567
+ options: UpsertWhiteInkOptions = {},
568
+ ): Promise<{ id: string; mode: "replace" | "add" }> {
569
+ const mode = options.mode || "auto";
570
+ if (mode === "add") {
571
+ const id = await this.addWhiteInkEntry(url, options.addOptions);
572
+ return { id, mode: "add" };
573
+ }
574
+
575
+ const targetId = this.resolveReplaceTargetId(options.id ?? null);
576
+ if (targetId) {
577
+ this.updateWhiteInkInConfig(targetId, {
578
+ ...(options.addOptions || {}),
579
+ sourceUrl: url,
580
+ url,
581
+ });
582
+ return { id: targetId, mode: "replace" };
583
+ }
584
+
585
+ if (mode === "replace" || options.createIfMissing === false) {
586
+ throw new Error("replace-target-not-found");
587
+ }
588
+
589
+ const id = await this.addWhiteInkEntry(url, options.addOptions);
590
+ return { id, mode: "add" };
591
+ }
592
+
593
+ private addItemToWorkingSessionIfNeeded(
594
+ item: WhiteInkItem,
595
+ sessionDirtyBeforeAdd: boolean,
596
+ ) {
597
+ if (!sessionDirtyBeforeAdd || !this.isToolActive) return;
598
+ if (this.workingItems.some((existing) => existing.id === item.id)) return;
599
+ this.workingItems = this.cloneItems([...this.workingItems, item]);
600
+ this.updateWhiteInks();
601
+ }
602
+
603
+ private async updateWhiteInkItem(
604
+ id: string,
605
+ updates: Partial<WhiteInkItem>,
606
+ options: UpdateWhiteInkOptions = {},
607
+ ) {
608
+ this.syncToolActiveFromWorkbench();
609
+ const target = options.target || "auto";
610
+ if (target === "working" || (target === "auto" && this.isToolActive)) {
611
+ this.updateWhiteInkInWorking(id, updates);
612
+ return;
613
+ }
614
+
615
+ this.updateWhiteInkInConfig(id, updates);
616
+ }
617
+
618
+ private updateWhiteInkInWorking(id: string, updates: Partial<WhiteInkItem>) {
619
+ let changed = false;
620
+ const next = this.workingItems.map((item) => {
621
+ if (item.id !== id) return item;
622
+ changed = true;
623
+ return this.normalizeItem({
624
+ ...item,
625
+ ...updates,
626
+ });
627
+ });
628
+ if (!changed) return;
629
+
630
+ this.workingItems = this.cloneItems(next);
631
+ this.hasWorkingChanges = true;
632
+ this.updateWhiteInks();
633
+ }
634
+
635
+ private updateWhiteInkInConfig(id: string, updates: Partial<WhiteInkItem>) {
636
+ let changed = false;
637
+ const next = this.items.map((item) => {
638
+ if (item.id !== id) return item;
639
+ changed = true;
640
+ const merged = this.normalizeItem({
641
+ ...item,
642
+ ...updates,
643
+ });
644
+ if (this.resolveSourceUrl(item) !== this.resolveSourceUrl(merged)) {
645
+ this.purgeSourceCaches(item);
646
+ }
647
+ return merged;
648
+ });
649
+ if (!changed) return;
650
+
651
+ this.updateConfig(next);
652
+ }
653
+
654
+ private removeWhiteInk(id: string) {
655
+ const removed = this.items.find((item) => item.id === id);
656
+ const next = this.items.filter((item) => item.id !== id);
657
+ if (next.length === this.items.length) return;
658
+
659
+ this.purgeSourceCaches(removed);
660
+ this.updateConfig(next);
661
+ }
662
+
663
+ private clearWhiteInks() {
664
+ this.sourceSizeBySrc.clear();
665
+ this.previewMaskBySource.clear();
666
+ this.pendingPreviewMaskBySource.clear();
667
+ this.updateConfig([]);
668
+ }
669
+
670
+ private async completeWhiteInks() {
671
+ this.updateConfig(this.cloneItems(this.workingItems));
672
+ this.hasWorkingChanges = false;
673
+ return { ok: true };
674
+ }
675
+
676
+ private getFrameRect(): FrameRect {
677
+ if (!this.canvasService) {
678
+ return { left: 0, top: 0, width: 0, height: 0 };
679
+ }
680
+ const configService = this.context?.services.get<ConfigurationService>(
681
+ "ConfigurationService",
682
+ );
683
+ if (!configService) {
684
+ return { left: 0, top: 0, width: 0, height: 0 };
685
+ }
686
+ const sizeState = readSizeState(configService);
687
+ const layout = computeSceneLayout(this.canvasService, sizeState);
688
+ if (!layout) {
689
+ return { left: 0, top: 0, width: 0, height: 0 };
690
+ }
691
+
692
+ return {
693
+ left: layout.cutRect.left,
694
+ top: layout.cutRect.top,
695
+ width: layout.cutRect.width,
696
+ height: layout.cutRect.height,
697
+ };
698
+ }
699
+
700
+ private getWhiteInkObjects(): any[] {
701
+ if (!this.canvasService) return [];
702
+ return this.canvasService.canvas.getObjects().filter((obj: any) => {
703
+ return obj?.data?.layerId === WHITE_INK_OBJECT_LAYER_ID;
704
+ }) as any[];
705
+ }
706
+
707
+ private getWhiteInkObject(id: string): any | undefined {
708
+ return this.getWhiteInkObjects().find((obj: any) => obj?.data?.id === id);
709
+ }
710
+
711
+ private clearRenderedWhiteInks() {
712
+ if (!this.canvasService) return;
713
+ const canvas = this.canvasService.canvas;
714
+ this.getWhiteInkObjects().forEach((obj) => canvas.remove(obj));
715
+ this.canvasService.requestRenderAll();
716
+ }
717
+
718
+ private purgeSourceCaches(item?: WhiteInkItem) {
719
+ const sourceUrl = this.resolveSourceUrl(item);
720
+ if (!sourceUrl) return;
721
+ this.sourceSizeBySrc.delete(sourceUrl);
722
+ this.previewMaskBySource.delete(sourceUrl);
723
+ this.pendingPreviewMaskBySource.delete(sourceUrl);
724
+ }
725
+
726
+ private rememberSourceSize(src: string, obj: any) {
727
+ const width = Number(obj?.width || 0);
728
+ const height = Number(obj?.height || 0);
729
+ if (src && width > 0 && height > 0) {
730
+ this.sourceSizeBySrc.set(src, { width, height });
731
+ }
732
+ }
733
+
734
+ private getSourceSize(src: string, obj?: any): SourceSize {
735
+ const cached = src ? this.sourceSizeBySrc.get(src) : undefined;
736
+ if (cached) return cached;
737
+
738
+ const width = Number(obj?.width || 0);
739
+ const height = Number(obj?.height || 0);
740
+ if (src && width > 0 && height > 0) {
741
+ const size = { width, height };
742
+ this.sourceSizeBySrc.set(src, size);
743
+ return size;
744
+ }
745
+
746
+ return { width: 1, height: 1 };
747
+ }
748
+
749
+ private getCoverScale(frame: FrameRect, size: SourceSize): number {
750
+ const sw = Math.max(1, size.width);
751
+ const sh = Math.max(1, size.height);
752
+ const fw = Math.max(1, frame.width);
753
+ const fh = Math.max(1, frame.height);
754
+ return Math.max(fw / sw, fh / sh);
755
+ }
756
+
757
+ private resolveRenderState(
758
+ item: WhiteInkItem,
759
+ src: string,
760
+ ): RenderWhiteInkState {
761
+ return {
762
+ src,
763
+ opacity: this.clampOpacity(item.opacity),
764
+ };
765
+ }
766
+
767
+ private computeCanvasProps(
768
+ render: RenderWhiteInkState,
769
+ size: SourceSize,
770
+ frame: FrameRect,
771
+ ) {
772
+ const centerX = frame.left + frame.width / 2;
773
+ const centerY = frame.top + frame.height / 2;
774
+ const scale = this.getCoverScale(frame, size);
775
+
776
+ return {
777
+ left: centerX,
778
+ top: centerY,
779
+ scaleX: scale,
780
+ scaleY: scale,
781
+ angle: 0,
782
+ originX: "center" as const,
783
+ originY: "center" as const,
784
+ uniformScaling: true,
785
+ lockScalingFlip: true,
786
+ selectable: false,
787
+ evented: false,
788
+ hasControls: false,
789
+ hasBorders: false,
790
+ opacity: render.opacity,
791
+ excludeFromExport: true,
792
+ };
793
+ }
794
+
795
+ private getCurrentSrc(obj: any): string | undefined {
796
+ if (!obj) return undefined;
797
+ if (typeof obj.getSrc === "function") return obj.getSrc();
798
+ return obj?._originalElement?.src;
799
+ }
800
+
801
+ private async upsertWhiteInkObject(
802
+ item: WhiteInkItem,
803
+ frame: FrameRect,
804
+ seq: number,
805
+ ) {
806
+ if (!this.canvasService) return;
807
+ const canvas = this.canvasService.canvas;
808
+ const sourceUrl = this.resolveSourceUrl(item);
809
+ if (!sourceUrl) return;
810
+
811
+ const previewSrc = await this.getPreviewMaskSource(sourceUrl);
812
+ if (seq !== this.renderSeq) return;
813
+
814
+ const render = this.resolveRenderState(item, previewSrc);
815
+ if (!render.src) return;
816
+
817
+ let obj = this.getWhiteInkObject(item.id);
818
+ const currentSrc = this.getCurrentSrc(obj);
819
+
820
+ if (obj && currentSrc && currentSrc !== render.src) {
821
+ canvas.remove(obj);
822
+ obj = undefined;
823
+ }
824
+
825
+ if (!obj) {
826
+ const created = await FabricImage.fromURL(render.src, {
827
+ crossOrigin: "anonymous",
828
+ });
829
+ if (seq !== this.renderSeq) return;
830
+
831
+ created.set({
832
+ excludeFromExport: true,
833
+ data: {
834
+ id: item.id,
835
+ layerId: WHITE_INK_OBJECT_LAYER_ID,
836
+ type: "white-ink-item",
837
+ },
838
+ } as any);
839
+ canvas.add(created as any);
840
+ obj = created as any;
841
+ }
842
+
843
+ this.rememberSourceSize(render.src, obj);
844
+ const sourceSize = this.getSourceSize(render.src, obj);
845
+ const props = this.computeCanvasProps(render, sourceSize, frame);
846
+
847
+ obj.set({
848
+ ...props,
849
+ data: {
850
+ ...(obj.data || {}),
851
+ id: item.id,
852
+ layerId: WHITE_INK_OBJECT_LAYER_ID,
853
+ type: "white-ink-item",
854
+ },
855
+ });
856
+ obj.setCoords();
857
+ }
858
+
859
+ private syncZOrder(items: WhiteInkItem[]) {
860
+ if (!this.canvasService) return;
861
+ const canvas = this.canvasService.canvas;
862
+ const objects = canvas.getObjects();
863
+ let insertIndex = 0;
864
+
865
+ const imageIndexes = objects
866
+ .map((obj: any, index: number) =>
867
+ obj?.data?.layerId === IMAGE_OBJECT_LAYER_ID ? index : -1,
868
+ )
869
+ .filter((index: number) => index >= 0);
870
+
871
+ if (imageIndexes.length > 0) {
872
+ insertIndex = Math.min(...imageIndexes);
873
+ } else {
874
+ const backgroundLayer = this.canvasService.getLayer("background");
875
+ if (backgroundLayer) {
876
+ const bgIndex = objects.indexOf(backgroundLayer as any);
877
+ if (bgIndex >= 0) insertIndex = bgIndex + 1;
878
+ }
879
+ }
880
+
881
+ items.forEach((item) => {
882
+ const obj = this.getWhiteInkObject(item.id);
883
+ if (!obj) return;
884
+ canvas.moveObjectTo(obj, insertIndex);
885
+ insertIndex += 1;
886
+ });
887
+
888
+ canvas
889
+ .getObjects()
890
+ .filter((obj: any) => obj?.data?.layerId === "image-overlay")
891
+ .forEach((obj: any) => canvas.bringObjectToFront(obj));
892
+
893
+ const dielineOverlay = this.canvasService.getLayer("dieline-overlay");
894
+ if (dielineOverlay) {
895
+ canvas.bringObjectToFront(dielineOverlay as any);
896
+ }
897
+ }
898
+
899
+ private applyImagePreviewVisibility(previewActive: boolean) {
900
+ if (!this.canvasService) return;
901
+ const visible = previewActive ? this.previewImageVisible : true;
902
+ let changed = false;
903
+
904
+ this.canvasService.canvas.getObjects().forEach((obj: any) => {
905
+ if (obj?.data?.layerId !== IMAGE_OBJECT_LAYER_ID) return;
906
+ if (obj.visible === visible) return;
907
+ obj.set({ visible });
908
+ obj.setCoords?.();
909
+ changed = true;
910
+ });
911
+
912
+ if (changed) {
913
+ this.canvasService.requestRenderAll();
914
+ }
915
+ }
916
+
917
+ private updateWhiteInks() {
918
+ void this.updateWhiteInksAsync();
919
+ }
920
+
921
+ private async updateWhiteInksAsync() {
922
+ if (!this.canvasService) return;
923
+ this.syncToolActiveFromWorkbench();
924
+ const seq = ++this.renderSeq;
925
+
926
+ const previewActive = this.isPreviewActive();
927
+ this.applyImagePreviewVisibility(previewActive);
928
+ const renderItems = previewActive ? this.workingItems : [];
929
+ const frame = this.getFrameRect();
930
+ const desiredIds = new Set(renderItems.map((item) => item.id));
931
+
932
+ this.getWhiteInkObjects().forEach((obj: any) => {
933
+ const id = obj?.data?.id;
934
+ if (typeof id === "string" && !desiredIds.has(id)) {
935
+ this.canvasService?.canvas.remove(obj);
936
+ }
937
+ });
938
+
939
+ for (const item of renderItems) {
940
+ if (seq !== this.renderSeq) return;
941
+ await this.upsertWhiteInkObject(item, frame, seq);
942
+ }
943
+ if (seq !== this.renderSeq) return;
944
+
945
+ this.syncZOrder(renderItems);
946
+ this.canvasService.requestRenderAll();
947
+ }
948
+
949
+ private async getPreviewMaskSource(sourceUrl: string): Promise<string> {
950
+ if (!sourceUrl) return "";
951
+ if (typeof document === "undefined" || typeof Image === "undefined") {
952
+ return sourceUrl;
953
+ }
954
+
955
+ const cached = this.previewMaskBySource.get(sourceUrl);
956
+ if (cached) return cached;
957
+
958
+ const pending = this.pendingPreviewMaskBySource.get(sourceUrl);
959
+ if (pending) {
960
+ const loaded = await pending;
961
+ return loaded || sourceUrl;
962
+ }
963
+
964
+ const task = this.createOpaqueMaskSource(sourceUrl);
965
+ this.pendingPreviewMaskBySource.set(sourceUrl, task);
966
+ const loaded = await task;
967
+ this.pendingPreviewMaskBySource.delete(sourceUrl);
968
+
969
+ if (!loaded) return sourceUrl;
970
+ this.previewMaskBySource.set(sourceUrl, loaded);
971
+ return loaded;
972
+ }
973
+
974
+ private async createOpaqueMaskSource(
975
+ sourceUrl: string,
976
+ ): Promise<string | null> {
977
+ try {
978
+ const img = await this.loadImageElement(sourceUrl);
979
+ const width = Math.max(1, Number(img.naturalWidth || img.width || 0));
980
+ const height = Math.max(1, Number(img.naturalHeight || img.height || 0));
981
+ if (width <= 0 || height <= 0) return null;
982
+
983
+ const canvas = document.createElement("canvas");
984
+ canvas.width = width;
985
+ canvas.height = height;
986
+ const ctx = canvas.getContext("2d");
987
+ if (!ctx) return null;
988
+
989
+ ctx.drawImage(img, 0, 0, width, height);
990
+ const imageData = ctx.getImageData(0, 0, width, height);
991
+ const data = imageData.data;
992
+
993
+ for (let i = 0; i < data.length; i += 4) {
994
+ const alpha = data[i + 3];
995
+ data[i] = 255;
996
+ data[i + 1] = 255;
997
+ data[i + 2] = 255;
998
+ data[i + 3] = alpha;
999
+ }
1000
+
1001
+ ctx.putImageData(imageData, 0, 0);
1002
+ return canvas.toDataURL("image/png");
1003
+ } catch (error) {
1004
+ this.debug("mask:extract:failed", { sourceUrl, error });
1005
+ return null;
1006
+ }
1007
+ }
1008
+
1009
+ private loadImageElement(sourceUrl: string): Promise<HTMLImageElement> {
1010
+ return new Promise((resolve, reject) => {
1011
+ const image = new Image();
1012
+ image.crossOrigin = "anonymous";
1013
+ image.onload = () => resolve(image);
1014
+ image.onerror = () => reject(new Error("white-ink-image-load-failed"));
1015
+ image.src = sourceUrl;
1016
+ });
1017
+ }
1018
+ }