@pooder/kit 6.3.0 → 6.3.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.
@@ -44,6 +44,7 @@ import {
44
44
  resolveImageOperationArea,
45
45
  type ImageOperation,
46
46
  } from "./imageOperations";
47
+ import { validateImagePlacement } from "./imagePlacement";
47
48
  import { buildImageSessionOverlaySpecs } from "./sessionOverlay";
48
49
 
49
50
  export interface ImageItem {
@@ -75,6 +76,18 @@ export interface ImageViewState {
75
76
  isImageSelectionActive: boolean;
76
77
  hasWorkingChanges: boolean;
77
78
  source: "working" | "committed";
79
+ placementPolicy: ImageSessionPlacementPolicy;
80
+ sessionNotice: ImageSessionNotice | null;
81
+ }
82
+
83
+ export type ImageSessionPlacementPolicy = "free" | "warn" | "strict";
84
+
85
+ export interface ImageSessionNotice {
86
+ code: "image-outside-frame";
87
+ level: "warning" | "error";
88
+ message: string;
89
+ imageIds: string[];
90
+ policy: ImageSessionPlacementPolicy;
78
91
  }
79
92
 
80
93
  interface RenderImageState {
@@ -238,6 +251,7 @@ export class ImageTool implements Extension {
238
251
  private activeSnapX: SnapMatch | null = null;
239
252
  private activeSnapY: SnapMatch | null = null;
240
253
  private movingImageId: string | null = null;
254
+ private sessionNotice: ImageSessionNotice | null = null;
241
255
  private hasRenderedSnapGuides = false;
242
256
  private canvasObjectMovingHandler?: (e: any) => void;
243
257
  private canvasMouseUpHandler?: (e: any) => void;
@@ -349,8 +363,12 @@ export class ImageTool implements Extension {
349
363
  if (
350
364
  e.key.startsWith("size.") ||
351
365
  e.key.startsWith("image.frame.") ||
366
+ e.key.startsWith("image.session.") ||
352
367
  e.key.startsWith("image.control.")
353
368
  ) {
369
+ if (e.key === "image.session.placementPolicy") {
370
+ this.clearSessionNotice();
371
+ }
354
372
  if (e.key.startsWith("image.control.")) {
355
373
  this.imageControlsByCapabilityKey.clear();
356
374
  }
@@ -951,6 +969,7 @@ export class ImageTool implements Extension {
951
969
  interaction: "session",
952
970
  commands: {
953
971
  begin: "imageSessionReset",
972
+ validate: "validateImageSession",
954
973
  commit: "completeImages",
955
974
  rollback: "imageSessionReset",
956
975
  },
@@ -1001,6 +1020,47 @@ export class ImageTool implements Extension {
1001
1020
  return this.isToolActive ? this.workingItems : this.items;
1002
1021
  }
1003
1022
 
1023
+ private getPlacementPolicy(): ImageSessionPlacementPolicy {
1024
+ const policy = this.getConfig<ImageSessionPlacementPolicy>(
1025
+ "image.session.placementPolicy",
1026
+ "free",
1027
+ );
1028
+ return policy === "warn" || policy === "strict" ? policy : "free";
1029
+ }
1030
+
1031
+ private areSessionNoticesEqual(
1032
+ a: ImageSessionNotice | null,
1033
+ b: ImageSessionNotice | null,
1034
+ ): boolean {
1035
+ if (!a && !b) return true;
1036
+ if (!a || !b) return false;
1037
+ return (
1038
+ a.code === b.code &&
1039
+ a.level === b.level &&
1040
+ a.message === b.message &&
1041
+ a.policy === b.policy &&
1042
+ JSON.stringify(a.imageIds) === JSON.stringify(b.imageIds)
1043
+ );
1044
+ }
1045
+
1046
+ private setSessionNotice(
1047
+ notice: ImageSessionNotice | null,
1048
+ options: { emit?: boolean } = {},
1049
+ ) {
1050
+ if (this.areSessionNoticesEqual(this.sessionNotice, notice)) {
1051
+ return;
1052
+ }
1053
+ this.sessionNotice = notice;
1054
+ if (options.emit !== false) {
1055
+ this.context?.eventBus.emit("image:session:notice", this.sessionNotice);
1056
+ this.emitImageStateChange();
1057
+ }
1058
+ }
1059
+
1060
+ private clearSessionNotice(options: { emit?: boolean } = {}) {
1061
+ this.setSessionNotice(null, options);
1062
+ }
1063
+
1004
1064
  private getImageViewState(): ImageViewState {
1005
1065
  this.syncToolActiveFromWorkbench();
1006
1066
  const items = this.cloneItems(this.getViewItems());
@@ -1018,6 +1078,8 @@ export class ImageTool implements Extension {
1018
1078
  isImageSelectionActive: this.isImageSelectionActive,
1019
1079
  hasWorkingChanges: this.hasWorkingChanges,
1020
1080
  source: this.isToolActive ? "working" : "committed",
1081
+ placementPolicy: this.getPlacementPolicy(),
1082
+ sessionNotice: this.sessionNotice,
1021
1083
  };
1022
1084
  }
1023
1085
 
@@ -1085,6 +1147,7 @@ export class ImageTool implements Extension {
1085
1147
  operation?: ImageOperation,
1086
1148
  ): Promise<string> {
1087
1149
  this.syncToolActiveFromWorkbench();
1150
+ this.clearSessionNotice({ emit: false });
1088
1151
  const id = this.generateId();
1089
1152
  const newItem = this.normalizeItem({
1090
1153
  id,
@@ -1153,7 +1216,11 @@ export class ImageTool implements Extension {
1153
1216
  return { id: targetId, mode: "replace" };
1154
1217
  }
1155
1218
 
1156
- const id = await this.addImageEntry(url, options.addOptions, options.operation);
1219
+ const id = await this.addImageEntry(
1220
+ url,
1221
+ options.addOptions,
1222
+ options.operation,
1223
+ );
1157
1224
  return { id, mode: "add" };
1158
1225
  }
1159
1226
 
@@ -1200,6 +1267,7 @@ export class ImageTool implements Extension {
1200
1267
 
1201
1268
  private updateConfig(newItems: ImageItem[], skipCanvasUpdate = false) {
1202
1269
  if (!this.context) return;
1270
+ this.clearSessionNotice({ emit: false });
1203
1271
  this.applyCommittedItems(newItems);
1204
1272
  runDeferredConfigUpdate(
1205
1273
  this,
@@ -1313,6 +1381,82 @@ export class ImageTool implements Extension {
1313
1381
  return getCoverScaleFromRect(frame, size);
1314
1382
  }
1315
1383
 
1384
+ private resolvePlacementState(item: ImageItem) {
1385
+ return {
1386
+ left: Number.isFinite(item.left as any) ? (item.left as number) : 0.5,
1387
+ top: Number.isFinite(item.top as any) ? (item.top as number) : 0.5,
1388
+ scale: Math.max(0.05, item.scale ?? 1),
1389
+ angle: Number.isFinite(item.angle as any) ? (item.angle as number) : 0,
1390
+ };
1391
+ }
1392
+
1393
+ private async validatePlacementForItem(item: ImageItem): Promise<boolean> {
1394
+ const frame = this.getFrameRect();
1395
+ if (!frame.width || !frame.height) {
1396
+ return true;
1397
+ }
1398
+
1399
+ const src = item.sourceUrl || item.url;
1400
+ if (!src) {
1401
+ return true;
1402
+ }
1403
+
1404
+ const source = await this.resolveImageSourceSize(item.id, src);
1405
+ if (!source) {
1406
+ return true;
1407
+ }
1408
+
1409
+ return validateImagePlacement({
1410
+ frame,
1411
+ source,
1412
+ placement: this.resolvePlacementState(item),
1413
+ }).ok;
1414
+ }
1415
+
1416
+ private async validateImageSession() {
1417
+ const policy = this.getPlacementPolicy();
1418
+ if (policy === "free") {
1419
+ this.clearSessionNotice();
1420
+ return { ok: true, policy };
1421
+ }
1422
+
1423
+ const invalidImageIds: string[] = [];
1424
+ for (const item of this.workingItems) {
1425
+ const valid = await this.validatePlacementForItem(item);
1426
+ if (!valid) {
1427
+ invalidImageIds.push(item.id);
1428
+ }
1429
+ }
1430
+
1431
+ if (!invalidImageIds.length) {
1432
+ this.clearSessionNotice();
1433
+ return { ok: true, policy };
1434
+ }
1435
+
1436
+ const notice: ImageSessionNotice = {
1437
+ code: "image-outside-frame",
1438
+ level: policy === "strict" ? "error" : "warning",
1439
+ message:
1440
+ policy === "strict"
1441
+ ? "图片位置不能超出 frame,请调整后再提交。"
1442
+ : "图片位置已超出 frame,建议调整后再提交。",
1443
+ imageIds: invalidImageIds,
1444
+ policy,
1445
+ };
1446
+ this.setSessionNotice(notice);
1447
+ this.setImageFocus(invalidImageIds[0], {
1448
+ syncCanvasSelection: true,
1449
+ skipRender: true,
1450
+ });
1451
+ return {
1452
+ ok: policy !== "strict",
1453
+ reason: notice.code,
1454
+ message: notice.message,
1455
+ imageIds: notice.imageIds,
1456
+ policy: notice.policy,
1457
+ };
1458
+ }
1459
+
1316
1460
  private getFrameVisualConfig(): FrameVisualConfig {
1317
1461
  const strokeStyleRaw = (this.getConfig<string>(
1318
1462
  "image.frame.strokeStyle",
@@ -1657,6 +1801,7 @@ export class ImageTool implements Extension {
1657
1801
  }
1658
1802
 
1659
1803
  private resetImageSession() {
1804
+ this.clearSessionNotice({ emit: false });
1660
1805
  this.workingItems = this.cloneItems(this.items);
1661
1806
  this.hasWorkingChanges = false;
1662
1807
  this.updateImages();
@@ -1706,6 +1851,7 @@ export class ImageTool implements Extension {
1706
1851
  const index = this.workingItems.findIndex((item) => item.id === id);
1707
1852
  if (index < 0) return;
1708
1853
 
1854
+ this.clearSessionNotice({ emit: false });
1709
1855
  const next = [...this.workingItems];
1710
1856
  next[index] = this.normalizeItem({ ...next[index], ...updates });
1711
1857
  this.workingItems = next;
@@ -1724,6 +1870,7 @@ export class ImageTool implements Extension {
1724
1870
  const index = this.items.findIndex((item) => item.id === id);
1725
1871
  if (index < 0) return;
1726
1872
 
1873
+ this.clearSessionNotice({ emit: false });
1727
1874
  const replacingSource =
1728
1875
  typeof updates.url === "string" && updates.url.length > 0;
1729
1876
  const next = [...this.items];
@@ -1868,12 +2015,44 @@ export class ImageTool implements Extension {
1868
2015
  }
1869
2016
 
1870
2017
  this.hasWorkingChanges = false;
2018
+ this.clearSessionNotice({ emit: false });
1871
2019
  this.workingItems = this.cloneItems(next);
1872
2020
  this.updateConfig(next);
1873
2021
  this.emitWorkingChange(this.focusedImageId);
1874
2022
  return { ok: true };
1875
2023
  }
1876
2024
 
2025
+ private async completeImageSession() {
2026
+ const sessionState =
2027
+ this.context?.services.get<ToolSessionService>("ToolSessionService");
2028
+ const workbench = this.context?.services.get<any>("WorkbenchService");
2029
+ console.info("[ImageTool] completeImageSession:start", {
2030
+ activeToolId: workbench?.activeToolId ?? null,
2031
+ isToolActive: this.isToolActive,
2032
+ dirtyBeforeComplete: this.hasWorkingChanges,
2033
+ workingCount: this.workingItems.length,
2034
+ committedCount: this.items.length,
2035
+ sessionDirty: sessionState?.isDirty(this.id),
2036
+ });
2037
+ const validation = await this.validateImageSession();
2038
+ if (!validation.ok) {
2039
+ console.warn("[ImageTool] completeImageSession:validation-failed", {
2040
+ validation,
2041
+ dirtyAfterValidation: this.hasWorkingChanges,
2042
+ });
2043
+ return validation;
2044
+ }
2045
+ const result = await this.commitWorkingImagesAsCropped();
2046
+ console.info("[ImageTool] completeImageSession:done", {
2047
+ result,
2048
+ dirtyAfterComplete: this.hasWorkingChanges,
2049
+ workingCount: this.workingItems.length,
2050
+ committedCount: this.items.length,
2051
+ sessionDirty: sessionState?.isDirty(this.id),
2052
+ });
2053
+ return result;
2054
+ }
2055
+
1877
2056
  private async exportCroppedImageByIds(
1878
2057
  imageIds: string[],
1879
2058
  options: ExportCroppedImageOptions,
@@ -63,12 +63,20 @@ export function createImageCommands(tool: any): CommandContribution[] {
63
63
  tool.resetImageSession();
64
64
  },
65
65
  },
66
+ {
67
+ command: "validateImageSession",
68
+ id: "validateImageSession",
69
+ title: "Validate Image Session",
70
+ handler: async () => {
71
+ return await tool.validateImageSession();
72
+ },
73
+ },
66
74
  {
67
75
  command: "completeImages",
68
76
  id: "completeImages",
69
77
  title: "Complete Images",
70
78
  handler: async () => {
71
- return await tool.commitWorkingImagesAsCropped();
79
+ return await tool.completeImageSession();
72
80
  },
73
81
  },
74
82
  {
@@ -124,5 +124,12 @@ export function createImageConfigurations(): ConfigurationContribution[] {
124
124
  label: "Image Frame Outer Background",
125
125
  default: "#f5f5f5",
126
126
  },
127
+ {
128
+ id: "image.session.placementPolicy",
129
+ type: "select",
130
+ label: "Image Session Placement Policy",
131
+ options: ["free", "warn", "strict"],
132
+ default: "free",
133
+ },
127
134
  ];
128
135
  }
@@ -0,0 +1,78 @@
1
+ import type { FrameRect } from "../../shared/scene/frame";
2
+ import {
3
+ getCoverScale as getCoverScaleFromRect,
4
+ type SourceSize,
5
+ } from "../../shared/imaging/sourceSizeCache";
6
+
7
+ export interface ImagePlacementState {
8
+ left: number;
9
+ top: number;
10
+ scale: number;
11
+ angle: number;
12
+ }
13
+
14
+ export interface ImagePlacementValidationArgs {
15
+ frame: FrameRect;
16
+ source: SourceSize;
17
+ placement: ImagePlacementState;
18
+ }
19
+
20
+ export interface ImagePlacementValidationResult {
21
+ ok: boolean;
22
+ }
23
+
24
+ function toRadians(angle: number): number {
25
+ return (angle * Math.PI) / 180;
26
+ }
27
+
28
+ export function validateImagePlacement(
29
+ args: ImagePlacementValidationArgs,
30
+ ): ImagePlacementValidationResult {
31
+ const { frame, source, placement } = args;
32
+ if (
33
+ frame.width <= 0 ||
34
+ frame.height <= 0 ||
35
+ source.width <= 0 ||
36
+ source.height <= 0
37
+ ) {
38
+ return { ok: true };
39
+ }
40
+
41
+ const coverScale = getCoverScaleFromRect(frame, source);
42
+ const imageWidth =
43
+ source.width * coverScale * Math.max(0.05, Number(placement.scale || 1));
44
+ const imageHeight =
45
+ source.height * coverScale * Math.max(0.05, Number(placement.scale || 1));
46
+
47
+ if (imageWidth <= 0 || imageHeight <= 0) {
48
+ return { ok: true };
49
+ }
50
+
51
+ const centerX = frame.left + placement.left * frame.width;
52
+ const centerY = frame.top + placement.top * frame.height;
53
+ const halfWidth = imageWidth / 2;
54
+ const halfHeight = imageHeight / 2;
55
+ const radians = toRadians(placement.angle || 0);
56
+ const cos = Math.cos(radians);
57
+ const sin = Math.sin(radians);
58
+
59
+ const frameCorners = [
60
+ { x: frame.left, y: frame.top },
61
+ { x: frame.left + frame.width, y: frame.top },
62
+ { x: frame.left + frame.width, y: frame.top + frame.height },
63
+ { x: frame.left, y: frame.top + frame.height },
64
+ ];
65
+
66
+ const coversFrame = frameCorners.every((corner) => {
67
+ const dx = corner.x - centerX;
68
+ const dy = corner.y - centerY;
69
+ const localX = dx * cos + dy * sin;
70
+ const localY = -dx * sin + dy * cos;
71
+ return (
72
+ Math.abs(localX) <= halfWidth + 1e-6 &&
73
+ Math.abs(localY) <= halfHeight + 1e-6
74
+ );
75
+ });
76
+
77
+ return { ok: coversFrame };
78
+ }
package/tests/run.ts CHANGED
@@ -33,7 +33,10 @@ function assert(condition: unknown, message: string) {
33
33
 
34
34
  function testWrappedOffsets() {
35
35
  assert(wrappedDistance(100, 10, 30) === 20, "distance 10->30 should be 20");
36
- assert(wrappedDistance(100, 90, 10) === 20, "distance 90->10 should wrap to 20");
36
+ assert(
37
+ wrappedDistance(100, 90, 10) === 20,
38
+ "distance 90->10 should wrap to 20",
39
+ );
37
40
 
38
41
  const a = sampleWrappedOffsets(100, 10, 30, 5);
39
42
  assert(
@@ -76,9 +79,18 @@ function testMaskOps() {
76
79
 
77
80
  const r = findMinimalConnectRadius(mask, width, height, 20);
78
81
  const closed = circularMorphology(mask, width, height, r, "closing");
79
- assert(isMaskConnected8(closed, width, height), `closed mask should be connected (r=${r})`);
82
+ assert(
83
+ isMaskConnected8(closed, width, height),
84
+ `closed mask should be connected (r=${r})`,
85
+ );
80
86
  if (r > 0) {
81
- const closedPrev = circularMorphology(mask, width, height, r - 1, "closing");
87
+ const closedPrev = circularMorphology(
88
+ mask,
89
+ width,
90
+ height,
91
+ r - 1,
92
+ "closing",
93
+ );
82
94
  assert(
83
95
  !isMaskConnected8(closedPrev, width, height),
84
96
  `r should be minimal (r=${r})`,
@@ -97,10 +109,12 @@ function testMaskOps() {
97
109
 
98
110
  const imgW = 2;
99
111
  const imgH = 1;
100
- const rgba = new Uint8ClampedArray([
101
- 255, 255, 255, 255, 10, 10, 10, 254,
102
- ]);
103
- const imageData = { width: imgW, height: imgH, data: rgba } as unknown as ImageData;
112
+ const rgba = new Uint8ClampedArray([255, 255, 255, 255, 10, 10, 10, 254]);
113
+ const imageData = {
114
+ width: imgW,
115
+ height: imgH,
116
+ data: rgba,
117
+ } as unknown as ImageData;
104
118
  const paddedWidth = imgW + 4;
105
119
  const paddedHeight = imgH + 4;
106
120
  const created = createMask(imageData, {
@@ -111,15 +125,25 @@ function testMaskOps() {
111
125
  maskMode: "auto",
112
126
  alphaOpaqueCutoff: 250,
113
127
  });
114
- assert(created[2 * paddedWidth + 2] === 0, "white pixel should be background");
115
- assert(created[2 * paddedWidth + 3] === 1, "non-white pixel should be foreground");
128
+ assert(
129
+ created[2 * paddedWidth + 2] === 0,
130
+ "white pixel should be background",
131
+ );
132
+ assert(
133
+ created[2 * paddedWidth + 3] === 1,
134
+ "non-white pixel should be foreground",
135
+ );
116
136
  }
117
137
 
118
138
  function testEdgeScale() {
119
139
  const currentMax = 100;
120
140
  const baseBounds = { width: 50, height: 20 };
121
141
  const expandedBounds = { width: 70, height: 40 };
122
- const { width, height, scale } = computeDetectEdgeSize(currentMax, baseBounds, expandedBounds);
142
+ const { width, height, scale } = computeDetectEdgeSize(
143
+ currentMax,
144
+ baseBounds,
145
+ expandedBounds,
146
+ );
123
147
  assert(scale === 2, `expected scale 2, got ${scale}`);
124
148
  assert(width === 140, `expected width 140, got ${width}`);
125
149
  assert(height === 80, `expected height 80, got ${height}`);
@@ -290,7 +314,10 @@ function testVisibilityDsl() {
290
314
  }
291
315
 
292
316
  function testImageViewStateHelper() {
293
- assert(hasAnyImageInViewState(null) === false, "null image state should be empty");
317
+ assert(
318
+ hasAnyImageInViewState(null) === false,
319
+ "null image state should be empty",
320
+ );
294
321
  assert(
295
322
  hasAnyImageInViewState({
296
323
  items: [],
@@ -301,6 +328,8 @@ function testImageViewStateHelper() {
301
328
  isImageSelectionActive: false,
302
329
  hasWorkingChanges: false,
303
330
  source: "committed",
331
+ placementPolicy: "free",
332
+ sessionNotice: null,
304
333
  }) === false,
305
334
  "empty image state should report false",
306
335
  );
@@ -324,6 +353,8 @@ function testImageViewStateHelper() {
324
353
  isImageSelectionActive: true,
325
354
  hasWorkingChanges: true,
326
355
  source: "working",
356
+ placementPolicy: "free",
357
+ sessionNotice: null,
327
358
  }) === true,
328
359
  "non-empty image state should report true",
329
360
  );
@@ -348,6 +379,7 @@ function testContributionCompatibility() {
348
379
  "getImageViewState",
349
380
  "setImageTransform",
350
381
  "imageSessionReset",
382
+ "validateImageSession",
351
383
  "completeImages",
352
384
  "exportUserCroppedImage",
353
385
  "focusImage",
@@ -427,6 +459,7 @@ function testContributionCompatibility() {
427
459
  "image.frame.dashLength",
428
460
  "image.frame.innerBackground",
429
461
  "image.frame.outerBackground",
462
+ "image.session.placementPolicy",
430
463
  ];
431
464
  const expectedWhiteInkConfigKeys = [
432
465
  "whiteInk.items",
@@ -472,10 +505,10 @@ function main() {
472
505
  testBridgeSelection();
473
506
  testMaskOps();
474
507
  testEdgeScale();
475
- testFeaturePlacementProjection();
476
- testVisibilityDsl();
477
- testImageViewStateHelper();
478
- testContributionCompatibility();
508
+ testFeaturePlacementProjection();
509
+ testVisibilityDsl();
510
+ testImageViewStateHelper();
511
+ testContributionCompatibility();
479
512
  console.log("ok");
480
513
  }
481
514