@pooder/kit 5.1.0 → 5.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pooder/kit",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "Standard plugins for Pooder editor",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -8,9 +8,19 @@ import {
8
8
  ToolSessionService,
9
9
  WorkbenchService,
10
10
  } from "@pooder/core";
11
- import { Canvas as FabricCanvas, Image as FabricImage, Point } from "fabric";
11
+ import {
12
+ Canvas as FabricCanvas,
13
+ Image as FabricImage,
14
+ Pattern,
15
+ Point,
16
+ } from "fabric";
12
17
  import { CanvasService, RenderObjectSpec } from "../services";
13
- import { computeSceneLayout, readSizeState } from "./sceneLayoutModel";
18
+ import { generateDielinePath } from "./geometry";
19
+ import {
20
+ buildSceneGeometry,
21
+ computeSceneLayout,
22
+ readSizeState,
23
+ } from "./sceneLayoutModel";
14
24
 
15
25
  export interface ImageItem {
16
26
  id: string;
@@ -54,6 +64,15 @@ interface FrameVisualConfig {
54
64
  outerBackground: string;
55
65
  }
56
66
 
67
+ type DielineShape = "rect" | "circle" | "ellipse" | "custom";
68
+ type ShapeOverlayShape = Exclude<DielineShape, "custom">;
69
+
70
+ interface SceneGeometryLike {
71
+ shape: DielineShape;
72
+ radius: number;
73
+ offset: number;
74
+ }
75
+
57
76
  interface UpsertImageOptions {
58
77
  id?: string;
59
78
  mode?: "replace" | "add";
@@ -113,6 +132,8 @@ export class ImageTool implements Extension {
113
132
  private focusedImageId: string | null = null;
114
133
  private renderSeq = 0;
115
134
  private dirtyTrackerDisposable?: { dispose(): void };
135
+ private cropShapeHatchPattern?: Pattern;
136
+ private cropShapeHatchPatternColor?: string;
116
137
 
117
138
  activate(context: ExtensionContext) {
118
139
  this.context = context;
@@ -128,6 +149,7 @@ export class ImageTool implements Extension {
128
149
  context.eventBus.on("selection:updated", this.onSelectionChanged);
129
150
  context.eventBus.on("selection:cleared", this.onSelectionCleared);
130
151
  context.eventBus.on("scene:layout:change", this.onSceneLayoutChanged);
152
+ context.eventBus.on("scene:geometry:change", this.onSceneGeometryChanged);
131
153
 
132
154
  const configService = context.services.get<ConfigurationService>(
133
155
  "ConfigurationService",
@@ -175,8 +197,11 @@ export class ImageTool implements Extension {
175
197
  context.eventBus.off("selection:updated", this.onSelectionChanged);
176
198
  context.eventBus.off("selection:cleared", this.onSelectionCleared);
177
199
  context.eventBus.off("scene:layout:change", this.onSceneLayoutChanged);
200
+ context.eventBus.off("scene:geometry:change", this.onSceneGeometryChanged);
178
201
  this.dirtyTrackerDisposable?.dispose();
179
202
  this.dirtyTrackerDisposable = undefined;
203
+ this.cropShapeHatchPattern = undefined;
204
+ this.cropShapeHatchPatternColor = undefined;
180
205
 
181
206
  this.clearRenderedImages();
182
207
  if (this.canvasService) {
@@ -258,6 +283,10 @@ export class ImageTool implements Extension {
258
283
  this.updateImages();
259
284
  };
260
285
 
286
+ private onSceneGeometryChanged = () => {
287
+ this.updateImages();
288
+ };
289
+
261
290
  private syncToolActiveFromWorkbench(fallbackId?: string | null) {
262
291
  const wb = this.context?.services.get<WorkbenchService>("WorkbenchService");
263
292
  const activeId = wb?.activeToolId;
@@ -415,9 +444,7 @@ export class ImageTool implements Extension {
415
444
  {
416
445
  command: "exportUserCroppedImage",
417
446
  title: "Export User Cropped Image",
418
- handler: async (
419
- options: ExportUserCroppedImageOptions = {},
420
- ) => {
447
+ handler: async (options: ExportUserCroppedImageOptions = {}) => {
421
448
  return await this.exportUserCroppedImage(options);
422
449
  },
423
450
  },
@@ -894,10 +921,268 @@ export class ImageTool implements Extension {
894
921
  "image.frame.innerBackground",
895
922
  "rgba(0,0,0,0)",
896
923
  ) || "rgba(0,0,0,0)",
897
- outerBackground: "#f5f5f5",
924
+ outerBackground:
925
+ this.getConfig<string>("image.frame.outerBackground", "#f5f5f5") ||
926
+ "#f5f5f5",
898
927
  };
899
928
  }
900
929
 
930
+ private toSceneGeometryLike(raw: any): SceneGeometryLike | null {
931
+ const shape = raw?.shape;
932
+ if (
933
+ shape !== "rect" &&
934
+ shape !== "circle" &&
935
+ shape !== "ellipse" &&
936
+ shape !== "custom"
937
+ ) {
938
+ return null;
939
+ }
940
+
941
+ const radius = Number(raw?.radius);
942
+ const offset = Number(raw?.offset);
943
+ return {
944
+ shape,
945
+ radius: Number.isFinite(radius) ? radius : 0,
946
+ offset: Number.isFinite(offset) ? offset : 0,
947
+ };
948
+ }
949
+
950
+ private async resolveSceneGeometryForOverlay(): Promise<SceneGeometryLike | null> {
951
+ if (!this.context) return null;
952
+ const commandService = this.context.services.get<any>("CommandService");
953
+ if (commandService) {
954
+ try {
955
+ const raw = await Promise.resolve(
956
+ commandService.executeCommand("getSceneGeometry"),
957
+ );
958
+ const geometry = this.toSceneGeometryLike(raw);
959
+ if (geometry) {
960
+ this.debug("overlay:sceneGeometry:command", geometry);
961
+ return geometry;
962
+ }
963
+ this.debug("overlay:sceneGeometry:command:invalid", { raw });
964
+ } catch (error) {
965
+ this.debug("overlay:sceneGeometry:command:error", {
966
+ error: error instanceof Error ? error.message : String(error),
967
+ });
968
+ }
969
+ }
970
+
971
+ if (!this.canvasService) return null;
972
+ const configService = this.context.services.get<ConfigurationService>(
973
+ "ConfigurationService",
974
+ );
975
+ if (!configService) return null;
976
+
977
+ const sizeState = readSizeState(configService);
978
+ const layout = computeSceneLayout(this.canvasService, sizeState);
979
+ if (!layout) {
980
+ this.debug("overlay:sceneGeometry:fallback:missing-layout");
981
+ return null;
982
+ }
983
+
984
+ const geometry = this.toSceneGeometryLike(
985
+ buildSceneGeometry(configService, layout),
986
+ );
987
+ if (geometry) {
988
+ this.debug("overlay:sceneGeometry:fallback", geometry);
989
+ }
990
+ return geometry;
991
+ }
992
+
993
+ private resolveCutShapeRadius(
994
+ geometry: SceneGeometryLike,
995
+ frame: FrameRect,
996
+ ): number {
997
+ const visualRadius = Number.isFinite(geometry.radius)
998
+ ? Math.max(0, geometry.radius)
999
+ : 0;
1000
+ const visualOffset = Number.isFinite(geometry.offset) ? geometry.offset : 0;
1001
+ const rawCutRadius =
1002
+ visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
1003
+ const maxRadius = Math.max(0, Math.min(frame.width, frame.height) / 2);
1004
+ return Math.max(0, Math.min(maxRadius, rawCutRadius));
1005
+ }
1006
+
1007
+ private getCropShapeHatchPattern(
1008
+ color = "rgba(255, 0, 0, 0.6)",
1009
+ ): Pattern | undefined {
1010
+ if (typeof document === "undefined") return undefined;
1011
+ if (
1012
+ this.cropShapeHatchPattern &&
1013
+ this.cropShapeHatchPatternColor === color
1014
+ ) {
1015
+ return this.cropShapeHatchPattern;
1016
+ }
1017
+
1018
+ const size = 16;
1019
+ const patternCanvas = document.createElement("canvas");
1020
+ patternCanvas.width = size;
1021
+ patternCanvas.height = size;
1022
+ const ctx = patternCanvas.getContext("2d");
1023
+ if (!ctx) return undefined;
1024
+
1025
+ ctx.clearRect(0, 0, size, size);
1026
+ ctx.fillStyle = "rgba(255, 0, 0, 0.08)";
1027
+ ctx.fillRect(0, 0, size, size);
1028
+ ctx.strokeStyle = color;
1029
+ ctx.lineWidth = 1.5;
1030
+ ctx.beginPath();
1031
+ ctx.moveTo(-size, size);
1032
+ ctx.lineTo(size, -size);
1033
+ ctx.moveTo(-size / 2, size + size / 2);
1034
+ ctx.lineTo(size + size / 2, -size / 2);
1035
+ ctx.moveTo(0, size);
1036
+ ctx.lineTo(size, 0);
1037
+ ctx.moveTo(size / 2, size + size / 2);
1038
+ ctx.lineTo(size + size + size / 2, -size / 2);
1039
+ ctx.stroke();
1040
+
1041
+ const pattern = new Pattern({
1042
+ source: patternCanvas,
1043
+ // @ts-ignore: Fabric Pattern accepts canvas source here.
1044
+ repetition: "repeat",
1045
+ });
1046
+ this.cropShapeHatchPattern = pattern;
1047
+ this.cropShapeHatchPatternColor = color;
1048
+ return pattern;
1049
+ }
1050
+
1051
+ private buildCropShapeOverlaySpecs(
1052
+ frame: FrameRect,
1053
+ sceneGeometry: SceneGeometryLike | null,
1054
+ ): RenderObjectSpec[] {
1055
+ if (!sceneGeometry) {
1056
+ this.debug("overlay:shape:skip", { reason: "scene-geometry-missing" });
1057
+ return [];
1058
+ }
1059
+ if (sceneGeometry.shape === "custom") {
1060
+ this.debug("overlay:shape:skip", { reason: "shape-custom" });
1061
+ return [];
1062
+ }
1063
+
1064
+ const shape = sceneGeometry.shape as ShapeOverlayShape;
1065
+ const inset = 0;
1066
+ const shapeWidth = Math.max(1, frame.width);
1067
+ const shapeHeight = Math.max(1, frame.height);
1068
+ const radius = this.resolveCutShapeRadius(sceneGeometry, frame);
1069
+
1070
+ this.debug("overlay:shape:geometry", {
1071
+ shape,
1072
+ frameWidth: frame.width,
1073
+ frameHeight: frame.height,
1074
+ offset: sceneGeometry.offset,
1075
+ inset,
1076
+ shapeWidth,
1077
+ shapeHeight,
1078
+ baseRadius: sceneGeometry.radius,
1079
+ radius,
1080
+ });
1081
+
1082
+ const isSameAsFrame =
1083
+ Math.abs(shapeWidth - frame.width) <= 0.0001 &&
1084
+ Math.abs(shapeHeight - frame.height) <= 0.0001;
1085
+ if (shape === "rect" && radius <= 0.0001 && isSameAsFrame) {
1086
+ this.debug("overlay:shape:skip", {
1087
+ reason: "shape-rect-no-radius",
1088
+ });
1089
+ return [];
1090
+ }
1091
+
1092
+ const baseOptions = {
1093
+ shape,
1094
+ width: shapeWidth,
1095
+ height: shapeHeight,
1096
+ radius,
1097
+ x: frame.width / 2,
1098
+ y: frame.height / 2,
1099
+ features: [],
1100
+ canvasWidth: frame.width,
1101
+ canvasHeight: frame.height,
1102
+ };
1103
+
1104
+ try {
1105
+ const shapePathData = generateDielinePath(baseOptions);
1106
+ const outerRectPathData = `M 0 0 L ${frame.width} 0 L ${frame.width} ${frame.height} L 0 ${frame.height} Z`;
1107
+ const hatchPathData = `${outerRectPathData} ${shapePathData}`;
1108
+ if (!shapePathData || !hatchPathData) {
1109
+ this.debug("overlay:shape:skip", {
1110
+ reason: "path-generation-empty",
1111
+ shape,
1112
+ radius,
1113
+ });
1114
+ return [];
1115
+ }
1116
+
1117
+ const patternFill = this.getCropShapeHatchPattern();
1118
+ const hatchFill = patternFill || "rgba(255, 0, 0, 0.22)";
1119
+ const hatchPathLength = hatchPathData.length;
1120
+ const shapePathLength = shapePathData.length;
1121
+ const specs: RenderObjectSpec[] = [
1122
+ {
1123
+ id: "image.cropShapeHatch",
1124
+ type: "path",
1125
+ data: { id: "image.cropShapeHatch", zIndex: 5 },
1126
+ props: {
1127
+ pathData: hatchPathData,
1128
+ left: frame.left,
1129
+ top: frame.top,
1130
+ originX: "left",
1131
+ originY: "top",
1132
+ fill: hatchFill,
1133
+ opacity: patternFill ? 1 : 0.8,
1134
+ stroke: null,
1135
+ fillRule: "evenodd",
1136
+ selectable: false,
1137
+ evented: false,
1138
+ excludeFromExport: true,
1139
+ objectCaching: false,
1140
+ },
1141
+ },
1142
+ {
1143
+ id: "image.cropShapePath",
1144
+ type: "path",
1145
+ data: { id: "image.cropShapePath", zIndex: 6 },
1146
+ props: {
1147
+ pathData: shapePathData,
1148
+ left: frame.left,
1149
+ top: frame.top,
1150
+ originX: "left",
1151
+ originY: "top",
1152
+ fill: "rgba(0,0,0,0)",
1153
+ stroke: "rgba(255, 0, 0, 0.9)",
1154
+ strokeWidth: 1,
1155
+ selectable: false,
1156
+ evented: false,
1157
+ excludeFromExport: true,
1158
+ objectCaching: false,
1159
+ },
1160
+ },
1161
+ ];
1162
+ this.debug("overlay:shape:built", {
1163
+ shape,
1164
+ radius,
1165
+ inset,
1166
+ shapeWidth,
1167
+ shapeHeight,
1168
+ fillRule: "evenodd",
1169
+ shapePathLength,
1170
+ hatchPathLength,
1171
+ hatchFillType:
1172
+ hatchFill && typeof hatchFill === "object" ? "pattern" : "color",
1173
+ ids: specs.map((spec) => spec.id),
1174
+ });
1175
+ return specs;
1176
+ } catch (error) {
1177
+ this.debug("overlay:shape:error", {
1178
+ shape,
1179
+ radius,
1180
+ error: error instanceof Error ? error.message : String(error),
1181
+ });
1182
+ return [];
1183
+ }
1184
+ }
1185
+
901
1186
  private resolveRenderImageState(item: ImageItem): RenderImageState {
902
1187
  const active = this.isToolActive;
903
1188
  const sourceUrl = item.sourceUrl || item.url;
@@ -962,6 +1247,21 @@ export class ImageTool implements Extension {
962
1247
  return obj?._originalElement?.src;
963
1248
  }
964
1249
 
1250
+ private applyImageControlVisibility(obj: any) {
1251
+ if (typeof obj?.setControlsVisibility !== "function") return;
1252
+ obj.setControlsVisibility({
1253
+ mt: false,
1254
+ mb: false,
1255
+ ml: false,
1256
+ mr: false,
1257
+ tl: true,
1258
+ tr: true,
1259
+ bl: true,
1260
+ br: true,
1261
+ mtr: true,
1262
+ });
1263
+ }
1264
+
965
1265
  private async upsertImageObject(
966
1266
  item: ImageItem,
967
1267
  frame: FrameRect,
@@ -1010,6 +1310,7 @@ export class ImageTool implements Extension {
1010
1310
  type: "image-item",
1011
1311
  },
1012
1312
  });
1313
+ this.applyImageControlVisibility(obj);
1013
1314
  obj.setCoords();
1014
1315
 
1015
1316
  const resolver = this.loadResolvers.get(item.id);
@@ -1047,9 +1348,25 @@ export class ImageTool implements Extension {
1047
1348
  overlayObjects.forEach((obj) => {
1048
1349
  canvas.bringObjectToFront(obj);
1049
1350
  });
1351
+
1352
+ if (this.isDebugEnabled()) {
1353
+ const stack = canvas
1354
+ .getObjects()
1355
+ .map((obj: any, index: number) => ({
1356
+ index,
1357
+ id: obj?.data?.id,
1358
+ layerId: obj?.data?.layerId,
1359
+ zIndex: obj?.data?.zIndex,
1360
+ }))
1361
+ .filter((item) => item.layerId === IMAGE_OVERLAY_LAYER_ID);
1362
+ this.debug("overlay:stack", stack);
1363
+ }
1050
1364
  }
1051
1365
 
1052
- private buildOverlaySpecs(frame: FrameRect): RenderObjectSpec[] {
1366
+ private buildOverlaySpecs(
1367
+ frame: FrameRect,
1368
+ sceneGeometry: SceneGeometryLike | null,
1369
+ ): RenderObjectSpec[] {
1053
1370
  const visible = this.isImageEditingVisible();
1054
1371
  if (
1055
1372
  !visible ||
@@ -1087,6 +1404,7 @@ export class ImageTool implements Extension {
1087
1404
  const bottomH = Math.max(0, canvasH - frameBottom);
1088
1405
  const leftW = frameLeft;
1089
1406
  const rightW = Math.max(0, canvasW - frameRight);
1407
+ const shapeOverlay = this.buildCropShapeOverlaySpecs(frame, sceneGeometry);
1090
1408
 
1091
1409
  const mask: RenderObjectSpec[] = [
1092
1410
  {
@@ -1158,7 +1476,7 @@ export class ImageTool implements Extension {
1158
1476
  const frameSpec: RenderObjectSpec = {
1159
1477
  id: "image.cropFrame",
1160
1478
  type: "rect",
1161
- data: { id: "image.cropFrame", zIndex: 5 },
1479
+ data: { id: "image.cropFrame", zIndex: 7 },
1162
1480
  props: {
1163
1481
  left: frame.left + frame.width / 2,
1164
1482
  top: frame.top + frame.height / 2,
@@ -1181,7 +1499,16 @@ export class ImageTool implements Extension {
1181
1499
  },
1182
1500
  };
1183
1501
 
1184
- return [...mask, frameSpec];
1502
+ const specs = [...mask, ...shapeOverlay, frameSpec];
1503
+ this.debug("overlay:built", {
1504
+ frame,
1505
+ shape: sceneGeometry?.shape,
1506
+ overlayIds: specs.map((spec) => ({
1507
+ id: spec.id,
1508
+ zIndex: spec.data?.zIndex,
1509
+ })),
1510
+ });
1511
+ return specs;
1185
1512
  }
1186
1513
 
1187
1514
  private updateImages() {
@@ -1217,7 +1544,10 @@ export class ImageTool implements Extension {
1217
1544
  if (seq !== this.renderSeq) return;
1218
1545
 
1219
1546
  this.syncImageZOrder(renderItems);
1220
- const overlaySpecs = this.buildOverlaySpecs(frame);
1547
+ const sceneGeometry = await this.resolveSceneGeometryForOverlay();
1548
+ if (seq !== this.renderSeq) return;
1549
+
1550
+ const overlaySpecs = this.buildOverlaySpecs(frame, sceneGeometry);
1221
1551
  await this.canvasService.applyObjectSpecsToRootLayer(
1222
1552
  IMAGE_OVERLAY_LAYER_ID,
1223
1553
  overlaySpecs,