@pooder/kit 0.0.2

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