@pooder/kit 6.2.1 → 6.3.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.
@@ -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,43 @@ 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();
50
64
  },
51
65
  },
52
66
  {
@@ -65,30 +79,6 @@ export function createImageCommands(tool: any): CommandContribution[] {
65
79
  return await tool.exportUserCroppedImage(options);
66
80
  },
67
81
  },
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
82
  {
93
83
  command: "focusImage",
94
84
  id: "focusImage",
@@ -105,9 +95,10 @@ export function createImageCommands(tool: any): CommandContribution[] {
105
95
  id: "removeImage",
106
96
  title: "Remove Image",
107
97
  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) {
98
+ const sourceItems = tool.isToolActive ? tool.workingItems : tool.items;
99
+ const removed = sourceItems.find((item: any) => item.id === id);
100
+ const next = sourceItems.filter((item: any) => item.id !== id);
101
+ if (next.length !== sourceItems.length) {
111
102
  tool.purgeSourceSizeCacheForItem(removed);
112
103
  if (tool.focusedImageId === id) {
113
104
  tool.setImageFocus(null, {
@@ -115,6 +106,13 @@ export function createImageCommands(tool: any): CommandContribution[] {
115
106
  skipRender: true,
116
107
  });
117
108
  }
109
+ if (tool.isToolActive) {
110
+ tool.workingItems = tool.cloneItems(next);
111
+ tool.hasWorkingChanges = true;
112
+ tool.updateImages();
113
+ tool.emitWorkingChange(id);
114
+ return;
115
+ }
118
116
  tool.updateConfig(next);
119
117
  }
120
118
  },
@@ -141,6 +139,13 @@ export function createImageCommands(tool: any): CommandContribution[] {
141
139
  syncCanvasSelection: true,
142
140
  skipRender: true,
143
141
  });
142
+ if (tool.isToolActive) {
143
+ tool.workingItems = [];
144
+ tool.hasWorkingChanges = true;
145
+ tool.updateImages();
146
+ tool.emitWorkingChange();
147
+ return;
148
+ }
144
149
  tool.updateConfig([]);
145
150
  },
146
151
  },
@@ -149,11 +154,19 @@ export function createImageCommands(tool: any): CommandContribution[] {
149
154
  id: "bringToFront",
150
155
  title: "Bring Image to Front",
151
156
  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];
157
+ const sourceItems = tool.isToolActive ? tool.workingItems : tool.items;
158
+ const index = sourceItems.findIndex((item: any) => item.id === id);
159
+ if (index !== -1 && index < sourceItems.length - 1) {
160
+ const next = [...sourceItems];
155
161
  const [item] = next.splice(index, 1);
156
162
  next.push(item);
163
+ if (tool.isToolActive) {
164
+ tool.workingItems = tool.cloneItems(next);
165
+ tool.hasWorkingChanges = true;
166
+ tool.updateImages();
167
+ tool.emitWorkingChange(id);
168
+ return;
169
+ }
157
170
  tool.updateConfig(next);
158
171
  }
159
172
  },
