@pooder/kit 1.0.0 → 3.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.
package/src/white-ink.ts CHANGED
@@ -1,301 +1,373 @@
1
- import {
2
- Command,
3
- Editor,
4
- EditorState,
5
- EventHandler,
6
- Extension,
7
- OptionSchema,
8
- Image,
9
- filters,
10
- PooderObject,
11
- PooderLayer
12
- } from '@pooder/core';
13
-
14
- interface WhiteInkToolOptions {
15
- customMask: string;
16
- opacity: number;
17
- enableClip: boolean;
18
- }
19
- export class WhiteInkTool implements Extension<WhiteInkToolOptions> {
20
- public name = 'WhiteInkTool';
21
- public options: WhiteInkToolOptions = {
22
- customMask: '',
23
- opacity: 1,
24
- enableClip: false
25
- };
26
-
27
- public schema: Record<keyof WhiteInkToolOptions, OptionSchema> = {
28
- customMask: { type: 'string', label: 'Custom Mask URL' },
29
- opacity: { type: 'number', min: 0, max: 1, step: 0.01, label: 'Opacity' },
30
- enableClip: { type: 'boolean', label: 'Enable Clip' }
31
- };
32
-
33
- private syncHandler: EventHandler | undefined;
34
-
35
- onMount(editor: Editor) {
36
- this.setup(editor);
37
- this.updateWhiteInk(editor, this.options);
38
- }
39
-
40
- onUnmount(editor: Editor) {
41
- this.teardown(editor);
42
- }
43
-
44
- onDestroy(editor: Editor) {
45
- this.teardown(editor);
46
- }
47
-
48
- private setup(editor: Editor) {
49
- let userLayer = editor.getLayer("user");
50
- if (!userLayer) {
51
- userLayer = new PooderLayer([], {
52
- width: editor.state.width,
53
- height: editor.state.height,
54
- left: 0,
55
- top: 0,
56
- originX: 'left',
57
- originY: 'top',
58
- selectable: false,
59
- evented: true,
60
- subTargetCheck: true,
61
- interactive: true,
62
- data: {
63
- id: 'user'
64
- }
65
- });
66
- editor.canvas.add(userLayer);
67
- }
68
-
69
- if (!this.syncHandler) {
70
- this.syncHandler = (e: any) => {
71
- const target = e.target;
72
- if (target && target.data?.id === 'user-image') {
73
- this.syncWithUserImage(editor);
74
- }
75
- };
76
-
77
- editor.canvas.on('object:moving', this.syncHandler);
78
- editor.canvas.on('object:scaling', this.syncHandler);
79
- editor.canvas.on('object:rotating', this.syncHandler);
80
- editor.canvas.on('object:modified', this.syncHandler);
81
- }
82
- }
83
-
84
- private teardown(editor: Editor) {
85
- if (this.syncHandler) {
86
- editor.canvas.off('object:moving', this.syncHandler);
87
- editor.canvas.off('object:scaling', this.syncHandler);
88
- editor.canvas.off('object:rotating', this.syncHandler);
89
- editor.canvas.off('object:modified', this.syncHandler);
90
- this.syncHandler = undefined;
91
- }
92
-
93
- const layer = editor.getLayer("user");
94
- if (layer) {
95
- const whiteInk = editor.getObject("white-ink", "user");
96
- if (whiteInk) {
97
- layer.remove(whiteInk);
98
- }
99
- }
100
-
101
- const userImage = editor.getObject("user-image", "user") as any;
102
- if (userImage && userImage.clipPath) {
103
- userImage.set({ clipPath: undefined });
104
- }
105
-
106
- editor.canvas.requestRenderAll();
107
- }
108
-
109
- onUpdate(editor: Editor, state: EditorState) {
110
- this.updateWhiteInk(editor, this.options);
111
- }
112
-
113
- commands: Record<string, Command> = {
114
- setWhiteInkImage: {
115
- execute: (editor: Editor, customMask: string, opacity: number, enableClip: boolean = true) => {
116
- if (this.options.customMask === customMask &&
117
- this.options.opacity === opacity &&
118
- this.options.enableClip === enableClip) return true;
119
-
120
- this.options.customMask = customMask;
121
- this.options.opacity = opacity;
122
- this.options.enableClip = enableClip;
123
-
124
- this.updateWhiteInk(editor, this.options);
125
-
126
- return true;
127
- },
128
- schema: {
129
- customMask: {
130
- type: 'string',
131
- label: 'Custom Mask URL',
132
- required: true
133
- },
134
- opacity: {
135
- type: 'number',
136
- label: 'Opacity',
137
- min: 0,
138
- max: 1,
139
- required: true
140
- },
141
- enableClip: {
142
- type: 'boolean',
143
- label: 'Enable Clip',
144
- default: true,
145
- required: false
146
- }
147
- }
148
- }
149
- };
150
-
151
- private updateWhiteInk(editor: Editor, opts: WhiteInkToolOptions) {
152
- const { customMask, opacity, enableClip } = opts;
153
-
154
- const layer = editor.getLayer("user");
155
- if (!layer) {
156
- console.warn('[WhiteInkTool] User layer not found');
157
- return;
158
- }
159
-
160
- const whiteInk = editor.getObject("white-ink", "user") as any;
161
- const userImage = editor.getObject("user-image", "user") as any;
162
-
163
- if (!customMask) {
164
- if (whiteInk) {
165
- layer.remove(whiteInk);
166
- }
167
- if (userImage && userImage.clipPath) {
168
- userImage.set({ clipPath: undefined });
169
- }
170
- editor.canvas.requestRenderAll();
171
- return;
172
- }
173
-
174
- // Check if we need to load/reload white ink backing
175
- if (whiteInk) {
176
- const currentSrc = whiteInk.getSrc?.() || whiteInk._element?.src;
177
- if (currentSrc !== customMask) {
178
- this.loadWhiteInk(editor, layer, customMask, opacity, enableClip, whiteInk);
179
- } else {
180
- if (whiteInk.opacity !== opacity) {
181
- whiteInk.set({ opacity });
182
- editor.canvas.requestRenderAll();
183
- }
184
- }
185
- } else {
186
- this.loadWhiteInk(editor, layer, customMask, opacity, enableClip);
187
- }
188
-
189
- // Handle Clip Path Toggle
190
- if (userImage) {
191
- if (enableClip) {
192
- // If enabled but missing, or mask changed (handled by re-load above, but good to ensure), apply it
193
- // We check if clipPath is present. Ideally we should check if it matches current mask,
194
- // but re-applying is safe.
195
- if (!userImage.clipPath) {
196
- this.applyClipPath(editor, customMask);
197
- }
198
- } else {
199
- // If disabled but present, remove it
200
- if (userImage.clipPath) {
201
- userImage.set({ clipPath: undefined });
202
- editor.canvas.requestRenderAll();
203
- }
204
- }
205
- }
206
- }
207
-
208
- private loadWhiteInk(editor: Editor, layer: PooderLayer, url: string, opacity: number, enableClip: boolean, oldImage?: any) {
209
- Image.fromURL(url, { crossOrigin: 'anonymous' }).then(image => {
210
- if (oldImage) {
211
- // Remove old image but don't copy properties yet, we'll sync with user-image
212
- layer.remove(oldImage);
213
- }
214
-
215
- image.filters?.push(new filters.BlendColor({
216
- color: '#FFFFFF',
217
- mode: 'add'
218
- }));
219
- image.applyFilters();
220
-
221
- image.set({
222
- opacity,
223
- selectable: false,
224
- evented: false,
225
- data: {
226
- id: 'white-ink'
227
- }
228
- });
229
-
230
- // Add to layer
231
- layer.add(image);
232
-
233
- // Ensure white-ink is behind user-image
234
- const userImage = editor.getObject("user-image", "user");
235
- if (userImage) {
236
- // Re-adding moves it to the top of the stack
237
- layer.remove(userImage);
238
- layer.add(userImage);
239
- }
240
-
241
- // Apply clip path to user-image if enabled
242
- if (enableClip) {
243
- this.applyClipPath(editor, url);
244
- } else if (userImage) {
245
- userImage.set({ clipPath: undefined });
246
- }
247
-
248
- // Sync position immediately
249
- this.syncWithUserImage(editor);
250
-
251
- editor.canvas.requestRenderAll();
252
- }).catch(err => {
253
- console.error("Failed to load white ink mask", url, err);
254
- });
255
- }
256
-
257
- private applyClipPath(editor: Editor, url: string) {
258
- const userImage = editor.getObject("user-image", "user") as any;
259
- if (!userImage) return;
260
-
261
- Image.fromURL(url, { crossOrigin: 'anonymous' }).then(maskImage => {
262
- // Configure clipPath
263
- // It needs to be relative to the object center
264
- maskImage.set({
265
- originX: 'center',
266
- originY: 'center',
267
- left: 0,
268
- top: 0,
269
- // Scale to fit userImage if dimensions differ
270
- scaleX: userImage.width / maskImage.width,
271
- scaleY: userImage.height / maskImage.height
272
- });
273
-
274
- userImage.set({ clipPath: maskImage });
275
- editor.canvas.requestRenderAll();
276
- }).catch(err => {
277
- console.error("Failed to load clip path", url, err);
278
- });
279
- }
280
-
281
- private syncWithUserImage(editor: Editor) {
282
- const userImage = editor.getObject("user-image", "user");
283
- const whiteInk = editor.getObject("white-ink", "user");
284
-
285
- if (userImage && whiteInk) {
286
- whiteInk.set({
287
- left: userImage.left,
288
- top: userImage.top,
289
- scaleX: userImage.scaleX,
290
- scaleY: userImage.scaleY,
291
- angle: userImage.angle,
292
- skewX: userImage.skewX,
293
- skewY: userImage.skewY,
294
- flipX: userImage.flipX,
295
- flipY: userImage.flipY,
296
- originX: userImage.originX,
297
- originY: userImage.originY
298
- });
299
- }
300
- }
301
- }
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
+ }