@pooder/kit 6.2.2 → 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.
@@ -1,4 +1,5 @@
1
1
  import type { CommandContribution } from "@pooder/core";
2
+ import type { ImageOperation } from "./imageOperations";
2
3
 
3
4
  export function createImageCommands(tool: any): CommandContribution[] {
4
5
  return [
@@ -23,30 +24,51 @@ export function createImageCommands(tool: any): CommandContribution[] {
23
24
  },
24
25
  },
25
26
  {
26
- command: "getWorkingImages",
27
- id: "getWorkingImages",
28
- title: "Get Working Images",
27
+ command: "applyImageOperation",
28
+ id: "applyImageOperation",
29
+ title: "Apply Image Operation",
30
+ handler: async (
31
+ id: string,
32
+ operation: ImageOperation,
33
+ options: Record<string, any> = {},
34
+ ) => {
35
+ await tool.applyImageOperation(id, operation, options);
36
+ },
37
+ },
38
+ {
39
+ command: "getImageViewState",
40
+ id: "getImageViewState",
41
+ title: "Get Image View State",
29
42
  handler: () => {
30
- return tool.cloneItems(tool.workingItems);
43
+ return tool.getImageViewState();
31
44
  },
32
45
  },
33
46
  {
34
- command: "setWorkingImage",
35
- id: "setWorkingImage",
36
- title: "Set Working Image",
37
- handler: (id: string, updates: Record<string, any>) => {
38
- tool.updateImageInWorking(id, updates);
47
+ command: "setImageTransform",
48
+ id: "setImageTransform",
49
+ title: "Set Image Transform",
50
+ handler: async (
51
+ id: string,
52
+ updates: Record<string, any>,
53
+ options: Record<string, any> = {},
54
+ ) => {
55
+ await tool.setImageTransform(id, updates, options);
39
56
  },
40
57
  },
41
58
  {
42
- command: "resetWorkingImages",
43
- id: "resetWorkingImages",
44
- title: "Reset Working Images",
59
+ command: "imageSessionReset",
60
+ id: "imageSessionReset",
61
+ title: "Reset Image Session",
45
62
  handler: () => {
46
- tool.workingItems = tool.cloneItems(tool.items);
47
- tool.hasWorkingChanges = false;
48
- tool.updateImages();
49
- tool.emitWorkingChange();
63
+ tool.resetImageSession();
64
+ },
65
+ },
66
+ {
67
+ command: "validateImageSession",
68
+ id: "validateImageSession",
69
+ title: "Validate Image Session",
70
+ handler: async () => {
71
+ return await tool.validateImageSession();
50
72
  },
51
73
  },
52
74
  {
@@ -54,7 +76,7 @@ export function createImageCommands(tool: any): CommandContribution[] {
54
76
  id: "completeImages",
55
77
  title: "Complete Images",
56
78
  handler: async () => {
57
- return await tool.commitWorkingImagesAsCropped();
79
+ return await tool.completeImageSession();
58
80
  },
59
81
  },
60
82
  {
@@ -65,30 +87,6 @@ export function createImageCommands(tool: any): CommandContribution[] {
65
87
  return await tool.exportUserCroppedImage(options);
66
88
  },
67
89
  },
68
- {
69
- command: "fitImageToArea",
70
- id: "fitImageToArea",
71
- title: "Fit Image to Area",
72
- handler: async (
73
- id: string,
74
- area: {
75
- width: number;
76
- height: number;
77
- left?: number;
78
- top?: number;
79
- },
80
- ) => {
81
- await tool.fitImageToArea(id, area);
82
- },
83
- },
84
- {
85
- command: "fitImageToDefaultArea",
86
- id: "fitImageToDefaultArea",
87
- title: "Fit Image to Default Area",
88
- handler: async (id: string) => {
89
- await tool.fitImageToDefaultArea(id);
90
- },
91
- },
92
90
  {
93
91
  command: "focusImage",
94
92
  id: "focusImage",
@@ -105,9 +103,10 @@ export function createImageCommands(tool: any): CommandContribution[] {
105
103
  id: "removeImage",
106
104
  title: "Remove Image",
107
105
  handler: (id: string) => {
108
- const removed = tool.items.find((item: any) => item.id === id);
109
- const next = tool.items.filter((item: any) => item.id !== id);
110
- if (next.length !== tool.items.length) {
106
+ const sourceItems = tool.isToolActive ? tool.workingItems : tool.items;
107
+ const removed = sourceItems.find((item: any) => item.id === id);
108
+ const next = sourceItems.filter((item: any) => item.id !== id);
109
+ if (next.length !== sourceItems.length) {
111
110
  tool.purgeSourceSizeCacheForItem(removed);
112
111
  if (tool.focusedImageId === id) {
113
112
  tool.setImageFocus(null, {
@@ -115,6 +114,13 @@ export function createImageCommands(tool: any): CommandContribution[] {
115
114
  skipRender: true,
116
115
  });
117
116
  }
117
+ if (tool.isToolActive) {
118
+ tool.workingItems = tool.cloneItems(next);
119
+ tool.hasWorkingChanges = true;
120
+ tool.updateImages();
121
+ tool.emitWorkingChange(id);
122
+ return;
123
+ }
118
124
  tool.updateConfig(next);
119
125
  }
120
126
  },
@@ -141,6 +147,13 @@ export function createImageCommands(tool: any): CommandContribution[] {
141
147
  syncCanvasSelection: true,
142
148
  skipRender: true,
143
149
  });
150
+ if (tool.isToolActive) {
151
+ tool.workingItems = [];
152
+ tool.hasWorkingChanges = true;
153
+ tool.updateImages();
154
+ tool.emitWorkingChange();
155
+ return;
156
+ }
144
157
  tool.updateConfig([]);
145
158
  },
146
159
  },
@@ -149,11 +162,19 @@ export function createImageCommands(tool: any): CommandContribution[] {
149
162
  id: "bringToFront",
150
163
  title: "Bring Image to Front",
151
164
  handler: (id: string) => {
152
- const index = tool.items.findIndex((item: any) => item.id === id);
153
- if (index !== -1 && index < tool.items.length - 1) {
154
- const next = [...tool.items];
165
+ const sourceItems = tool.isToolActive ? tool.workingItems : tool.items;
166
+ const index = sourceItems.findIndex((item: any) => item.id === id);
167
+ if (index !== -1 && index < sourceItems.length - 1) {
168
+ const next = [...sourceItems];
155
169
  const [item] = next.splice(index, 1);
156
170
  next.push(item);
171
+ if (tool.isToolActive) {
172
+ tool.workingItems = tool.cloneItems(next);
173
+ tool.hasWorkingChanges = true;
174
+ tool.updateImages();
175
+ tool.emitWorkingChange(id);
176
+ return;
177
+ }
157
178
  tool.updateConfig(next);
158
179
  }
159
180
  },
@@ -163,11 +184,19 @@ export function createImageCommands(tool: any): CommandContribution[] {
163
184
  id: "sendToBack",
164
185
  title: "Send Image to Back",
165
186
  handler: (id: string) => {
166
- const index = tool.items.findIndex((item: any) => item.id === id);
187
+ const sourceItems = tool.isToolActive ? tool.workingItems : tool.items;
188
+ const index = sourceItems.findIndex((item: any) => item.id === id);
167
189
  if (index > 0) {
168
- const next = [...tool.items];
190
+ const next = [...sourceItems];
169
191
  const [item] = next.splice(index, 1);
170
192
  next.unshift(item);
193
+ if (tool.isToolActive) {
194
+ tool.workingItems = tool.cloneItems(next);
195
+ tool.hasWorkingChanges = true;
196
+ tool.updateImages();
197
+ tool.emitWorkingChange(id);
198
+ return;
199
+ }
171
200
  tool.updateConfig(next);
172
201
  }
173
202
  },
@@ -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,135 @@
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 ImageOperationArea {
8
+ width: number;
9
+ height: number;
10
+ centerX: number;
11
+ centerY: number;
12
+ }
13
+
14
+ export interface ImageOperationViewport {
15
+ left: number;
16
+ top: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ export type ImageOperationAreaSpec =
22
+ | { type: "frame" }
23
+ | { type: "viewport" }
24
+ | ({
25
+ type: "custom";
26
+ } & ImageOperationArea);
27
+
28
+ export type ImageOperation =
29
+ | { type: "cover"; area?: ImageOperationAreaSpec }
30
+ | { type: "contain"; area?: ImageOperationAreaSpec }
31
+ | { type: "maximizeWidth"; area?: ImageOperationAreaSpec }
32
+ | { type: "maximizeHeight"; area?: ImageOperationAreaSpec }
33
+ | { type: "center"; area?: ImageOperationAreaSpec }
34
+ | { type: "resetTransform" };
35
+
36
+ export interface ComputeImageOperationArgs {
37
+ frame: FrameRect;
38
+ source: SourceSize;
39
+ operation: ImageOperation;
40
+ area: ImageOperationArea;
41
+ }
42
+
43
+ function clampNormalizedAnchor(value: number): number {
44
+ return Math.max(-1, Math.min(2, value));
45
+ }
46
+
47
+ function toNormalizedAnchor(center: number, start: number, size: number): number {
48
+ return clampNormalizedAnchor((center - start) / Math.max(1, size));
49
+ }
50
+
51
+ function resolveAbsoluteScale(
52
+ operation: ImageOperation,
53
+ area: ImageOperationArea,
54
+ source: SourceSize,
55
+ ): number | null {
56
+ const widthScale = Math.max(1, area.width) / Math.max(1, source.width);
57
+ const heightScale = Math.max(1, area.height) / Math.max(1, source.height);
58
+
59
+ switch (operation.type) {
60
+ case "cover":
61
+ return Math.max(widthScale, heightScale);
62
+ case "contain":
63
+ return Math.min(widthScale, heightScale);
64
+ case "maximizeWidth":
65
+ return widthScale;
66
+ case "maximizeHeight":
67
+ return heightScale;
68
+ default:
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export function resolveImageOperationArea(args: {
74
+ frame: FrameRect;
75
+ viewport: ImageOperationViewport;
76
+ area?: ImageOperationAreaSpec;
77
+ }): ImageOperationArea {
78
+ const spec = args.area || { type: "frame" };
79
+
80
+ if (spec.type === "custom") {
81
+ return {
82
+ width: Math.max(1, spec.width),
83
+ height: Math.max(1, spec.height),
84
+ centerX: spec.centerX,
85
+ centerY: spec.centerY,
86
+ };
87
+ }
88
+
89
+ if (spec.type === "viewport") {
90
+ return {
91
+ width: Math.max(1, args.viewport.width),
92
+ height: Math.max(1, args.viewport.height),
93
+ centerX: args.viewport.left + args.viewport.width / 2,
94
+ centerY: args.viewport.top + args.viewport.height / 2,
95
+ };
96
+ }
97
+
98
+ return {
99
+ width: Math.max(1, args.frame.width),
100
+ height: Math.max(1, args.frame.height),
101
+ centerX: args.frame.left + args.frame.width / 2,
102
+ centerY: args.frame.top + args.frame.height / 2,
103
+ };
104
+ }
105
+
106
+ export function computeImageOperationUpdates(
107
+ args: ComputeImageOperationArgs,
108
+ ): { scale?: number; left?: number; top?: number; angle?: number } {
109
+ const { frame, source, operation, area } = args;
110
+
111
+ if (operation.type === "resetTransform") {
112
+ return {
113
+ scale: 1,
114
+ left: 0.5,
115
+ top: 0.5,
116
+ angle: 0,
117
+ };
118
+ }
119
+
120
+ const left = toNormalizedAnchor(area.centerX, frame.left, frame.width);
121
+ const top = toNormalizedAnchor(area.centerY, frame.top, frame.height);
122
+
123
+ if (operation.type === "center") {
124
+ return { left, top };
125
+ }
126
+
127
+ const absoluteScale = resolveAbsoluteScale(operation, area, source);
128
+ const coverScale = getCoverScaleFromRect(frame, source);
129
+
130
+ return {
131
+ scale: Math.max(0.05, (absoluteScale || coverScale) / coverScale),
132
+ left,
133
+ top,
134
+ };
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
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./ImageTool";
2
2
  export * from "./commands";
3
3
  export * from "./config";
4
+ export * from "./imageOperations";
4
5
  export * from "./model";
5
6
  export * from "./renderer";
@@ -1 +1,13 @@
1
- export type { ImageItem } from "./ImageTool";
1
+ export type {
2
+ ImageItem,
3
+ ImageTransformUpdates,
4
+ ImageViewState,
5
+ } from "./ImageTool";
6
+
7
+ import type { ImageViewState } from "./ImageTool";
8
+
9
+ export function hasAnyImageInViewState(
10
+ state: ImageViewState | null | undefined,
11
+ ): boolean {
12
+ return Boolean(state?.hasAnyImage);
13
+ }
package/tests/run.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  normalizePointInGeometry,
26
26
  resolveFeaturePosition,
27
27
  } from "../src/extensions/featureCoordinates";
28
+ import { hasAnyImageInViewState } from "../src/extensions/image/model";
28
29
 
29
30
  function assert(condition: unknown, message: string) {
30
31
  if (!condition) throw new Error(message);
@@ -32,7 +33,10 @@ function assert(condition: unknown, message: string) {
32
33
 
33
34
  function testWrappedOffsets() {
34
35
  assert(wrappedDistance(100, 10, 30) === 20, "distance 10->30 should be 20");
35
- 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
+ );
36
40
 
37
41
  const a = sampleWrappedOffsets(100, 10, 30, 5);
38
42
  assert(
@@ -75,9 +79,18 @@ function testMaskOps() {
75
79
 
76
80
  const r = findMinimalConnectRadius(mask, width, height, 20);
77
81
  const closed = circularMorphology(mask, width, height, r, "closing");
78
- 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
+ );
79
86
  if (r > 0) {
80
- 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
+ );
81
94
  assert(
82
95
  !isMaskConnected8(closedPrev, width, height),
83
96
  `r should be minimal (r=${r})`,
@@ -96,10 +109,12 @@ function testMaskOps() {
96
109
 
97
110
  const imgW = 2;
98
111
  const imgH = 1;
99
- const rgba = new Uint8ClampedArray([
100
- 255, 255, 255, 255, 10, 10, 10, 254,
101
- ]);
102
- 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;
103
118
  const paddedWidth = imgW + 4;
104
119
  const paddedHeight = imgH + 4;
105
120
  const created = createMask(imageData, {
@@ -110,15 +125,25 @@ function testMaskOps() {
110
125
  maskMode: "auto",
111
126
  alphaOpaqueCutoff: 250,
112
127
  });
113
- assert(created[2 * paddedWidth + 2] === 0, "white pixel should be background");
114
- 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
+ );
115
136
  }
116
137
 
117
138
  function testEdgeScale() {
118
139
  const currentMax = 100;
119
140
  const baseBounds = { width: 50, height: 20 };
120
141
  const expandedBounds = { width: 70, height: 40 };
121
- const { width, height, scale } = computeDetectEdgeSize(currentMax, baseBounds, expandedBounds);
142
+ const { width, height, scale } = computeDetectEdgeSize(
143
+ currentMax,
144
+ baseBounds,
145
+ expandedBounds,
146
+ );
122
147
  assert(scale === 2, `expected scale 2, got ${scale}`);
123
148
  assert(width === 140, `expected width 140, got ${width}`);
124
149
  assert(height === 80, `expected height 80, got ${height}`);
@@ -288,6 +313,53 @@ function testVisibilityDsl() {
288
313
  );
289
314
  }
290
315
 
316
+ function testImageViewStateHelper() {
317
+ assert(
318
+ hasAnyImageInViewState(null) === false,
319
+ "null image state should be empty",
320
+ );
321
+ assert(
322
+ hasAnyImageInViewState({
323
+ items: [],
324
+ hasAnyImage: false,
325
+ focusedId: null,
326
+ focusedItem: null,
327
+ isToolActive: false,
328
+ isImageSelectionActive: false,
329
+ hasWorkingChanges: false,
330
+ source: "committed",
331
+ placementPolicy: "free",
332
+ sessionNotice: null,
333
+ }) === false,
334
+ "empty image state should report false",
335
+ );
336
+ assert(
337
+ hasAnyImageInViewState({
338
+ items: [
339
+ {
340
+ id: "img-1",
341
+ url: "blob:test",
342
+ opacity: 1,
343
+ },
344
+ ],
345
+ hasAnyImage: true,
346
+ focusedId: "img-1",
347
+ focusedItem: {
348
+ id: "img-1",
349
+ url: "blob:test",
350
+ opacity: 1,
351
+ },
352
+ isToolActive: true,
353
+ isImageSelectionActive: true,
354
+ hasWorkingChanges: true,
355
+ source: "working",
356
+ placementPolicy: "free",
357
+ sessionNotice: null,
358
+ }) === true,
359
+ "non-empty image state should report true",
360
+ );
361
+ }
362
+
291
363
  function testContributionCompatibility() {
292
364
  const imageCommandNames = createImageCommands({} as any).map(
293
365
  (entry) => entry.command,
@@ -303,13 +375,13 @@ function testContributionCompatibility() {
303
375
  const expectedImageCommands = [
304
376
  "addImage",
305
377
  "upsertImage",
306
- "getWorkingImages",
307
- "setWorkingImage",
308
- "resetWorkingImages",
378
+ "applyImageOperation",
379
+ "getImageViewState",
380
+ "setImageTransform",
381
+ "imageSessionReset",
382
+ "validateImageSession",
309
383
  "completeImages",
310
384
  "exportUserCroppedImage",
311
- "fitImageToArea",
312
- "fitImageToDefaultArea",
313
385
  "focusImage",
314
386
  "removeImage",
315
387
  "updateImage",
@@ -387,6 +459,7 @@ function testContributionCompatibility() {
387
459
  "image.frame.dashLength",
388
460
  "image.frame.innerBackground",
389
461
  "image.frame.outerBackground",
462
+ "image.session.placementPolicy",
390
463
  ];
391
464
  const expectedWhiteInkConfigKeys = [
392
465
  "whiteInk.items",
@@ -434,6 +507,7 @@ function main() {
434
507
  testEdgeScale();
435
508
  testFeaturePlacementProjection();
436
509
  testVisibilityDsl();
510
+ testImageViewStateHelper();
437
511
  testContributionCompatibility();
438
512
  console.log("ok");
439
513
  }