@pooder/kit 2.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/film.ts CHANGED
@@ -1,66 +1,128 @@
1
1
  import {
2
- Command,
3
- Editor,
4
- EditorState,
5
2
  Extension,
6
- Image,
7
- OptionSchema,
8
- PooderLayer,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
9
7
  } from "@pooder/core";
8
+ import { FabricImage as Image } from "fabric";
9
+ import CanvasService from "./CanvasService";
10
10
 
11
- interface FilmToolOptions {
12
- url: string;
13
- opacity: number;
14
- }
11
+ export class FilmTool implements Extension {
12
+ id = "pooder.kit.film";
15
13
 
16
- export class FilmTool implements Extension<FilmToolOptions> {
17
- public name = "FilmTool";
18
- public options: FilmToolOptions = {
19
- url: "",
20
- opacity: 0.5,
14
+ public metadata = {
15
+ name: "FilmTool",
21
16
  };
22
17
 
23
- public schema: Record<keyof FilmToolOptions, OptionSchema> = {
24
- url: {
25
- type: "string",
26
- label: "Film Image URL",
27
- },
28
- opacity: {
29
- type: "number",
30
- min: 0,
31
- max: 1,
32
- step: 0.1,
33
- label: "Opacity",
34
- },
35
- };
18
+ private url: string = "";
19
+ private opacity: number = 0.5;
20
+
21
+ private canvasService?: CanvasService;
36
22
 
37
- onMount(editor: Editor) {
38
- this.initLayer(editor);
39
- this.updateFilm(editor, this.options);
23
+ constructor(
24
+ options?: Partial<{
25
+ url: string;
26
+ opacity: number;
27
+ }>,
28
+ ) {
29
+ if (options) {
30
+ Object.assign(this, options);
31
+ }
40
32
  }
41
33
 
42
- onUnmount(editor: Editor) {
43
- const layer = editor.getLayer("overlay");
44
- if (layer) {
45
- const img = editor.getObject("film-image", "overlay");
46
- if (img) {
47
- layer.remove(img);
48
- editor.canvas.requestRenderAll();
34
+ activate(context: ExtensionContext) {
35
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
36
+ if (!this.canvasService) {
37
+ console.warn("CanvasService not found for FilmTool");
38
+ return;
39
+ }
40
+
41
+ const configService = context.services.get<any>("ConfigurationService");
42
+ if (configService) {
43
+ // Load initial config
44
+ this.url = configService.get("film.url", this.url);
45
+ this.opacity = configService.get("film.opacity", this.opacity);
46
+
47
+ // Listen for changes
48
+ configService.onAnyChange((e: { key: string; value: any }) => {
49
+ if (e.key.startsWith("film.")) {
50
+ const prop = e.key.split(".")[1];
51
+ console.log(
52
+ `[FilmTool] Config change detected: ${e.key} -> ${e.value}`,
53
+ );
54
+ if (prop && prop in this) {
55
+ (this as any)[prop] = e.value;
56
+ this.updateFilm();
57
+ }
58
+ }
59
+ });
60
+ }
61
+
62
+ this.initLayer();
63
+ this.updateFilm();
64
+ }
65
+
66
+ deactivate(context: ExtensionContext) {
67
+ if (this.canvasService) {
68
+ const layer = this.canvasService.getLayer("overlay");
69
+ if (layer) {
70
+ const img = this.canvasService.getObject("film-image", "overlay");
71
+ if (img) {
72
+ layer.remove(img);
73
+ this.canvasService.requestRenderAll();
74
+ }
49
75
  }
76
+ this.canvasService = undefined;
50
77
  }
51
78
  }
52
79
 
53
- onUpdate(editor: Editor, state: EditorState) {
54
- this.updateFilm(editor, this.options);
80
+ contribute() {
81
+ return {
82
+ [ContributionPointIds.CONFIGURATIONS]: [
83
+ {
84
+ id: "film.url",
85
+ type: "string",
86
+ label: "Film Image URL",
87
+ default: "",
88
+ },
89
+ {
90
+ id: "film.opacity",
91
+ type: "number",
92
+ label: "Opacity",
93
+ min: 0,
94
+ max: 1,
95
+ step: 0.1,
96
+ default: 0.5,
97
+ },
98
+ ] as ConfigurationContribution[],
99
+ [ContributionPointIds.COMMANDS]: [
100
+ {
101
+ command: "setFilmImage",
102
+ title: "Set Film Image",
103
+ handler: (url: string, opacity: number) => {
104
+ if (this.url === url && this.opacity === opacity) return true;
105
+
106
+ this.url = url;
107
+ this.opacity = opacity;
108
+
109
+ this.updateFilm();
110
+
111
+ return true;
112
+ },
113
+ },
114
+ ] as CommandContribution[],
115
+ };
55
116
  }
56
117
 
57
- private initLayer(editor: Editor) {
58
- let overlayLayer = editor.getLayer("overlay");
118
+ private initLayer() {
119
+ if (!this.canvasService) return;
120
+ let overlayLayer = this.canvasService.getLayer("overlay");
59
121
  if (!overlayLayer) {
60
- const width = editor.state.width;
61
- const height = editor.state.height;
122
+ const width = this.canvasService.canvas.width || 800;
123
+ const height = this.canvasService.canvas.height || 600;
62
124
 
63
- const layer = new PooderLayer([], {
125
+ const layer = this.canvasService.createLayer("overlay", {
64
126
  width,
65
127
  height,
66
128
  left: 0,
@@ -71,38 +133,35 @@ export class FilmTool implements Extension<FilmToolOptions> {
71
133
  evented: false,
72
134
  subTargetCheck: false,
73
135
  interactive: false,
74
- data: {
75
- id: "overlay",
76
- },
77
136
  });
78
137
 
79
- editor.canvas.add(layer);
80
- editor.canvas.bringObjectToFront(layer);
138
+ this.canvasService.canvas.bringObjectToFront(layer);
81
139
  }
82
140
  }
83
141
 
84
- private async updateFilm(editor: Editor, options: FilmToolOptions) {
85
- const layer = editor.getLayer("overlay");
142
+ private async updateFilm() {
143
+ if (!this.canvasService) return;
144
+ const layer = this.canvasService.getLayer("overlay");
86
145
  if (!layer) {
87
146
  console.warn("[FilmTool] Overlay layer not found");
88
147
  return;
89
148
  }
90
149
 
91
- const { url, opacity } = options;
150
+ const { url, opacity } = this;
92
151
 
93
152
  if (!url) {
94
- const img = editor.getObject("film-image", "overlay");
153
+ const img = this.canvasService.getObject("film-image", "overlay");
95
154
  if (img) {
96
155
  layer.remove(img);
97
- editor.canvas.requestRenderAll();
156
+ this.canvasService.requestRenderAll();
98
157
  }
99
158
  return;
100
159
  }
101
160
 
102
- const width = editor.state.width;
103
- const height = editor.state.height;
161
+ const width = this.canvasService.canvas.width || 800;
162
+ const height = this.canvasService.canvas.height || 600;
104
163
 
105
- let img = editor.getObject("film-image", "overlay") as Image;
164
+ let img = this.canvasService.getObject("film-image", "overlay") as Image;
106
165
  try {
107
166
  if (img) {
108
167
  if (img.getSrc() !== url) {
@@ -125,39 +184,11 @@ export class FilmTool implements Extension<FilmToolOptions> {
125
184
  });
126
185
  layer.add(img);
127
186
  }
128
- editor.canvas.requestRenderAll();
187
+ this.canvasService.requestRenderAll();
129
188
  } catch (error) {
130
189
  console.error("[FilmTool] Failed to load film image", url, error);
131
190
  }
191
+ layer.dirty = true;
192
+ this.canvasService.requestRenderAll();
132
193
  }
133
-
134
- commands: Record<string, Command> = {
135
- setFilmImage: {
136
- execute: (editor: Editor, url: string, opacity: number) => {
137
- if (this.options.url === url && this.options.opacity === opacity)
138
- return true;
139
-
140
- this.options.url = url;
141
- this.options.opacity = opacity;
142
-
143
- this.updateFilm(editor, this.options);
144
-
145
- return true;
146
- },
147
- schema: {
148
- url: {
149
- type: "string",
150
- label: "Image URL",
151
- required: true,
152
- },
153
- opacity: {
154
- type: "number",
155
- label: "Opacity",
156
- min: 0,
157
- max: 1,
158
- required: true,
159
- },
160
- },
161
- },
162
- };
163
194
  }
package/src/geometry.ts CHANGED
@@ -8,13 +8,14 @@ export interface HoleData {
8
8
  }
9
9
 
10
10
  export interface GeometryOptions {
11
- shape: "rect" | "circle" | "ellipse";
11
+ shape: "rect" | "circle" | "ellipse" | "custom";
12
12
  width: number;
13
13
  height: number;
14
14
  radius: number;
15
15
  x: number;
16
16
  y: number;
17
17
  holes: Array<HoleData>;
18
+ pathData?: string;
18
19
  }
19
20
 
20
21
  export interface MaskGeometryOptions extends GeometryOptions {
@@ -28,14 +29,16 @@ export interface MaskGeometryOptions extends GeometryOptions {
28
29
  function ensurePaper(width: number, height: number) {
29
30
  if (!paper.project) {
30
31
  paper.setup(new paper.Size(width, height));
32
+ } else {
33
+ paper.view.viewSize = new paper.Size(width, height);
31
34
  }
32
35
  }
33
36
 
34
37
  /**
35
- * Creates the base dieline shape (Rect/Circle/Ellipse)
38
+ * Creates the base dieline shape (Rect/Circle/Ellipse/Custom)
36
39
  */
37
40
  function createBaseShape(options: GeometryOptions): paper.PathItem {
38
- const { shape, width, height, radius, x, y } = options;
41
+ const { shape, width, height, radius, x, y, pathData } = options;
39
42
  const center = new paper.Point(x, y);
40
43
 
41
44
  if (shape === "rect") {
@@ -50,13 +53,136 @@ function createBaseShape(options: GeometryOptions): paper.PathItem {
50
53
  center: center,
51
54
  radius: Math.max(0, r),
52
55
  });
53
- } else {
54
- // ellipse
56
+ } else if (shape === "ellipse") {
55
57
  return new paper.Path.Ellipse({
56
58
  center: center,
57
59
  radius: [Math.max(0, width / 2), Math.max(0, height / 2)],
58
60
  });
61
+ } else if (shape === "custom" && pathData) {
62
+ const path = new paper.Path();
63
+ path.pathData = pathData;
64
+ // Align center
65
+ path.position = center;
66
+ // Scale to match width/height if needed?
67
+ // For now, assume pathData is correct size, but we might want to support resizing.
68
+ // If width/height are provided and different from bounds, we could scale.
69
+ if (
70
+ width > 0 &&
71
+ height > 0 &&
72
+ path.bounds.width > 0 &&
73
+ path.bounds.height > 0
74
+ ) {
75
+ path.scale(width / path.bounds.width, height / path.bounds.height);
76
+ }
77
+ return path;
78
+ } else {
79
+ // Fallback
80
+ return new paper.Path.Rectangle({
81
+ point: [x - width / 2, y - height / 2],
82
+ size: [Math.max(0, width), Math.max(0, height)],
83
+ });
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Creates an offset version of the base shape.
89
+ * For Rect/Circle, we can just adjust params.
90
+ * For Custom shapes, we need a true offset algorithm (Paper.js doesn't have a robust one built-in for all cases,
91
+ * but we can simulate it or use a simple scaling if offset is small, OR rely on a library like Clipper.js.
92
+ * However, since we want to avoid heavy deps, let's try a simple approach:
93
+ * If it's a simple shape, we re-create it.
94
+ * If it's custom, we unfortunately have to scale it for now as a poor-man's offset,
95
+ * UNLESS we implement a stroke expansion.
96
+ *
97
+ * Stroke Expansion Trick:
98
+ * 1. Create path
99
+ * 2. Set strokeWidth = offset * 2
100
+ * 3. Convert stroke to path (paper.js has path.expand())
101
+ * 4. Union original + expanded (for positive offset) or Subtract (for negative).
102
+ */
103
+ function createOffsetShape(
104
+ options: GeometryOptions,
105
+ offset: number,
106
+ ): paper.PathItem {
107
+ const { shape, width, height, radius, x, y, pathData } = options;
108
+ const center = new paper.Point(x, y);
109
+
110
+ if (shape === "rect" || shape === "circle" || shape === "ellipse") {
111
+ // For standard shapes, we can just adjust the dimensions
112
+ const offsetOptions = {
113
+ ...options,
114
+ width: Math.max(0, width + offset * 2),
115
+ height: Math.max(0, height + offset * 2),
116
+ radius: radius === 0 ? 0 : Math.max(0, radius + offset),
117
+ };
118
+ return createBaseShape(offsetOptions);
119
+ } else if (shape === "custom" && pathData) {
120
+ const original = createBaseShape(options);
121
+ if (offset === 0) return original;
122
+
123
+ // Use Stroke Expansion for Offset
124
+ // Create a copy for stroking
125
+ const stroker = original.clone() as paper.Path;
126
+ stroker.strokeColor = new paper.Color("black");
127
+ stroker.strokeWidth = Math.abs(offset) * 2;
128
+ // Round join usually looks better for offsets
129
+ stroker.strokeJoin = "round";
130
+ stroker.strokeCap = "round";
131
+
132
+ // Expand stroke to path
133
+ // @ts-ignore - paper.js types might be missing expand depending on version, but it exists in recent versions
134
+ // If expand is not available, we might fallback to scaling.
135
+ // Assuming modern paper.js
136
+ let expanded: paper.Item;
137
+ try {
138
+ // @ts-ignore
139
+ expanded = stroker.expand({ stroke: true, fill: false, insert: false });
140
+ } catch (e) {
141
+ // Fallback if expand fails or not present
142
+ stroker.remove();
143
+ // Fallback to scaling (imperfect)
144
+ const scaleX =
145
+ (original.bounds.width + offset * 2) / original.bounds.width;
146
+ const scaleY =
147
+ (original.bounds.height + offset * 2) / original.bounds.height;
148
+ original.scale(scaleX, scaleY);
149
+ return original;
150
+ }
151
+
152
+ stroker.remove();
153
+
154
+ // The expanded stroke is a "ring".
155
+ // For positive offset: Union(Original, Ring)
156
+ // For negative offset: Subtract(Original, Ring) ? No, that makes a hole.
157
+ // For negative offset: We want the "inner" boundary of the ring.
158
+
159
+ // Actually, expand() returns a Group or Path.
160
+ // If it's a closed path, the ring has an outer and inner boundary.
161
+
162
+ let result: paper.PathItem;
163
+
164
+ if (offset > 0) {
165
+ // @ts-ignore
166
+ result = original.unite(expanded);
167
+ } else {
168
+ // For negative offset (shrink), we want the original MINUS the stroke?
169
+ // No, the stroke is centered on the line.
170
+ // So the inner edge of the stroke is at -offset.
171
+ // We want the area INSIDE the inner edge.
172
+ // That is Original SUBTRACT the Ring?
173
+ // Yes, if we subtract the ring, we lose the border area.
174
+ // @ts-ignore
175
+ result = original.subtract(expanded);
176
+ }
177
+
178
+ // Cleanup
179
+ original.remove();
180
+ expanded.remove();
181
+
182
+ return result;
59
183
  }
184
+
185
+ return createBaseShape(options);
60
186
  }
61
187
 
62
188
  /**
@@ -80,13 +206,9 @@ function getDielineShape(options: GeometryOptions): paper.PathItem {
80
206
  radius: hole.outerRadius,
81
207
  });
82
208
 
83
- // Check intersection with main body
84
- // Only add lug if it intersects (or is contained in) the main shape
85
- // This prevents floating islands when bleed shrinks
86
- if (!mainShape.intersects(lug) && !mainShape.contains(lug.position)) {
87
- lug.remove();
88
- return; // Skip this lug
89
- }
209
+ // REMOVED: Intersects check. We want to process all holes defined in config.
210
+ // If a hole is completely outside, it might form an island, but that's better than missing it.
211
+ // Users can remove the hole if they don't want it.
90
212
 
91
213
  // Create Cut (Inner Radius)
92
214
  const cut = new paper.Path.Circle({
@@ -98,39 +220,58 @@ function getDielineShape(options: GeometryOptions): paper.PathItem {
98
220
  if (!lugsPath) {
99
221
  lugsPath = lug;
100
222
  } else {
101
- const temp = lugsPath.unite(lug);
102
- lugsPath.remove();
103
- lug.remove();
104
- lugsPath = temp;
223
+ try {
224
+ const temp = lugsPath.unite(lug);
225
+ lugsPath.remove();
226
+ lug.remove();
227
+ lugsPath = temp;
228
+ } catch (e) {
229
+ console.error("Geometry: Failed to unite lug", e);
230
+ // Keep previous lugsPath, ignore this one to prevent crash
231
+ lug.remove();
232
+ }
105
233
  }
106
234
 
107
235
  // Union Cuts
108
236
  if (!cutsPath) {
109
237
  cutsPath = cut;
110
238
  } else {
111
- const temp = cutsPath.unite(cut);
112
- cutsPath.remove();
113
- cut.remove();
114
- cutsPath = temp;
239
+ try {
240
+ const temp = cutsPath.unite(cut);
241
+ cutsPath.remove();
242
+ cut.remove();
243
+ cutsPath = temp;
244
+ } catch (e) {
245
+ console.error("Geometry: Failed to unite cut", e);
246
+ cut.remove();
247
+ }
115
248
  }
116
249
  });
117
250
 
118
251
  // 2. Add Lugs to Main Shape (Union) - Additive Fusion
119
252
  if (lugsPath) {
120
- const temp = mainShape.unite(lugsPath);
121
- mainShape.remove();
122
- // @ts-ignore
123
- lugsPath.remove();
124
- mainShape = temp;
253
+ try {
254
+ const temp = mainShape.unite(lugsPath);
255
+ mainShape.remove();
256
+ // @ts-ignore
257
+ lugsPath.remove();
258
+ mainShape = temp;
259
+ } catch (e) {
260
+ console.error("Geometry: Failed to unite lugsPath to mainShape", e);
261
+ }
125
262
  }
126
263
 
127
264
  // 3. Subtract Cuts from Main Shape (Difference)
128
265
  if (cutsPath) {
129
- const temp = mainShape.subtract(cutsPath);
130
- mainShape.remove();
131
- // @ts-ignore
132
- cutsPath.remove();
133
- mainShape = temp;
266
+ try {
267
+ const temp = mainShape.subtract(cutsPath);
268
+ mainShape.remove();
269
+ // @ts-ignore
270
+ cutsPath.remove();
271
+ mainShape = temp;
272
+ } catch (e) {
273
+ console.error("Geometry: Failed to subtract cutsPath from mainShape", e);
274
+ }
134
275
  }
135
276
  }
136
277
 
@@ -200,15 +341,76 @@ export function generateBleedZonePath(
200
341
  const shapeOriginal = getDielineShape(options);
201
342
 
202
343
  // 2. Offset Shape
203
- // Adjust dimensions for offset
204
- const offsetOptions: GeometryOptions = {
205
- ...options,
206
- width: Math.max(0, options.width + offset * 2),
207
- height: Math.max(0, options.height + offset * 2),
208
- radius: options.radius === 0 ? 0 : Math.max(0, options.radius + offset),
209
- };
344
+ // We use createOffsetShape for more accurate offset (especially for custom shapes)
345
+ // But we still need to respect holes if they exist.
346
+ // getDielineShape handles holes.
347
+ // The issue is: do holes shrink/expand with bleed?
348
+ // Usually, bleed is only for the outer cut. Holes are internal cuts.
349
+ // Internal cuts usually also have bleed if they are die-cut, but maybe different direction?
350
+ // For simplicity, let's assume we offset the FINAL shape (including holes).
351
+
352
+ // Actually, getDielineShape calls createBaseShape.
353
+ // Let's modify generateBleedZonePath to use createOffsetShape logic if possible,
354
+ // OR just perform offset on the final shape result.
355
+
356
+ // The previous logic was: create base shape with adjusted width/height/radius.
357
+ // This works for Rect/Circle.
358
+ // For Custom, we need createOffsetShape.
359
+
360
+ let shapeOffset: paper.PathItem;
361
+
362
+ if (options.shape === "custom") {
363
+ // For custom shape, we offset the base shape first, then apply holes?
364
+ // Or offset the final result?
365
+ // Bleed is usually "outside" the cut line.
366
+ // If we have a donut, bleed is outside the outer circle AND inside the inner circle?
367
+ // Or just outside the outer?
368
+ // Let's assume bleed expands the solid area.
369
+
370
+ // So we take the final shape (Original) and expand it.
371
+ // We can use the same Stroke Expansion trick on the final shape.
372
+
373
+ // Since shapeOriginal is already the final shape (Base - Holes),
374
+ // we can try to offset it directly.
375
+
376
+ const stroker = shapeOriginal.clone() as paper.Path;
377
+ stroker.strokeColor = new paper.Color("black");
378
+ stroker.strokeWidth = Math.abs(offset) * 2;
379
+ stroker.strokeJoin = "round";
380
+ stroker.strokeCap = "round";
381
+
382
+ let expanded: paper.Item;
383
+ try {
384
+ // @ts-ignore
385
+ expanded = stroker.expand({ stroke: true, fill: false, insert: false });
386
+ } catch (e) {
387
+ // Fallback
388
+ stroker.remove();
389
+ shapeOffset = shapeOriginal.clone();
390
+ // scaling fallback...
391
+ return shapeOffset.pathData; // Fail gracefully
392
+ }
393
+ stroker.remove();
210
394
 
211
- const shapeOffset = getDielineShape(offsetOptions);
395
+ if (offset > 0) {
396
+ // @ts-ignore
397
+ shapeOffset = shapeOriginal.unite(expanded);
398
+ } else {
399
+ // @ts-ignore
400
+ shapeOffset = shapeOriginal.subtract(expanded);
401
+ }
402
+ expanded.remove();
403
+ } else {
404
+ // Legacy logic for standard shapes (still valid and fast)
405
+ // Adjust dimensions for offset
406
+ const offsetOptions: GeometryOptions = {
407
+ ...options,
408
+ width: Math.max(0, options.width + offset * 2),
409
+ height: Math.max(0, options.height + offset * 2),
410
+ radius: options.radius === 0 ? 0 : Math.max(0, options.radius + offset),
411
+ };
412
+ shapeOffset = getDielineShape(offsetOptions);
413
+ }
212
414
 
213
415
  // 3. Calculate Difference
214
416
  let bleedZone: paper.PathItem;
@@ -249,3 +451,14 @@ export function getNearestPointOnDieline(
249
451
 
250
452
  return result;
251
453
  }
454
+
455
+ export function getPathBounds(pathData: string): {
456
+ width: number;
457
+ height: number;
458
+ } {
459
+ const path = new paper.Path();
460
+ path.pathData = pathData;
461
+ const bounds = path.bounds;
462
+ path.remove();
463
+ return { width: bounds.width, height: bounds.height };
464
+ }