@@ -163,11 +176,19 @@ export function createImageCommands(tool: any): CommandContribution[] {
163
176
  id: "sendToBack",
164
177
  title: "Send Image to Back",
165
178
  handler: (id: string) => {
166
- const index = tool.items.findIndex((item: any) => item.id === id);
179
+ const sourceItems = tool.isToolActive ? tool.workingItems : tool.items;
180
+ const index = sourceItems.findIndex((item: any) => item.id === id);
167
181
  if (index > 0) {
168
- const next = [...tool.items];
182
+ const next = [...sourceItems];
169
183
  const [item] = next.splice(index, 1);
170
184
  next.unshift(item);
185
+ if (tool.isToolActive) {
186
+ tool.workingItems = tool.cloneItems(next);
187
+ tool.hasWorkingChanges = true;
188
+ tool.updateImages();
189
+ tool.emitWorkingChange(id);
190
+ return;
191
+ }
171
192
  tool.updateConfig(next);
172
193
  }
173
194
  },
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,206 @@
1
+ import type { Pattern } from "fabric";
2
+ import type { RenderObjectSpec } from "../../services";
3
+ import type {
4
+ SceneGeometrySnapshot,
5
+ SceneLayoutSnapshot,
6
+ SceneRect,
7
+ } from "../../shared/scene/sceneLayoutModel";
8
+ import { generateDielinePath } from "../geometry";
9
+
10
+ export interface ImageSessionOverlayVisualConfig {
11
+ strokeColor: string;
12
+ strokeWidth: number;
13
+ strokeStyle: "solid" | "dashed" | "hidden";
14
+ dashLength: number;
15
+ innerBackground: string;
16
+ outerBackground: string;
17
+ }
18
+
19
+ export interface ImageSessionOverlayViewport {
20
+ left: number;
21
+ top: number;
22
+ width: number;
23
+ height: number;
24
+ }
25
+
26
+ interface BuiltinShapeOverlayPaths {
27
+ hatchPathData: string;
28
+ shapePathData: string;
29
+ }
30
+
31
+ const EPSILON = 0.0001;
32
+ const SHAPE_OUTLINE_COLOR = "rgba(255, 0, 0, 0.9)";
33
+ const DEFAULT_HATCH_FILL = "rgba(255, 0, 0, 0.22)";
34
+
35
+ function buildRectPath(width: number, height: number): string {
36
+ return `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`;
37
+ }
38
+
39
+ function buildViewportMaskPath(
40
+ viewport: ImageSessionOverlayViewport,
41
+ cutRect: SceneRect,
42
+ ): string {
43
+ const cutLeft = cutRect.left - viewport.left;
44
+ const cutTop = cutRect.top - viewport.top;
45
+ return [
46
+ buildRectPath(viewport.width, viewport.height),
47
+ `M ${cutLeft} ${cutTop} L ${cutLeft + cutRect.width} ${cutTop} L ${
48
+ cutLeft + cutRect.width
49
+ } ${cutTop + cutRect.height} L ${cutLeft} ${cutTop + cutRect.height} Z`,
50
+ ].join(" ");
51
+ }
52
+
53
+ function resolveCutShapeRadiusPx(
54
+ geometry: SceneGeometrySnapshot,
55
+ cutRect: SceneRect,
56
+ ): number {
57
+ const visualRadius = Number.isFinite(geometry.radius)
58
+ ? Math.max(0, geometry.radius)
59
+ : 0;
60
+ const visualOffset = Number.isFinite(geometry.offset) ? geometry.offset : 0;
61
+ const rawCutRadius =
62
+ visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
63
+ const maxRadius = Math.max(0, Math.min(cutRect.width, cutRect.height) / 2);
64
+ return Math.max(0, Math.min(maxRadius, rawCutRadius));
65
+ }
66
+
67
+ function buildBuiltinShapeOverlayPaths(
68
+ cutRect: SceneRect,
69
+ geometry: SceneGeometrySnapshot | null,
70
+ ): BuiltinShapeOverlayPaths | null {
71
+ if (!geometry || geometry.shape === "custom") {
72
+ return null;
73
+ }
74
+
75
+ const radius = resolveCutShapeRadiusPx(geometry, cutRect);
76
+ if (geometry.shape === "rect" && radius <= EPSILON) {
77
+ return null;
78
+ }
79
+
80
+ const shapePathData = generateDielinePath({
81
+ shape: geometry.shape,
82
+ shapeStyle: geometry.shapeStyle,
83
+ width: Math.max(1, cutRect.width),
84
+ height: Math.max(1, cutRect.height),
85
+ radius,
86
+ x: cutRect.width / 2,
87
+ y: cutRect.height / 2,
88
+ features: [],
89
+ canvasWidth: Math.max(1, cutRect.width),
90
+ canvasHeight: Math.max(1, cutRect.height),
91
+ });
92
+ if (!shapePathData) {
93
+ return null;
94
+ }
95
+
96
+ return {
97
+ shapePathData,
98
+ hatchPathData: `${buildRectPath(cutRect.width, cutRect.height)} ${shapePathData}`,
99
+ };
100
+ }
101
+
102
+ export function buildImageSessionOverlaySpecs(args: {
103
+ viewport: ImageSessionOverlayViewport;
104
+ layout: SceneLayoutSnapshot;
105
+ geometry: SceneGeometrySnapshot | null;
106
+ visual: ImageSessionOverlayVisualConfig;
107
+ hatchPattern?: Pattern;
108
+ }): RenderObjectSpec[] {
109
+ const { viewport, layout, geometry, visual, hatchPattern } = args;
110
+ const cutRect = layout.cutRect;
111
+ const specs: RenderObjectSpec[] = [];
112
+
113
+ specs.push({
114
+ id: "image.cropMask.rect",
115
+ type: "path",
116
+ space: "screen",
117
+ data: { id: "image.cropMask.rect", zIndex: 1 },
118
+ props: {
119
+ pathData: buildViewportMaskPath(viewport, cutRect),
120
+ left: viewport.left,
121
+ top: viewport.top,
122
+ originX: "left",
123
+ originY: "top",
124
+ fill: visual.outerBackground,
125
+ stroke: null,
126
+ fillRule: "evenodd",
127
+ selectable: false,
128
+ evented: false,
129
+ excludeFromExport: true,
130
+ objectCaching: false,
131
+ },
132
+ });
133
+
134
+ const shapeOverlay = buildBuiltinShapeOverlayPaths(cutRect, geometry);
135
+ if (shapeOverlay) {
136
+ specs.push({
137
+ id: "image.cropShapeHatch",
138
+ type: "path",
139
+ space: "screen",
140
+ data: { id: "image.cropShapeHatch", zIndex: 5 },
141
+ props: {
142
+ pathData: shapeOverlay.hatchPathData,
143
+ left: cutRect.left,
144
+ top: cutRect.top,
145
+ originX: "left",
146
+ originY: "top",
147
+ fill: hatchPattern || DEFAULT_HATCH_FILL,
148
+ opacity: hatchPattern ? 1 : 0.8,
149
+ stroke: null,
150
+ fillRule: "evenodd",
151
+ selectable: false,
152
+ evented: false,
153
+ excludeFromExport: true,
154
+ objectCaching: false,
155
+ },
156
+ });
157
+ specs.push({
158
+ id: "image.cropShapeOutline",
159
+ type: "path",
160
+ space: "screen",
161
+ data: { id: "image.cropShapeOutline", zIndex: 6 },
162
+ props: {
163
+ pathData: shapeOverlay.shapePathData,
164
+ left: cutRect.left,
165
+ top: cutRect.top,
166
+ originX: "left",
167
+ originY: "top",
168
+ fill: "transparent",
169
+ stroke: SHAPE_OUTLINE_COLOR,
170
+ strokeWidth: 1,
171
+ selectable: false,
172
+ evented: false,
173
+ excludeFromExport: true,
174
+ objectCaching: false,
175
+ },
176
+ });
177
+ }
178
+
179
+ specs.push({
180
+ id: "image.cropFrame",
181
+ type: "rect",
182
+ space: "screen",
183
+ data: { id: "image.cropFrame", zIndex: 7 },
184
+ props: {
185
+ left: cutRect.left,
186
+ top: cutRect.top,
187
+ width: cutRect.width,
188
+ height: cutRect.height,
189
+ originX: "left",
190
+ originY: "top",
191
+ fill: visual.innerBackground,
192
+ stroke:
193
+ visual.strokeStyle === "hidden" ? "rgba(0,0,0,0)" : visual.strokeColor,
194
+ strokeWidth: visual.strokeStyle === "hidden" ? 0 : visual.strokeWidth,
195
+ strokeDashArray:
196
+ visual.strokeStyle === "dashed"
197
+ ? [visual.dashLength, visual.dashLength]
198
+ : undefined,
199
+ selectable: false,
200
+ evented: false,
201
+ excludeFromExport: true,
202
+ },
203
+ });
204
+
205
+ return specs;
206
+ }
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);
@@ -288,6 +289,46 @@ function testVisibilityDsl() {
288
289
  );
