@pooder/kit 5.0.2 → 5.0.4

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/feature.ts CHANGED
@@ -34,6 +34,8 @@ export class FeatureTool implements Extension {
34
34
  private context?: ExtensionContext;
35
35
  private isUpdatingConfig = false;
36
36
  private isToolActive = false;
37
+ private isFeatureSessionActive = false;
38
+ private sessionOriginalFeatures: ConstraintFeature[] | null = null;
37
39
  private hasWorkingChanges = false;
38
40
  private dirtyTrackerDisposable?: { dispose(): void };
39
41
 
@@ -76,6 +78,7 @@ export class FeatureTool implements Extension {
76
78
  if (this.isUpdatingConfig) return;
77
79
 
78
80
  if (e.key === "dieline.features") {
81
+ if (this.isFeatureSessionActive) return;
79
82
  const next = (e.value || []) as ConstraintFeature[];
80
83
  this.workingFeatures = this.cloneFeatures(next);
81
84
  this.hasWorkingChanges = false;
@@ -100,6 +103,7 @@ export class FeatureTool implements Extension {
100
103
 
101
104
  deactivate(context: ExtensionContext) {
102
105
  context.eventBus.off("tool:activated", this.onToolActivated);
106
+ this.restoreSessionFeaturesToConfig();
103
107
  this.dirtyTrackerDisposable?.dispose();
104
108
  this.dirtyTrackerDisposable = undefined;
105
109
  this.teardown();
@@ -107,8 +111,11 @@ export class FeatureTool implements Extension {
107
111
  this.context = undefined;
108
112
  }
109
113
 
110
- private onToolActivated = (event: { id: string }) => {
114
+ private onToolActivated = (event: { id: string | null }) => {
111
115
  this.isToolActive = event.id === this.id;
116
+ if (!this.isToolActive) {
117
+ this.restoreSessionFeaturesToConfig();
118
+ }
112
119
  this.updateVisibility();
113
120
  };
114
121
 
@@ -140,9 +147,9 @@ export class FeatureTool implements Extension {
140
147
  name: "Feature",
141
148
  interaction: "session",
142
149
  commands: {
143
- begin: "resetWorkingFeatures",
150
+ begin: "beginFeatureSession",
144
151
  commit: "completeFeatures",
145
- rollback: "resetWorkingFeatures",
152
+ rollback: "rollbackFeatureSession",
146
153
  },
147
154
  session: {
148
155
  autoBegin: false,
@@ -151,6 +158,25 @@ export class FeatureTool implements Extension {
151
158
  },
152
159
  ],
153
160
  [ContributionPointIds.COMMANDS]: [
161
+ {
162
+ command: "beginFeatureSession",
163
+ title: "Begin Feature Session",
164
+ handler: async () => {
165
+ if (this.isFeatureSessionActive) {
166
+ return { ok: true };
167
+ }
168
+ const original = this.getCommittedFeatures();
169
+ this.sessionOriginalFeatures = this.cloneFeatures(original);
170
+ this.isFeatureSessionActive = true;
171
+ await this.refreshGeometry();
172
+ this.setWorkingFeatures(this.cloneFeatures(original));
173
+ this.hasWorkingChanges = false;
174
+ this.redraw();
175
+ this.emitWorkingChange();
176
+ this.updateCommittedFeatures([]);
177
+ return { ok: true };
178
+ },
179
+ },
154
180
  {
155
181
  command: "addFeature",
156
182
  title: "Add Edge Feature",
@@ -203,21 +229,27 @@ export class FeatureTool implements Extension {
203
229
  },
204
230
  },
205
231
  {
206
- command: "resetWorkingFeatures",
207
- title: "Reset Working Features",
232
+ command: "rollbackFeatureSession",
233
+ title: "Rollback Feature Session",
208
234
  handler: async () => {
209
- const configService =
210
- this.context?.services.get<ConfigurationService>(
211
- "ConfigurationService",
212
- );
213
- const next = (configService?.get("dieline.features", []) ||
214
- []) as ConstraintFeature[];
215
-
235
+ const original = this.cloneFeatures(
236
+ this.sessionOriginalFeatures || this.getCommittedFeatures(),
237
+ );
216
238
  await this.refreshGeometry();
217
- this.setWorkingFeatures(this.cloneFeatures(next));
239
+ this.setWorkingFeatures(original);
218
240
  this.hasWorkingChanges = false;
219
241
  this.redraw();
220
242
  this.emitWorkingChange();
243
+ this.updateCommittedFeatures(original);
244
+ this.clearFeatureSessionState();
245
+ return { ok: true };
246
+ },
247
+ },
248
+ {
249
+ command: "resetWorkingFeatures",
250
+ title: "Reset Working Features",
251
+ handler: async () => {
252
+ await this.resetWorkingFeaturesFromSource();
221
253
  return { ok: true };
222
254
  },
223
255
  },
@@ -243,6 +275,42 @@ export class FeatureTool implements Extension {
243
275
  return JSON.parse(JSON.stringify(features || [])) as ConstraintFeature[];
244
276
  }
245
277
 
278
+ private getConfigService(): ConfigurationService | undefined {
279
+ return this.context?.services.get<ConfigurationService>("ConfigurationService");
280
+ }
281
+
282
+ private getCommittedFeatures(): ConstraintFeature[] {
283
+ const configService = this.getConfigService();
284
+ const committed = (configService?.get("dieline.features", []) ||
285
+ []) as ConstraintFeature[];
286
+ return this.cloneFeatures(committed);
287
+ }
288
+
289
+ private updateCommittedFeatures(next: ConstraintFeature[]) {
290
+ const configService = this.getConfigService();
291
+ if (!configService) return;
292
+ this.isUpdatingConfig = true;
293
+ try {
294
+ configService.update("dieline.features", next);
295
+ } finally {
296
+ this.isUpdatingConfig = false;
297
+ }
298
+ }
299
+
300
+ private clearFeatureSessionState() {
301
+ this.isFeatureSessionActive = false;
302
+ this.sessionOriginalFeatures = null;
303
+ }
304
+
305
+ private restoreSessionFeaturesToConfig() {
306
+ if (!this.isFeatureSessionActive) return;
307
+ const original = this.cloneFeatures(
308
+ this.sessionOriginalFeatures || this.getCommittedFeatures(),
309
+ );
310
+ this.updateCommittedFeatures(original);
311
+ this.clearFeatureSessionState();
312
+ }
313
+
246
314
  private emitWorkingChange() {
247
315
  this.context?.eventBus.emit("feature:working:change", {
248
316
  features: this.cloneFeatures(this.workingFeatures),
@@ -261,6 +329,19 @@ export class FeatureTool implements Extension {
261
329
  } catch (e) {}
262
330
  }
263
331
 
332
+ private async resetWorkingFeaturesFromSource() {
333
+ const next = this.cloneFeatures(
334
+ this.isFeatureSessionActive && this.sessionOriginalFeatures
335
+ ? this.sessionOriginalFeatures
336
+ : this.getCommittedFeatures(),
337
+ );
338
+ await this.refreshGeometry();
339
+ this.setWorkingFeatures(next);
340
+ this.hasWorkingChanges = false;
341
+ this.redraw();
342
+ this.emitWorkingChange();
343
+ }
344
+
264
345
  private setWorkingFeatures(next: ConstraintFeature[]) {
265
346
  this.workingFeatures = next;
266
347
  }
@@ -335,13 +416,7 @@ export class FeatureTool implements Extension {
335
416
  this.workingFeatures,
336
417
  { dielineWidth, dielineHeight },
337
418
  (next) => {
338
- this.isUpdatingConfig = true;
339
- try {
340
- configService.update("dieline.features", next);
341
- } finally {
342
- this.isUpdatingConfig = false;
343
- }
344
-
419
+ this.updateCommittedFeatures(next as ConstraintFeature[]);
345
420
  this.workingFeatures = this.cloneFeatures(next as any);
346
421
  this.emitWorkingChange();
347
422
  },
@@ -355,6 +430,7 @@ export class FeatureTool implements Extension {
355
430
  }
356
431
 
357
432
  this.hasWorkingChanges = false;
433
+ this.clearFeatureSessionState();
358
434
  // Keep feature markers above dieline overlay after config-driven redraw.
359
435
  this.redraw();
360
436
  return { ok: true };
package/src/image.ts CHANGED
@@ -57,8 +57,7 @@ interface FrameVisualConfig {
57
57
 
58
58
  interface UpsertImageOptions {
59
59
  id?: string;
60
- mode?: "auto" | "replace" | "add";
61
- createIfMissing?: boolean;
60
+ mode?: "replace" | "add";
62
61
  addOptions?: Partial<ImageItem>;
63
62
  fitOnAdd?: boolean;
64
63
  }
@@ -108,7 +107,6 @@ interface DetectFromFrameOptions {
108
107
 
109
108
  const IMAGE_OBJECT_LAYER_ID = "image.user";
110
109
  const IMAGE_OVERLAY_LAYER_ID = "image-overlay";
111
- const IMAGE_REPLACE_GUARD_MS = 2500;
112
110
  const IMAGE_DETECT_EXPAND_DEFAULT = 30;
113
111
  const IMAGE_DETECT_SIMPLIFY_TOLERANCE_DEFAULT = 2;
114
112
  const IMAGE_DETECT_MULTIPLIER_DEFAULT = 2;
@@ -131,7 +129,6 @@ export class ImageTool implements Extension {
131
129
  private isToolActive = false;
132
130
  private isImageSelectionActive = false;
133
131
  private focusedImageId: string | null = null;
134
- private suppressSelectionClearUntil = 0;
135
132
  private renderSeq = 0;
136
133
  private dirtyTrackerDisposable?: { dispose(): void };
137
134
 
@@ -218,13 +215,10 @@ export class ImageTool implements Extension {
218
215
  const before = this.isToolActive;
219
216
  this.syncToolActiveFromWorkbench(event.id);
220
217
  if (!this.isToolActive) {
221
- const now = Date.now();
222
- const inGuardWindow =
223
- now <= this.suppressSelectionClearUntil && !!this.focusedImageId;
224
- if (!inGuardWindow) {
225
- this.isImageSelectionActive = false;
226
- this.focusedImageId = null;
227
- }
218
+ this.setImageFocus(null, {
219
+ syncCanvasSelection: true,
220
+ skipRender: true,
221
+ });
228
222
  }
229
223
  this.debug("tool:activated", {
230
224
  id: event.id,
@@ -233,7 +227,6 @@ export class ImageTool implements Extension {
233
227
  before,
234
228
  isToolActive: this.isToolActive,
235
229
  focusedImageId: this.focusedImageId,
236
- suppressSelectionClearUntil: this.suppressSelectionClearUntil,
237
230
  });
238
231
  if (!this.isToolActive && this.isDebugEnabled()) {
239
232
  console.trace("[ImageTool] tool deactivated trace");
@@ -271,16 +264,10 @@ export class ImageTool implements Extension {
271
264
  };
272
265
 
273
266
  private onSelectionCleared = () => {
274
- const now = Date.now();
275
- if (now <= this.suppressSelectionClearUntil && this.focusedImageId) {
276
- this.debug("selection:cleared ignored", {
277
- suppressUntil: this.suppressSelectionClearUntil,
278
- focusedImageId: this.focusedImageId,
279
- });
280
- return;
281
- }
282
- this.isImageSelectionActive = false;
283
- this.focusedImageId = null;
267
+ this.setImageFocus(null, {
268
+ syncCanvasSelection: false,
269
+ skipRender: true,
270
+ });
284
271
  this.debug("selection:cleared applied");
285
272
  this.updateImages();
286
273
  };
@@ -353,7 +340,7 @@ export class ImageTool implements Extension {
353
340
  id: "image.frame.strokeColor",
354
341
  type: "color",
355
342
  label: "Image Frame Stroke Color",
356
- default: "#FF0000",
343
+ default: "#808080",
357
344
  },
358
345
  {
359
346
  id: "image.frame.strokeWidth",
@@ -369,7 +356,7 @@ export class ImageTool implements Extension {
369
356
  type: "select",
370
357
  label: "Image Frame Stroke Style",
371
358
  options: ["solid", "dashed", "hidden"],
372
- default: "solid",
359
+ default: "dashed",
373
360
  },
374
361
  {
375
362
  id: "image.frame.dashLength",
@@ -474,6 +461,16 @@ export class ImageTool implements Extension {
474
461
  await this.fitImageToDefaultArea(id);
475
462
  },
476
463
  },
464
+ {
465
+ command: "focusImage",
466
+ title: "Focus Image",
467
+ handler: (
468
+ id: string | null,
469
+ options: { syncCanvasSelection?: boolean } = {},
470
+ ) => {
471
+ return this.setImageFocus(id, options);
472
+ },
473
+ },
477
474
  {
478
475
  command: "removeImage",
479
476
  title: "Remove Image",
@@ -483,8 +480,10 @@ export class ImageTool implements Extension {
483
480
  if (next.length !== this.items.length) {
484
481
  this.purgeSourceSizeCacheForItem(removed);
485
482
  if (this.focusedImageId === id) {
486
- this.focusedImageId = null;
487
- this.isImageSelectionActive = false;
483
+ this.setImageFocus(null, {
484
+ syncCanvasSelection: true,
485
+ skipRender: true,
486
+ });
488
487
  }
489
488
  this.updateConfig(next);
490
489
  }
@@ -506,8 +505,10 @@ export class ImageTool implements Extension {
506
505
  title: "Clear Images",
507
506
  handler: () => {
508
507
  this.sourceSizeBySrc.clear();
509
- this.focusedImageId = null;
510
- this.isImageSelectionActive = false;
508
+ this.setImageFocus(null, {
509
+ syncCanvasSelection: true,
510
+ skipRender: true,
511
+ });
511
512
  this.updateConfig([]);
512
513
  },
513
514
  },
@@ -584,29 +585,50 @@ export class ImageTool implements Extension {
584
585
  return Math.random().toString(36).substring(2, 9);
585
586
  }
586
587
 
587
- private getImageIdFromActiveObject(): string | null {
588
- const active = this.canvasService?.canvas.getActiveObject() as any;
589
- if (
590
- active?.data?.layerId === IMAGE_OBJECT_LAYER_ID &&
591
- typeof active?.data?.id === "string"
592
- ) {
593
- return active.data.id;
594
- }
595
- return null;
588
+ private hasImageItem(id: string): boolean {
589
+ return (
590
+ this.items.some((item) => item.id === id) ||
591
+ this.workingItems.some((item) => item.id === id)
592
+ );
596
593
  }
597
594
 
598
- private resolveReplaceTargetId(explicitId?: string | null): string | null {
599
- const has = (id: string | null | undefined) =>
600
- !!id && this.items.some((item) => item.id === id);
595
+ private setImageFocus(
596
+ id: string | null,
597
+ options: { syncCanvasSelection?: boolean; skipRender?: boolean } = {},
598
+ ) {
599
+ const syncCanvasSelection = options.syncCanvasSelection !== false;
601
600
 
602
- if (has(explicitId)) return explicitId as string;
603
- if (has(this.focusedImageId)) return this.focusedImageId as string;
601
+ if (id && !this.hasImageItem(id)) {
602
+ return { ok: false, reason: "image-not-found" as const };
603
+ }
604
604
 
605
- const activeId = this.getImageIdFromActiveObject();
606
- if (has(activeId)) return activeId;
605
+ this.focusedImageId = id;
606
+ this.isImageSelectionActive = !!id;
607
+
608
+ if (syncCanvasSelection && this.canvasService) {
609
+ const canvas = this.canvasService.canvas;
610
+ if (!id) {
611
+ canvas.discardActiveObject();
612
+ } else {
613
+ const obj = this.getImageObject(id);
614
+ if (obj) {
615
+ obj.set({
616
+ selectable: true,
617
+ evented: true,
618
+ hasControls: true,
619
+ hasBorders: true,
620
+ });
621
+ canvas.setActiveObject(obj);
622
+ }
623
+ }
624
+ this.canvasService.requestRenderAll();
625
+ }
626
+
627
+ if (!options.skipRender) {
628
+ this.updateImages();
629
+ }
607
630
 
608
- if (this.items.length === 1) return this.items[0].id;
609
- return null;
631
+ return { ok: true, id };
610
632
  }
611
633
 
612
634
  private async addImageEntry(
@@ -622,9 +644,6 @@ export class ImageTool implements Extension {
622
644
  ...options,
623
645
  } as ImageItem);
624
646
 
625
- this.focusedImageId = id;
626
- this.isImageSelectionActive = true;
627
- this.suppressSelectionClearUntil = Date.now() + IMAGE_REPLACE_GUARD_MS;
628
647
  const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
629
648
  const waitLoaded = this.waitImageLoaded(id, true);
630
649
  this.updateConfig([...this.items, newItem]);
@@ -634,7 +653,7 @@ export class ImageTool implements Extension {
634
653
  await this.fitImageToDefaultArea(id);
635
654
  }
636
655
  if (loaded) {
637
- this.focusImageSelection(id);
656
+ this.setImageFocus(id);
638
657
  }
639
658
  return id;
640
659
  }
@@ -643,23 +662,20 @@ export class ImageTool implements Extension {
643
662
  url: string,
644
663
  options: UpsertImageOptions = {},
645
664
  ): Promise<{ id: string; mode: "replace" | "add" }> {
646
- const mode = options.mode || "auto";
665
+ const mode = options.mode || (options.id ? "replace" : "add");
647
666
  const fitOnAdd = options.fitOnAdd !== false;
648
- if (mode === "add") {
649
- const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
650
- return { id, mode: "add" };
651
- }
652
-
653
- const targetId = this.resolveReplaceTargetId(options.id ?? null);
654
- if (targetId) {
667
+ if (mode === "replace") {
668
+ if (!options.id) {
669
+ throw new Error("replace-target-id-required");
670
+ }
671
+ const targetId = options.id;
672
+ if (!this.hasImageItem(targetId)) {
673
+ throw new Error("replace-target-not-found");
674
+ }
655
675
  await this.updateImageInConfig(targetId, { url });
656
676
  return { id: targetId, mode: "replace" };
657
677
  }
658
678
 
659
- if (mode === "replace" || options.createIfMissing === false) {
660
- throw new Error("replace-target-not-found");
661
- }
662
-
663
679
  const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
664
680
  return { id, mode: "add" };
665
681
  }
@@ -870,12 +886,12 @@ export class ImageTool implements Extension {
870
886
  private getFrameVisualConfig(): FrameVisualConfig {
871
887
  const strokeStyleRaw = (this.getConfig<string>(
872
888
  "image.frame.strokeStyle",
873
- "solid",
874
- ) || "solid") as string;
889
+ "dashed",
890
+ ) || "dashed") as string;
875
891
  const strokeStyle: "solid" | "dashed" | "hidden" =
876
892
  strokeStyleRaw === "dashed" || strokeStyleRaw === "hidden"
877
893
  ? strokeStyleRaw
878
- : "solid";
894
+ : "dashed";
879
895
 
880
896
  const strokeWidth = Number(
881
897
  this.getConfig<number>("image.frame.strokeWidth", 2) ?? 2,
@@ -886,8 +902,8 @@ export class ImageTool implements Extension {
886
902
 
887
903
  return {
888
904
  strokeColor:
889
- this.getConfig<string>("image.frame.strokeColor", "#FF0000") ||
890
- "#FF0000",
905
+ this.getConfig<string>("image.frame.strokeColor", "#808080") ||
906
+ "#808080",
891
907
  strokeWidth: Number.isFinite(strokeWidth) ? Math.max(0, strokeWidth) : 2,
892
908
  strokeStyle,
893
909
  dashLength: Number.isFinite(dashLength) ? Math.max(1, dashLength) : 8,
@@ -1199,8 +1215,10 @@ export class ImageTool implements Extension {
1199
1215
  const frame = this.getFrameRect();
1200
1216
  const desiredIds = new Set(renderItems.map((item) => item.id));
1201
1217
  if (this.focusedImageId && !desiredIds.has(this.focusedImageId)) {
1202
- this.focusedImageId = null;
1203
- this.isImageSelectionActive = false;
1218
+ this.setImageFocus(null, {
1219
+ syncCanvasSelection: false,
1220
+ skipRender: true,
1221
+ });
1204
1222
  }
1205
1223
 
1206
1224
  this.getImageObjects().forEach((obj: any) => {
@@ -1281,8 +1299,10 @@ export class ImageTool implements Extension {
1281
1299
  next[index] = this.normalizeItem({ ...next[index], ...updates });
1282
1300
  this.workingItems = next;
1283
1301
  this.hasWorkingChanges = true;
1284
- this.isImageSelectionActive = true;
1285
- this.focusedImageId = id;
1302
+ this.setImageFocus(id, {
1303
+ syncCanvasSelection: false,
1304
+ skipRender: true,
1305
+ });
1286
1306
  if (this.isToolActive) {
1287
1307
  this.updateImages();
1288
1308
  }
@@ -1318,16 +1338,13 @@ export class ImageTool implements Extension {
1318
1338
  this.updateConfig(next);
1319
1339
 
1320
1340
  if (replacingSource) {
1321
- this.focusedImageId = id;
1322
- this.isImageSelectionActive = true;
1323
- this.suppressSelectionClearUntil = Date.now() + IMAGE_REPLACE_GUARD_MS;
1324
1341
  this.debug("replace:image:begin", { id, replacingUrl });
1325
1342
  this.purgeSourceSizeCacheForItem(base);
1326
1343
  const loaded = await this.waitImageLoaded(id, true);
1327
1344
  this.debug("replace:image:loaded", { id, loaded });
1328
1345
  if (loaded) {
1329
1346
  await this.refitImageToFrame(id);
1330
- this.focusImageSelection(id);
1347
+ this.setImageFocus(id);
1331
1348
  }
1332
1349
  }
1333
1350
  }
@@ -1380,32 +1397,10 @@ export class ImageTool implements Extension {
1380
1397
  this.updateConfig(next);
1381
1398
  this.workingItems = this.cloneItems(next);
1382
1399
  this.hasWorkingChanges = false;
1383
- this.isImageSelectionActive = true;
1384
- this.focusedImageId = id;
1385
1400
  this.updateImages();
1386
1401
  this.emitWorkingChange(id);
1387
1402
  }
1388
1403
 
1389
- private focusImageSelection(id: string) {
1390
- if (!this.canvasService) return;
1391
- const obj = this.getImageObject(id);
1392
- if (!obj) return;
1393
-
1394
- this.isImageSelectionActive = true;
1395
- this.focusedImageId = id;
1396
- this.suppressSelectionClearUntil = Date.now() + 700;
1397
- obj.set({
1398
- selectable: true,
1399
- evented: true,
1400
- hasControls: true,
1401
- hasBorders: true,
1402
- });
1403
- this.canvasService.canvas.setActiveObject(obj);
1404
- this.debug("focus:image", { id });
1405
- this.canvasService.requestRenderAll();
1406
- this.updateImages();
1407
- }
1408
-
1409
1404
  private async fitImageToArea(
1410
1405
  id: string,
1411
1406
  area: { width: number; height: number; left?: number; top?: number },
@@ -1473,10 +1468,6 @@ export class ImageTool implements Extension {
1473
1468
  return { ok: false, reason: "frame-not-ready" };
1474
1469
  }
1475
1470
 
1476
- const focusId =
1477
- this.resolveReplaceTargetId(this.focusedImageId) ||
1478
- (this.workingItems.length === 1 ? this.workingItems[0].id : null);
1479
-
1480
1471
  const next: ImageItem[] = [];
1481
1472
  for (const item of this.workingItems) {
1482
1473
  const url = await this.exportCroppedImageByIds([item.id], {
@@ -1502,13 +1493,7 @@ export class ImageTool implements Extension {
1502
1493
  this.hasWorkingChanges = false;
1503
1494
  this.workingItems = this.cloneItems(next);
1504
1495
  this.updateConfig(next);
1505
- this.emitWorkingChange(focusId);
1506
- if (focusId) {
1507
- this.focusedImageId = focusId;
1508
- this.isImageSelectionActive = true;
1509
- this.suppressSelectionClearUntil = Date.now() + IMAGE_REPLACE_GUARD_MS;
1510
- this.focusImageSelection(focusId);
1511
- }
1496
+ this.emitWorkingChange(this.focusedImageId);
1512
1497
  return { ok: true };
1513
1498
  }
1514
1499