@pooder/kit 6.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,11 @@ import {
10
10
  } from "@pooder/core";
11
11
  import {
12
12
  Canvas as FabricCanvas,
13
+ Control,
13
14
  Image as FabricImage,
14
15
  Pattern,
15
16
  Point,
17
+ controlsUtils,
16
18
  } from "fabric";
17
19
  import { CanvasService, RenderLayoutRect, RenderObjectSpec } from "../services";
18
20
  import { isDielineShape, normalizeShapeStyle } from "./dielineShape";
@@ -66,6 +68,18 @@ interface FrameVisualConfig {
66
68
  outerBackground: string;
67
69
  }
68
70
 
71
+ interface ImageControlVisualConfig {
72
+ cornerSize: number;
73
+ touchCornerSize: number;
74
+ cornerStyle: "rect" | "circle";
75
+ cornerColor: string;
76
+ cornerStrokeColor: string;
77
+ transparentCorners: boolean;
78
+ borderColor: string;
79
+ borderScaleFactor: number;
80
+ padding: number;
81
+ }
82
+
69
83
  type ShapeOverlayShape = Exclude<DielineShape, "custom">;
70
84
 
71
85
  interface SceneGeometryLike {
@@ -113,6 +127,45 @@ interface ExportUserCroppedImageResult {
113
127
 
114
128
  const IMAGE_OBJECT_LAYER_ID = "image.user";
115
129
  const IMAGE_OVERLAY_LAYER_ID = "image-overlay";
130
+ type ImageControlCapability = "rotate" | "scale" | "flipX" | "flipY";
131
+
132
+ interface ImageControlDescriptor {
133
+ key: string;
134
+ capability: ImageControlCapability;
135
+ create: () => Control;
136
+ }
137
+
138
+ const IMAGE_DEFAULT_CONTROL_CAPABILITIES: ImageControlCapability[] = [
139
+ "rotate",
140
+ "scale",
141
+ ];
142
+
143
+ const IMAGE_CONTROL_DESCRIPTORS: ImageControlDescriptor[] = [
144
+ {
145
+ key: "tl",
146
+ capability: "rotate",
147
+ create: () =>
148
+ new Control({
149
+ x: -0.5,
150
+ y: -0.5,
151
+ actionName: "rotate",
152
+ actionHandler: controlsUtils.rotationWithSnapping,
153
+ cursorStyleHandler: controlsUtils.rotationStyleHandler,
154
+ }),
155
+ },
156
+ {
157
+ key: "br",
158
+ capability: "scale",
159
+ create: () =>
160
+ new Control({
161
+ x: 0.5,
162
+ y: 0.5,
163
+ actionName: "scale",
164
+ actionHandler: controlsUtils.scalingEqually,
165
+ cursorStyleHandler: controlsUtils.scaleCursorStyleHandler,
166
+ }),
167
+ },
168
+ ];
116
169
 
117
170
  export class ImageTool implements Extension {
118
171
  id = "pooder.kit.image";
@@ -140,6 +193,8 @@ export class ImageTool implements Extension {
140
193
  private imageSpecs: RenderObjectSpec[] = [];
141
194
  private overlaySpecs: RenderObjectSpec[] = [];
142
195
  private renderProducerDisposable?: { dispose: () => void };
196
+ private imageControlsByCapabilityKey: Map<string, Record<string, Control>> =
197
+ new Map();
143
198
 
144
199
  activate(context: ExtensionContext) {
145
200
  this.context = context;
@@ -215,7 +270,14 @@ export class ImageTool implements Extension {
215
270
  return;
216
271
  }
217
272
 
218
- if (e.key.startsWith("size.") || e.key.startsWith("image.frame.")) {
273
+ if (
274
+ e.key.startsWith("size.") ||
275
+ e.key.startsWith("image.frame.") ||
276
+ e.key.startsWith("image.control.")
277
+ ) {
278
+ if (e.key.startsWith("image.control.")) {
279
+ this.imageControlsByCapabilityKey.clear();
280
+ }
219
281
  this.updateImages();
220
282
  }
221
283
  });
@@ -246,6 +308,7 @@ export class ImageTool implements Extension {
246
308
  this.cropShapeHatchPatternKey = undefined;
247
309
  this.imageSpecs = [];
248
310
  this.overlaySpecs = [];
311
+ this.imageControlsByCapabilityKey.clear();
249
312
 
250
313
  this.clearRenderedImages();
251
314
  this.renderProducerDisposable?.dispose();
@@ -346,6 +409,113 @@ export class ImageTool implements Extension {
346
409
  );
347
410
  }
348
411
 
412
+ private getEnabledImageControlCapabilities(): ImageControlCapability[] {
413
+ return IMAGE_DEFAULT_CONTROL_CAPABILITIES;
414
+ }
415
+
416
+ private getImageControls(
417
+ capabilities: ImageControlCapability[],
418
+ ): Record<string, Control> {
419
+ const normalized = [...new Set(capabilities)].sort();
420
+ const cacheKey = normalized.join("|");
421
+ const cached = this.imageControlsByCapabilityKey.get(cacheKey);
422
+ if (cached) {
423
+ return cached;
424
+ }
425
+
426
+ const enabled = new Set(normalized);
427
+ const controls: Record<string, Control> = {};
428
+ IMAGE_CONTROL_DESCRIPTORS.forEach((descriptor) => {
429
+ if (!enabled.has(descriptor.capability)) return;
430
+ controls[descriptor.key] = descriptor.create();
431
+ });
432
+
433
+ this.imageControlsByCapabilityKey.set(cacheKey, controls);
434
+ return controls;
435
+ }
436
+
437
+ private getImageControlVisualConfig(): ImageControlVisualConfig {
438
+ const cornerSizeRaw = Number(
439
+ this.getConfig<number>("image.control.cornerSize", 14) ?? 14,
440
+ );
441
+ const touchCornerSizeRaw = Number(
442
+ this.getConfig<number>("image.control.touchCornerSize", 24) ?? 24,
443
+ );
444
+ const borderScaleFactorRaw = Number(
445
+ this.getConfig<number>("image.control.borderScaleFactor", 1.5) ?? 1.5,
446
+ );
447
+ const paddingRaw = Number(
448
+ this.getConfig<number>("image.control.padding", 0) ?? 0,
449
+ );
450
+ const cornerStyleRaw = (this.getConfig<string>(
451
+ "image.control.cornerStyle",
452
+ "circle",
453
+ ) || "circle") as string;
454
+ const cornerStyle: "rect" | "circle" =
455
+ cornerStyleRaw === "rect" ? "rect" : "circle";
456
+
457
+ return {
458
+ cornerSize: Number.isFinite(cornerSizeRaw)
459
+ ? Math.max(4, Math.min(64, cornerSizeRaw))
460
+ : 14,
461
+ touchCornerSize: Number.isFinite(touchCornerSizeRaw)
462
+ ? Math.max(8, Math.min(96, touchCornerSizeRaw))
463
+ : 24,
464
+ cornerStyle,
465
+ cornerColor:
466
+ this.getConfig<string>("image.control.cornerColor", "#ffffff") ||
467
+ "#ffffff",
468
+ cornerStrokeColor:
469
+ this.getConfig<string>("image.control.cornerStrokeColor", "#1677ff") ||
470
+ "#1677ff",
471
+ transparentCorners: !!this.getConfig<boolean>(
472
+ "image.control.transparentCorners",
473
+ false,
474
+ ),
475
+ borderColor:
476
+ this.getConfig<string>("image.control.borderColor", "#1677ff") ||
477
+ "#1677ff",
478
+ borderScaleFactor: Number.isFinite(borderScaleFactorRaw)
479
+ ? Math.max(0.5, Math.min(8, borderScaleFactorRaw))
480
+ : 1.5,
481
+ padding: Number.isFinite(paddingRaw)
482
+ ? Math.max(0, Math.min(64, paddingRaw))
483
+ : 0,
484
+ };
485
+ }
486
+
487
+ private applyImageObjectInteractionState(obj: any) {
488
+ if (!obj) return;
489
+ const visible = this.isImageEditingVisible();
490
+ const visual = this.getImageControlVisualConfig();
491
+ obj.set({
492
+ selectable: visible,
493
+ evented: visible,
494
+ hasControls: visible,
495
+ hasBorders: visible,
496
+ lockScalingFlip: true,
497
+ cornerSize: visual.cornerSize,
498
+ touchCornerSize: visual.touchCornerSize,
499
+ cornerStyle: visual.cornerStyle,
500
+ cornerColor: visual.cornerColor,
501
+ cornerStrokeColor: visual.cornerStrokeColor,
502
+ transparentCorners: visual.transparentCorners,
503
+ borderColor: visual.borderColor,
504
+ borderScaleFactor: visual.borderScaleFactor,
505
+ padding: visual.padding,
506
+ });
507
+ obj.controls = this.getImageControls(
508
+ this.getEnabledImageControlCapabilities(),
509
+ );
510
+ obj.setCoords?.();
511
+ }
512
+
513
+ private refreshImageObjectInteractionState() {
514
+ this.getImageObjects().forEach((obj) =>
515
+ this.applyImageObjectInteractionState(obj),
516
+ );
517
+ }
518
+
349
519
  private isDebugEnabled(): boolean {
350
520
  return !!this.getConfig<boolean>("image.debug", false);
351
521
  }
@@ -390,6 +560,73 @@ export class ImageTool implements Extension {
390
560
  label: "Image Debug Log",
391
561
  default: false,
392
562
  },
563
+ {
564
+ id: "image.control.cornerSize",
565
+ type: "number",
566
+ label: "Image Control Corner Size",
567
+ min: 4,
568
+ max: 64,
569
+ step: 1,
570
+ default: 14,
571
+ },
572
+ {
573
+ id: "image.control.touchCornerSize",
574
+ type: "number",
575
+ label: "Image Control Touch Corner Size",
576
+ min: 8,
577
+ max: 96,
578
+ step: 1,
579
+ default: 24,
580
+ },
581
+ {
582
+ id: "image.control.cornerStyle",
583
+ type: "select",
584
+ label: "Image Control Corner Style",
585
+ options: ["circle", "rect"],
586
+ default: "circle",
587
+ },
588
+ {
589
+ id: "image.control.cornerColor",
590
+ type: "color",
591
+ label: "Image Control Corner Color",
592
+ default: "#ffffff",
593
+ },
594
+ {
595
+ id: "image.control.cornerStrokeColor",
596
+ type: "color",
597
+ label: "Image Control Corner Stroke Color",
598
+ default: "#1677ff",
599
+ },
600
+ {
601
+ id: "image.control.transparentCorners",
602
+ type: "boolean",
603
+ label: "Image Control Transparent Corners",
604
+ default: false,
605
+ },
606
+ {
607
+ id: "image.control.borderColor",
608
+ type: "color",
609
+ label: "Image Control Border Color",
610
+ default: "#1677ff",
611
+ },
612
+ {
613
+ id: "image.control.borderScaleFactor",
614
+ type: "number",
615
+ label: "Image Control Border Width",
616
+ min: 0.5,
617
+ max: 8,
618
+ step: 0.1,
619
+ default: 1.5,
620
+ },
621
+ {
622
+ id: "image.control.padding",
623
+ type: "number",
624
+ label: "Image Control Padding",
625
+ min: 0,
626
+ max: 64,
627
+ step: 1,
628
+ default: 0,
629
+ },
393
630
  {
394
631
  id: "image.frame.strokeColor",
395
632
  type: "color",
@@ -664,12 +901,7 @@ export class ImageTool implements Extension {
664
901
  } else {
665
902
  const obj = this.getImageObject(id);
666
903
  if (obj) {
667
- obj.set({
668
- selectable: true,
669
- evented: true,
670
- hasControls: true,
671
- hasBorders: true,
672
- });
904
+ this.applyImageObjectInteractionState(obj);
673
905
  canvas.setActiveObject(obj);
674
906
  }
675
907
  }
@@ -1608,6 +1840,7 @@ export class ImageTool implements Extension {
1608
1840
  this.overlaySpecs = this.buildOverlaySpecs(frame, sceneGeometry);
1609
1841
  await this.canvasService.flushRenderFromProducers();
1610
1842
  if (seq !== this.renderSeq) return;
1843
+ this.refreshImageObjectInteractionState();
1611
1844
 
1612
1845
  renderItems.forEach((item) => {
1613
1846
  if (!this.getImageObject(item.id)) return;
@@ -60,6 +60,7 @@ interface ResolvedRenderPassSpec {
60
60
  interface ResolvedClipPathEffectSpec {
61
61
  type: "clipPath";
62
62
  key: string;
63
+ visibility?: RenderPassSpec["visibility"];
63
64
  source: RenderObjectSpec;
64
65
  targetPassIds: string[];
65
66
  }
@@ -89,6 +90,7 @@ export default class CanvasService implements Service {
89
90
 
90
91
  private managedProducerPassIds: Set<string> = new Set();
91
92
  private managedPassMetas: Map<string, ManagedPassMeta> = new Map();
93
+ private managedPassEffects: ResolvedClipPathEffectSpec[] = [];
92
94
 
93
95
  private canvasForwardersBound = false;
94
96
  private readonly forwardSelectionCreated = (e: any) => {
@@ -112,9 +114,11 @@ export default class CanvasService implements Service {
112
114
 
113
115
  private readonly onToolActivated = () => {
114
116
  this.applyManagedPassVisibility();
117
+ void this.applyManagedPassEffects(undefined, { render: true });
115
118
  };
116
119
  private readonly onToolSessionChanged = () => {
117
120
  this.applyManagedPassVisibility();
121
+ void this.applyManagedPassEffects(undefined, { render: true });
118
122
  };
119
123
  private readonly onCanvasObjectChanged = () => {
120
124
  if (this.producerApplyInProgress) return;
@@ -190,6 +194,7 @@ export default class CanvasService implements Service {
190
194
  this.renderProducers.clear();
191
195
  this.managedProducerPassIds.clear();
192
196
  this.managedPassMetas.clear();
197
+ this.managedPassEffects = [];
193
198
  this.context = undefined;
194
199
  this.workbenchService = undefined;
195
200
  this.toolSessionService = undefined;
@@ -332,6 +337,7 @@ export default class CanvasService implements Service {
332
337
  return {
333
338
  type: "clipPath",
334
339
  key,
340
+ visibility: effect.visibility,
335
341
  source: {
336
342
  ...source,
337
343
  id: sourceId,
@@ -466,23 +472,35 @@ export default class CanvasService implements Service {
466
472
  return state;
467
473
  }
468
474
 
475
+ private isSessionActive(toolId: string): boolean {
476
+ if (!this.toolSessionService) return false;
477
+ return this.toolSessionService.getState(toolId).status === "active";
478
+ }
479
+
480
+ private hasAnyActiveSession(): boolean {
481
+ return this.toolSessionService?.hasAnyActiveSession() ?? false;
482
+ }
483
+
484
+ private buildVisibilityEvalContext(
485
+ layers: Map<string, VisibilityLayerState>,
486
+ ) {
487
+ return {
488
+ activeToolId: this.workbenchService?.activeToolId ?? null,
489
+ isSessionActive: (toolId: string) => this.isSessionActive(toolId),
490
+ hasAnyActiveSession: () => this.hasAnyActiveSession(),
491
+ layers,
492
+ };
493
+ }
494
+
469
495
  private applyManagedPassVisibility(options: { render?: boolean } = {}): boolean {
470
496
  if (!this.managedPassMetas.size) return false;
471
497
  const layers = this.getPassRuntimeState();
472
- const activeToolId = this.workbenchService?.activeToolId ?? null;
473
- const isSessionActive = (toolId: string) => {
474
- if (!this.toolSessionService) return false;
475
- return this.toolSessionService.getState(toolId).status === "active";
476
- };
498
+ const context = this.buildVisibilityEvalContext(layers);
477
499
 
478
500
  let changed = false;
479
501
 
480
502
  this.managedPassMetas.forEach((meta) => {
481
- const visible = evaluateVisibilityExpr(meta.visibility, {
482
- activeToolId,
483
- isSessionActive,
484
- layers,
485
- });
503
+ const visible = evaluateVisibilityExpr(meta.visibility, context);
486
504
  changed = this.setPassVisibility(meta.id, visible) || changed;
487
505
  });
488
506
 
@@ -560,9 +578,10 @@ export default class CanvasService implements Service {
560
578
 
561
579
  this.managedProducerPassIds = nextPassIds;
562
580
  this.managedPassMetas = nextManagedPassMetas;
581
+ this.managedPassEffects = nextEffects;
563
582
 
564
583
  this.syncManagedPassStacking(Array.from(nextManagedPassMetas.values()));
565
- await this.applyManagedPassEffects(nextEffects);
584
+ await this.applyManagedPassEffects(nextEffects, { render: false });
566
585
  this.applyManagedPassVisibility({ render: false });
567
586
  } finally {
568
587
  this.producerApplyInProgress = false;
@@ -571,11 +590,19 @@ export default class CanvasService implements Service {
571
590
  this.requestRenderAll();
572
591
  }
573
592
 
574
- private async applyManagedPassEffects(effects: ResolvedClipPathEffectSpec[]) {
593
+ private async applyManagedPassEffects(
594
+ effects: ResolvedClipPathEffectSpec[] = this.managedPassEffects,
595
+ options: { render?: boolean } = {},
596
+ ) {
575
597
  const effectTargetMap = new Map<FabricObject, ResolvedClipPathEffectSpec>();
598
+ const layers = this.getPassRuntimeState();
599
+ const visibilityContext = this.buildVisibilityEvalContext(layers);
576
600
 
577
601
  for (const effect of effects) {
578
602
  if (effect.type !== "clipPath") continue;
603
+ if (!evaluateVisibilityExpr(effect.visibility, visibilityContext)) {
604
+ continue;
605
+ }
579
606
  effect.targetPassIds.forEach((targetPassId) => {
580
607
  this.getPassCanvasObjects(targetPassId).forEach((obj) => {
581
608
  effectTargetMap.set(obj, effect);
@@ -613,6 +640,10 @@ export default class CanvasService implements Service {
613
640
  targetEffect.key,
614
641
  );
615
642
  }
643
+
644
+ if (options.render !== false) {
645
+ this.requestRenderAll();
646
+ }
616
647
  }
617
648
 
618
649
  getObject(id: string, passId?: string): FabricObject | undefined {
@@ -52,6 +52,7 @@ export type VisibilityExpr =
52
52
  | { op: "const"; value: boolean }
53
53
  | { op: "activeToolIn"; ids: string[] }
54
54
  | { op: "sessionActive"; toolId: string }
55
+ | { op: "anySessionActive" }
55
56
  | { op: "layerExists"; layerId: string }
56
57
  | {
57
58
  op: "layerObjectCount";
@@ -66,6 +67,7 @@ export type VisibilityExpr =
66
67
  export interface RenderClipPathEffectSpec {
67
68
  type: "clipPath";
68
69
  id?: string;
70
+ visibility?: VisibilityExpr;
69
71
  source: RenderObjectSpec;
70
72
  targetPassIds: string[];
71
73
  }
@@ -8,6 +8,7 @@ export interface VisibilityLayerState {
8
8
  export interface VisibilityEvalContext {
9
9
  activeToolId?: string | null;
10
10
  isSessionActive?: (toolId: string) => boolean;
11
+ hasAnyActiveSession?: () => boolean;
11
12
  layers: Map<string, VisibilityLayerState>;
12
13
  }
13
14
 
@@ -51,6 +52,10 @@ export function evaluateVisibilityExpr(
51
52
  return context.isSessionActive ? context.isSessionActive(toolId) : false;
52
53
  }
53
54
 
55
+ if (expr.op === "anySessionActive") {
56
+ return context.hasAnyActiveSession ? context.hasAnyActiveSession() : false;
57
+ }
58
+
54
59
  if (expr.op === "layerExists") {
55
60
  return layerState(context, expr.layerId).exists === true;
56
61
  }
package/tests/run.ts CHANGED
@@ -123,6 +123,7 @@ function testVisibilityDsl() {
123
123
  const context = {
124
124
  activeToolId: "pooder.kit.image",
125
125
  isSessionActive: (toolId: string) => toolId === "pooder.kit.feature",
126
+ hasAnyActiveSession: () => true,
126
127
  layers,
127
128
  };
128
129
 
@@ -162,6 +163,10 @@ function testVisibilityDsl() {
162
163
  ) === false,
163
164
  "sessionActive false failed",
164
165
  );
166
+ assert(
167
+ evaluateVisibilityExpr({ op: "anySessionActive" }, context) === true,
168
+ "anySessionActive true failed",
169
+ );
165
170
  assert(
166
171
  evaluateVisibilityExpr(
167
172
  { op: "layerExists", layerId: "ruler-overlay" },