289
290
  }
290
291
 
292
+ function testImageViewStateHelper() {
293
+ assert(hasAnyImageInViewState(null) === false, "null image state should be empty");
294
+ assert(
295
+ hasAnyImageInViewState({
296
+ items: [],
297
+ hasAnyImage: false,
298
+ focusedId: null,
299
+ focusedItem: null,
300
+ isToolActive: false,
301
+ isImageSelectionActive: false,
302
+ hasWorkingChanges: false,
303
+ source: "committed",
304
+ }) === false,
305
+ "empty image state should report false",
306
+ );
307
+ assert(
308
+ hasAnyImageInViewState({
309
+ items: [
310
+ {
311
+ id: "img-1",
312
+ url: "blob:test",
313
+ opacity: 1,
314
+ },
315
+ ],
316
+ hasAnyImage: true,
317
+ focusedId: "img-1",
318
+ focusedItem: {
319
+ id: "img-1",
320
+ url: "blob:test",
321
+ opacity: 1,
322
+ },
323
+ isToolActive: true,
324
+ isImageSelectionActive: true,
325
+ hasWorkingChanges: true,
326
+ source: "working",
327
+ }) === true,
328
+ "non-empty image state should report true",
329
+ );
330
+ }
331
+
291
332
  function testContributionCompatibility() {
292
333
  const imageCommandNames = createImageCommands({} as any).map(
293
334
  (entry) => entry.command,
@@ -303,13 +344,12 @@ function testContributionCompatibility() {
303
344
  const expectedImageCommands = [
304
345
  "addImage",
305
346
  "upsertImage",
306
- "getWorkingImages",
307
- "setWorkingImage",
308
- "resetWorkingImages",
347
+ "applyImageOperation",
348
+ "getImageViewState",
349
+ "setImageTransform",
350
+ "imageSessionReset",
309
351
  "completeImages",
310
352
  "exportUserCroppedImage",
311
- "fitImageToArea",
312
- "fitImageToDefaultArea",
313
353
  "focusImage",
314
354
  "removeImage",
315
355
  "updateImage",
@@ -432,9 +472,10 @@ function main() {
432
472
  testBridgeSelection();
433
473
  testMaskOps();
434
474
  testEdgeScale();
435
- testFeaturePlacementProjection();
436
- testVisibilityDsl();
437
- testContributionCompatibility();
475
+ testFeaturePlacementProjection();
476
+ testVisibilityDsl();
477
+ testImageViewStateHelper();
478
+ testContributionCompatibility();
438
479
  console.log("ok");
439
480
  }
440
481