@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.
@@ -14,24 +14,15 @@ import {
14
14
  Point,
15
15
  controlsUtils,
16
16
  } from "fabric";
17
- import {
18
- CanvasService,
19
- RenderLayoutRect,
20
- RenderObjectSpec,
21
- } from "../../services";
22
- import { isDielineShape, normalizeShapeStyle } from "../dielineShape";
23
- import type { DielineShape, DielineShapeStyle } from "../dielineShape";
24
- import { generateDielinePath, getPathBounds } from "../geometry";
17
+ import { CanvasService, RenderObjectSpec } from "../../services";
25
18
  import {
26
19
  buildSceneGeometry,
27
20
  computeSceneLayout,
28
21
  readSizeState,
22
+ type SceneGeometrySnapshot,
23
+ type SceneLayoutSnapshot,
29
24
  } from "../../shared/scene/sceneLayoutModel";
30
- import {
31
- type FrameRect,
32
- resolveCutFrameRect,
33
- toLayoutSceneRect as toSceneLayoutRect,
34
- } from "../../shared/scene/frame";
25
+ import { type FrameRect, resolveCutFrameRect } from "../../shared/scene/frame";
35
26
  import {
36
27
  createSourceSizeCache,
37
28
  getCoverScale as getCoverScaleFromRect,
@@ -48,6 +39,12 @@ import {
48
39
  } from "../../shared/constants/layers";
49
40
  import { createImageCommands } from "./commands";
50
41
  import { createImageConfigurations } from "./config";
42
+ import {
43
+ computeImageOperationUpdates,
44
+ resolveImageOperationArea,
45
+ type ImageOperation,
46
+ } from "./imageOperations";
47
+ import { buildImageSessionOverlaySpecs } from "./sessionOverlay";
51
48
 
52
49
  export interface ImageItem {
53
50
  id: string;
@@ -61,6 +58,25 @@ export interface ImageItem {
61
58
  committedUrl?: string;
62
59
  }
63
60
 
61
+ export interface ImageTransformUpdates {
62
+ scale?: number;
63
+ angle?: number;
64
+ left?: number;
65
+ top?: number;
66
+ opacity?: number;
67
+ }
68
+
69
+ export interface ImageViewState {
70
+ items: ImageItem[];
71
+ hasAnyImage: boolean;
72
+ focusedId: string | null;
73
+ focusedItem: ImageItem | null;
74
+ isToolActive: boolean;
75
+ isImageSelectionActive: boolean;
76
+ hasWorkingChanges: boolean;
77
+ source: "working" | "committed";
78
+ }
79
+
64
80
  interface RenderImageState {
65
81
  src: string;
66
82
  left: number;
@@ -91,27 +107,16 @@ interface ImageControlVisualConfig {
91
107
  padding: number;
92
108
  }
93
109
 
94
- type ShapeOverlayShape = Exclude<DielineShape, "custom">;
95
-
96
- interface SceneGeometryLike {
97
- shape: DielineShape;
98
- shapeStyle: DielineShapeStyle;
99
- radius: number;
100
- offset: number;
110
+ interface ImageSessionOverlayState {
111
+ layout: SceneLayoutSnapshot;
112
+ geometry: SceneGeometrySnapshot;
101
113
  }
102
114
 
103
115
  interface UpsertImageOptions {
104
116
  id?: string;
105
117
  mode?: "replace" | "add";
106
118
  addOptions?: Partial<ImageItem>;
107
- fitOnAdd?: boolean;
108
- }
109
-
110
- interface DielineFitArea {
111
- width: number;
112
- height: number;
113
- left: number;
114
- top: number;
119
+ operation?: ImageOperation;
115
120
  }
116
121
 
117
122
  interface UpdateImageOptions {
@@ -382,6 +387,7 @@ export class ImageTool implements Extension {
382
387
  this.clearRenderedImages();
383
388
  this.renderProducerDisposable?.dispose();
384
389
  this.renderProducerDisposable = undefined;
390
+ this.emitImageStateChange();
385
391
  if (this.canvasService) {
386
392
  void this.canvasService.flushRenderFromProducers();
387
393
  this.canvasService = undefined;
@@ -663,10 +669,21 @@ export class ImageTool implements Extension {
663
669
  }
664
670
  }
665
671
 
672
+ private clearSnapGuideContext() {
673
+ const topContext = this.canvasService?.canvas.contextTop;
674
+ if (!this.canvasService || !topContext) return;
675
+ this.canvasService.canvas.clearContext(topContext);
676
+ }
677
+
666
678
  private clearSnapPreview() {
679
+ const shouldClearCanvas =
680
+ this.hasRenderedSnapGuides || !!this.activeSnapX || !!this.activeSnapY;
667
681
  this.activeSnapX = null;
668
682
  this.activeSnapY = null;
669
683
  this.hasRenderedSnapGuides = false;
684
+ if (shouldClearCanvas) {
685
+ this.clearSnapGuideContext();
686
+ }
670
687
  this.canvasService?.requestRenderAll();
671
688
  }
672
689
 
@@ -933,9 +950,9 @@ export class ImageTool implements Extension {
933
950
  name: "Image",
934
951
  interaction: "session",
935
952
  commands: {
936
- begin: "resetWorkingImages",
953
+ begin: "imageSessionReset",
937
954
  commit: "completeImages",
938
- rollback: "resetWorkingImages",
955
+ rollback: "imageSessionReset",
939
956
  },
940
957
  session: {
941
958
  autoBegin: true,
@@ -980,6 +997,34 @@ export class ImageTool implements Extension {
980
997
  return this.normalizeItems((items || []).map((i) => ({ ...i })));
981
998
  }
982
999
 
1000
+ private getViewItems(): ImageItem[] {
1001
+ return this.isToolActive ? this.workingItems : this.items;
1002
+ }
1003
+
1004
+ private getImageViewState(): ImageViewState {
1005
+ this.syncToolActiveFromWorkbench();
1006
+ const items = this.cloneItems(this.getViewItems());
1007
+ const focusedItem =
1008
+ this.focusedImageId == null
1009
+ ? null
1010
+ : items.find((item) => item.id === this.focusedImageId) || null;
1011
+
1012
+ return {
1013
+ items,
1014
+ hasAnyImage: items.length > 0,
1015
+ focusedId: this.focusedImageId,
1016
+ focusedItem,
1017
+ isToolActive: this.isToolActive,
1018
+ isImageSelectionActive: this.isImageSelectionActive,
1019
+ hasWorkingChanges: this.hasWorkingChanges,
1020
+ source: this.isToolActive ? "working" : "committed",
1021
+ };
1022
+ }
1023
+
1024
+ private emitImageStateChange() {
1025
+ this.context?.eventBus.emit("image:state:change", this.getImageViewState());
1026
+ }
1027
+
983
1028
  private emitWorkingChange(changedId: string | null = null) {
984
1029
  this.context?.eventBus.emit("image:working:change", {
985
1030
  changedId,
@@ -1027,6 +1072,8 @@ export class ImageTool implements Extension {
1027
1072
 
1028
1073
  if (!options.skipRender) {
1029
1074
  this.updateImages();
1075
+ } else {
1076
+ this.emitImageStateChange();
1030
1077
  }
1031
1078
 
1032
1079
  return { ok: true, id };
@@ -1035,8 +1082,9 @@ export class ImageTool implements Extension {
1035
1082
  private async addImageEntry(
1036
1083
  url: string,
1037
1084
  options?: Partial<ImageItem>,
1038
- fitOnAdd = true,
1085
+ operation?: ImageOperation,
1039
1086
  ): Promise<string> {
1087
+ this.syncToolActiveFromWorkbench();
1040
1088
  const id = this.generateId();
1041
1089
  const newItem = this.normalizeItem({
1042
1090
  id,
@@ -1045,13 +1093,20 @@ export class ImageTool implements Extension {
1045
1093
  ...options,
1046
1094
  } as ImageItem);
1047
1095
 
1048
- const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
1049
1096
  const waitLoaded = this.waitImageLoaded(id, true);
1050
- this.updateConfig([...this.items, newItem]);
1051
- this.addItemToWorkingSessionIfNeeded(newItem, sessionDirtyBeforeAdd);
1097
+ if (this.isToolActive) {
1098
+ this.workingItems = this.cloneItems([...this.workingItems, newItem]);
1099
+ this.hasWorkingChanges = true;
1100
+ this.updateImages();
1101
+ this.emitWorkingChange(id);
1102
+ } else {
1103
+ this.updateConfig([...this.items, newItem]);
1104
+ }
1052
1105
  const loaded = await waitLoaded;
1053
- if (loaded && fitOnAdd) {
1054
- await this.fitImageToDefaultArea(id);
1106
+ if (loaded && operation) {
1107
+ await this.applyImageOperation(id, operation, {
1108
+ target: this.isToolActive ? "working" : "config",
1109
+ });
1055
1110
  }
1056
1111
  if (loaded) {
1057
1112
  this.setImageFocus(id);
@@ -1063,8 +1118,8 @@ export class ImageTool implements Extension {
1063
1118
  url: string,
1064
1119
  options: UpsertImageOptions = {},
1065
1120
  ): Promise<{ id: string; mode: "replace" | "add" }> {
1121
+ this.syncToolActiveFromWorkbench();
1066
1122
  const mode = options.mode || (options.id ? "replace" : "add");
1067
- const fitOnAdd = options.fitOnAdd !== false;
1068
1123
  if (mode === "replace") {
1069
1124
  if (!options.id) {
1070
1125
  throw new Error("replace-target-id-required");
@@ -1073,25 +1128,35 @@ export class ImageTool implements Extension {
1073
1128
  if (!this.hasImageItem(targetId)) {
1074
1129
  throw new Error("replace-target-not-found");
1075
1130
  }
1076
- await this.updateImageInConfig(targetId, { url });
1131
+ if (this.isToolActive) {
1132
+ const current =
1133
+ this.workingItems.find((item) => item.id === targetId) ||
1134
+ this.items.find((item) => item.id === targetId);
1135
+ this.purgeSourceSizeCacheForItem(current);
1136
+ this.updateImageInWorking(targetId, {
1137
+ url,
1138
+ sourceUrl: url,
1139
+ committedUrl: undefined,
1140
+ });
1141
+ } else {
1142
+ await this.updateImageInConfig(targetId, { url });
1143
+ }
1144
+ const loaded = await this.waitImageLoaded(targetId, true);
1145
+ if (loaded && options.operation) {
1146
+ await this.applyImageOperation(targetId, options.operation, {
1147
+ target: this.isToolActive ? "working" : "config",
1148
+ });
1149
+ }
1150
+ if (loaded) {
1151
+ this.setImageFocus(targetId);
1152
+ }
1077
1153
  return { id: targetId, mode: "replace" };
1078
1154
  }
1079
1155
 
1080
- const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
1156
+ const id = await this.addImageEntry(url, options.addOptions, options.operation);
1081
1157
  return { id, mode: "add" };
1082
1158
  }
1083
1159
 
1084
- private addItemToWorkingSessionIfNeeded(
1085
- item: ImageItem,
1086
- sessionDirtyBeforeAdd: boolean,
1087
- ) {
1088
- if (!sessionDirtyBeforeAdd || !this.isToolActive) return;
1089
- if (this.workingItems.some((existing) => existing.id === item.id)) return;
1090
- this.workingItems = this.cloneItems([...this.workingItems, item]);
1091
- this.updateImages();
1092
- this.emitWorkingChange(item.id);
1093
- }
1094
-
1095
1160
  private async updateImage(
1096
1161
  id: string,
1097
1162
  updates: Partial<ImageItem>,
@@ -1166,42 +1231,6 @@ export class ImageTool implements Extension {
1166
1231
  return this.canvasService.toScreenRect(frame || this.getFrameRect());
1167
1232
  }
1168
1233
 
1169
- private toLayoutSceneRect(rect: FrameRect): RenderLayoutRect {
1170
- return toSceneLayoutRect(rect);
1171
- }
1172
-
1173
- private async resolveDefaultFitArea(): Promise<DielineFitArea | null> {
1174
- if (!this.canvasService) return null;
1175
- const frame = this.getFrameRect();
1176
- if (frame.width <= 0 || frame.height <= 0) return null;
1177
- return {
1178
- width: Math.max(1, frame.width),
1179
- height: Math.max(1, frame.height),
1180
- left: frame.left + frame.width / 2,
1181
- top: frame.top + frame.height / 2,
1182
- };
1183
- }
1184
-
1185
- private async fitImageToDefaultArea(id: string) {
1186
- if (!this.canvasService) return;
1187
- const area = await this.resolveDefaultFitArea();
1188
-
1189
- if (area) {
1190
- await this.fitImageToArea(id, area);
1191
- return;
1192
- }
1193
-
1194
- const viewport = this.canvasService.getSceneViewportRect();
1195
- const canvasW = Math.max(1, viewport.width || 0);
1196
- const canvasH = Math.max(1, viewport.height || 0);
1197
- await this.fitImageToArea(id, {
1198
- width: canvasW,
1199
- height: canvasH,
1200
- left: viewport.left + canvasW / 2,
1201
- top: viewport.top + canvasH / 2,
1202
- });
1203
- }
1204
-
1205
1234
  private getImageObjects(): any[] {
1206
1235
  if (!this.canvasService) return [];
1207
1236
  return this.canvasService.canvas.getObjects().filter((obj: any) => {
@@ -1319,94 +1348,42 @@ export class ImageTool implements Extension {
1319
1348
  };
1320
1349
  }
1321
1350
 
1322
- private toSceneGeometryLike(raw: any): SceneGeometryLike | null {
1323
- const shape = raw?.shape;
1324
- if (!isDielineShape(shape)) {
1351
+ private resolveSessionOverlayState(): ImageSessionOverlayState | null {
1352
+ if (!this.canvasService || !this.context) {
1325
1353
  return null;
1326
1354
  }
1327
-
1328
- const radiusRaw = Number(raw?.radius);
1329
- const offsetRaw = Number(raw?.offset);
1330
- const unit = typeof raw?.unit === "string" ? raw.unit : "px";
1331
- const radius =
1332
- unit === "scene" || !this.canvasService
1333
- ? radiusRaw
1334
- : this.canvasService.toSceneLength(radiusRaw);
1335
- const offset =
1336
- unit === "scene" || !this.canvasService
1337
- ? offsetRaw
1338
- : this.canvasService.toSceneLength(offsetRaw);
1339
- return {
1340
- shape,
1341
- shapeStyle: normalizeShapeStyle(raw?.shapeStyle),
1342
- radius: Number.isFinite(radius) ? radius : 0,
1343
- offset: Number.isFinite(offset) ? offset : 0,
1344
- };
1345
- }
1346
-
1347
- private async resolveSceneGeometryForOverlay(): Promise<SceneGeometryLike | null> {
1348
- if (!this.context) return null;
1349
- const commandService = this.context.services.get<any>("CommandService");
1350
- if (commandService) {
1351
- try {
1352
- const raw = await Promise.resolve(
1353
- commandService.executeCommand("getSceneGeometry"),
1354
- );
1355
- const geometry = this.toSceneGeometryLike(raw);
1356
- if (geometry) {
1357
- this.debug("overlay:sceneGeometry:command", geometry);
1358
- return geometry;
1359
- }
1360
- this.debug("overlay:sceneGeometry:command:invalid", { raw });
1361
- } catch (error) {
1362
- this.debug("overlay:sceneGeometry:command:error", {
1363
- error: error instanceof Error ? error.message : String(error),
1364
- });
1365
- }
1366
- }
1367
-
1368
- if (!this.canvasService) return null;
1369
1355
  const configService = this.context.services.get<ConfigurationService>(
1370
1356
  "ConfigurationService",
1371
1357
  );
1372
- if (!configService) return null;
1373
-
1374
- const sizeState = readSizeState(configService);
1375
- const layout = computeSceneLayout(this.canvasService, sizeState);
1376
- if (!layout) {
1377
- this.debug("overlay:sceneGeometry:fallback:missing-layout");
1358
+ if (!configService) {
1378
1359
  return null;
1379
1360
  }
1380
1361
 
1381
- const geometry = this.toSceneGeometryLike(
1382
- buildSceneGeometry(configService, layout),
1362
+ const layout = computeSceneLayout(
1363
+ this.canvasService,
1364
+ readSizeState(configService),
1383
1365
  );
1384
- if (geometry) {
1385
- this.debug("overlay:sceneGeometry:fallback", geometry);
1366
+ if (!layout) {
1367
+ this.debug("overlay:layout:missing");
1368
+ return null;
1386
1369
  }
1387
- return geometry;
1388
- }
1389
1370
 
1390
- private resolveCutShapeRadius(
1391
- geometry: SceneGeometryLike,
1392
- frame: FrameRect,
1393
- ): number {
1394
- const visualRadius = Number.isFinite(geometry.radius)
1395
- ? Math.max(0, geometry.radius)
1396
- : 0;
1397
- const visualOffset = Number.isFinite(geometry.offset) ? geometry.offset : 0;
1398
- const rawCutRadius =
1399
- visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
1400
- const maxRadius = Math.max(0, Math.min(frame.width, frame.height) / 2);
1401
- return Math.max(0, Math.min(maxRadius, rawCutRadius));
1371
+ const geometry = buildSceneGeometry(configService, layout);
1372
+ this.debug("overlay:state:resolved", {
1373
+ cutRect: layout.cutRect,
1374
+ shape: geometry.shape,
1375
+ shapeStyle: geometry.shapeStyle,
1376
+ radius: geometry.radius,
1377
+ offset: geometry.offset,
1378
+ });
1379
+ return { layout, geometry };
1402
1380
  }
1403
1381
 
1404
1382
  private getCropShapeHatchPattern(
1405
1383
  color = "rgba(255, 0, 0, 0.6)",
1406
1384
  ): Pattern | undefined {
1407
1385
  if (typeof document === "undefined") return undefined;
1408
- const sceneScale = this.canvasService?.getSceneScale() || 1;
1409
- const cacheKey = `${color}::${sceneScale.toFixed(6)}`;
1386
+ const cacheKey = color;
1410
1387
  if (
1411
1388
  this.cropShapeHatchPattern &&
1412
1389
  this.cropShapeHatchPatternColor === color &&
@@ -1443,152 +1420,12 @@ export class ImageTool implements Extension {
1443
1420
  // @ts-ignore: Fabric Pattern accepts canvas source here.
1444
1421
  repetition: "repeat",
1445
1422
  });
1446
- // Scene specs are scaled to screen by CanvasService; keep hatch density in screen pixels.
1447
- (pattern as any).patternTransform = [
1448
- 1 / sceneScale,
1449
- 0,
1450
- 0,
1451
- 1 / sceneScale,
1452
- 0,
1453
- 0,
1454
- ];
1455
1423
  this.cropShapeHatchPattern = pattern;
1456
1424
  this.cropShapeHatchPatternColor = color;
1457
1425
  this.cropShapeHatchPatternKey = cacheKey;
1458
1426
  return pattern;
1459
1427
  }
1460
1428
 
1461
- private buildCropShapeOverlaySpecs(
1462
- frame: FrameRect,
1463
- sceneGeometry: SceneGeometryLike | null,
1464
- ): RenderObjectSpec[] {
1465
- if (!sceneGeometry) {
1466
- this.debug("overlay:shape:skip", { reason: "scene-geometry-missing" });
1467
- return [];
1468
- }
1469
- if (sceneGeometry.shape === "custom") {
1470
- this.debug("overlay:shape:skip", { reason: "shape-custom" });
1471
- return [];
1472
- }
1473
-
1474
- const shape = sceneGeometry.shape as ShapeOverlayShape;
1475
- const shapeStyle = sceneGeometry.shapeStyle;
1476
- const inset = 0;
1477
- const shapeWidth = Math.max(1, frame.width);
1478
- const shapeHeight = Math.max(1, frame.height);
1479
- const radius = this.resolveCutShapeRadius(sceneGeometry, frame);
1480
-
1481
- this.debug("overlay:shape:geometry", {
1482
- shape,
1483
- frameWidth: frame.width,
1484
- frameHeight: frame.height,
1485
- offset: sceneGeometry.offset,
1486
- shapeStyle,
1487
- inset,
1488
- shapeWidth,
1489
- shapeHeight,
1490
- baseRadius: sceneGeometry.radius,
1491
- radius,
1492
- });
1493
-
1494
- const isSameAsFrame =
1495
- Math.abs(shapeWidth - frame.width) <= 0.0001 &&
1496
- Math.abs(shapeHeight - frame.height) <= 0.0001;
1497
- if (shape === "rect" && radius <= 0.0001 && isSameAsFrame) {
1498
- this.debug("overlay:shape:skip", {
1499
- reason: "shape-rect-no-radius",
1500
- });
1501
- return [];
1502
- }
1503
-
1504
- const baseOptions = {
1505
- shape,
1506
- width: shapeWidth,
1507
- height: shapeHeight,
1508
- radius,
1509
- x: frame.width / 2,
1510
- y: frame.height / 2,
1511
- features: [],
1512
- shapeStyle,
1513
- canvasWidth: frame.width,
1514
- canvasHeight: frame.height,
1515
- };
1516
-
1517
- try {
1518
- const shapePathData = generateDielinePath(baseOptions);
1519
- const outerRectPathData = `M 0 0 L ${frame.width} 0 L ${frame.width} ${frame.height} L 0 ${frame.height} Z`;
1520
- const hatchPathData = `${outerRectPathData} ${shapePathData}`;
1521
- if (!shapePathData || !hatchPathData) {
1522
- this.debug("overlay:shape:skip", {
1523
- reason: "path-generation-empty",
1524
- shape,
1525
- radius,
1526
- });
1527
- return [];
1528
- }
1529
-
1530
- const patternFill = this.getCropShapeHatchPattern();
1531
- const hatchFill = patternFill || "rgba(255, 0, 0, 0.22)";
1532
- const shapeBounds = getPathBounds(shapePathData);
1533
- const hatchBounds = getPathBounds(hatchPathData);
1534
- const frameRect = this.toLayoutSceneRect(frame);
1535
- const hatchPathLength = hatchPathData.length;
1536
- const shapePathLength = shapePathData.length;
1537
- const specs: RenderObjectSpec[] = [
1538
- {
1539
- id: "image.cropShapeHatch",
1540
- type: "path",
1541
- data: { id: "image.cropShapeHatch", zIndex: 5 },
1542
- layout: {
1543
- reference: "custom",
1544
- referenceRect: frameRect,
1545
- alignX: "start",
1546
- alignY: "start",
1547
- offsetX: hatchBounds.x,
1548
- offsetY: hatchBounds.y,
1549
- },
1550
- props: {
1551
- pathData: hatchPathData,
1552
- originX: "left",
1553
- originY: "top",
1554
- fill: hatchFill,
1555
- opacity: patternFill ? 1 : 0.8,
1556
- stroke: "rgba(255, 0, 0, 0.9)",
1557
- strokeWidth: this.canvasService?.toSceneLength(1) ?? 1,
1558
- fillRule: "evenodd",
1559
- selectable: false,
1560
- evented: false,
1561
- excludeFromExport: true,
1562
- objectCaching: false,
1563
- },
1564
- },
1565
- ];
1566
- this.debug("overlay:shape:built", {
1567
- shape,
1568
- radius,
1569
- inset,
1570
- shapeWidth,
1571
- shapeHeight,
1572
- fillRule: "evenodd",
1573
- shapePathLength,
1574
- hatchPathLength,
1575
- shapeBounds,
1576
- hatchBounds,
1577
- hatchFillType:
1578
- hatchFill && typeof hatchFill === "object" ? "pattern" : "color",
1579
- ids: specs.map((spec) => spec.id),
1580
- });
1581
- return specs;
1582
- } catch (error) {
1583
- this.debug("overlay:shape:error", {
1584
- shape,
1585
- radius,
1586
- error: error instanceof Error ? error.message : String(error),
1587
- });
1588
- return [];
1589
- }
1590
- }
1591
-
1592
1429
  private resolveRenderImageState(item: ImageItem): RenderImageState {
1593
1430
  const active = this.isToolActive;
1594
1431
  const sourceUrl = item.sourceUrl || item.url;
@@ -1689,19 +1526,13 @@ export class ImageTool implements Extension {
1689
1526
  }
1690
1527
 
1691
1528
  private buildOverlaySpecs(
1692
- frame: FrameRect,
1693
- sceneGeometry: SceneGeometryLike | null,
1529
+ overlayState: ImageSessionOverlayState | null,
1694
1530
  ): RenderObjectSpec[] {
1695
1531
  const visible = this.isImageEditingVisible();
1696
- if (
1697
- !visible ||
1698
- frame.width <= 0 ||
1699
- frame.height <= 0 ||
1700
- !this.canvasService
1701
- ) {
1532
+ if (!visible || !overlayState || !this.canvasService) {
1702
1533
  this.debug("overlay:hidden", {
1703
1534
  visible,
1704
- frame,
1535
+ cutRect: overlayState?.layout.cutRect,
1705
1536
  isToolActive: this.isToolActive,
1706
1537
  isImageSelectionActive: this.isImageSelectionActive,
1707
1538
  focusedImageId: this.focusedImageId,
@@ -1709,174 +1540,23 @@ export class ImageTool implements Extension {
1709
1540
  return [];
1710
1541
  }
1711
1542
 
1712
- const viewport = this.canvasService.getSceneViewportRect();
1713
- const canvasW = viewport.width || 0;
1714
- const canvasH = viewport.height || 0;
1715
- const canvasLeft = viewport.left || 0;
1716
- const canvasTop = viewport.top || 0;
1543
+ const viewport = this.canvasService.getScreenViewportRect();
1717
1544
  const visual = this.getFrameVisualConfig();
1718
- const strokeWidthScene = this.canvasService.toSceneLength(
1719
- visual.strokeWidth,
1720
- );
1721
- const dashLengthScene = this.canvasService.toSceneLength(visual.dashLength);
1722
-
1723
- const frameLeft = Math.max(
1724
- canvasLeft,
1725
- Math.min(canvasLeft + canvasW, frame.left),
1726
- );
1727
- const frameTop = Math.max(
1728
- canvasTop,
1729
- Math.min(canvasTop + canvasH, frame.top),
1730
- );
1731
- const frameRight = Math.max(
1732
- frameLeft,
1733
- Math.min(canvasLeft + canvasW, frame.left + frame.width),
1734
- );
1735
- const frameBottom = Math.max(
1736
- frameTop,
1737
- Math.min(canvasTop + canvasH, frame.top + frame.height),
1738
- );
1739
- const visibleFrameH = Math.max(0, frameBottom - frameTop);
1740
-
1741
- const topH = Math.max(0, frameTop - canvasTop);
1742
- const bottomH = Math.max(0, canvasTop + canvasH - frameBottom);
1743
- const leftW = Math.max(0, frameLeft - canvasLeft);
1744
- const rightW = Math.max(0, canvasLeft + canvasW - frameRight);
1745
- const viewportRect = this.toLayoutSceneRect({
1746
- left: canvasLeft,
1747
- top: canvasTop,
1748
- width: canvasW,
1749
- height: canvasH,
1750
- });
1751
- const visibleFrameBandRect = this.toLayoutSceneRect({
1752
- left: canvasLeft,
1753
- top: frameTop,
1754
- width: canvasW,
1755
- height: visibleFrameH,
1756
- });
1757
- const frameRect = this.toLayoutSceneRect(frame);
1758
- const shapeOverlay = this.buildCropShapeOverlaySpecs(frame, sceneGeometry);
1759
-
1760
- const mask: RenderObjectSpec[] = [
1761
- {
1762
- id: "image.cropMask.top",
1763
- type: "rect",
1764
- data: { id: "image.cropMask.top", zIndex: 1 },
1765
- layout: {
1766
- reference: "custom",
1767
- referenceRect: viewportRect,
1768
- alignX: "start",
1769
- alignY: "start",
1770
- width: "100%",
1771
- height: topH,
1772
- },
1773
- props: {
1774
- originX: "left",
1775
- originY: "top",
1776
- fill: visual.outerBackground,
1777
- selectable: false,
1778
- evented: false,
1779
- },
1780
- },
1781
- {
1782
- id: "image.cropMask.bottom",
1783
- type: "rect",
1784
- data: { id: "image.cropMask.bottom", zIndex: 2 },
1785
- layout: {
1786
- reference: "custom",
1787
- referenceRect: viewportRect,
1788
- alignX: "start",
1789
- alignY: "end",
1790
- width: "100%",
1791
- height: bottomH,
1792
- },
1793
- props: {
1794
- originX: "left",
1795
- originY: "top",
1796
- fill: visual.outerBackground,
1797
- selectable: false,
1798
- evented: false,
1799
- },
1800
- },
1801
- {
1802
- id: "image.cropMask.left",
1803
- type: "rect",
1804
- data: { id: "image.cropMask.left", zIndex: 3 },
1805
- layout: {
1806
- reference: "custom",
1807
- referenceRect: visibleFrameBandRect,
1808
- alignX: "start",
1809
- alignY: "start",
1810
- width: leftW,
1811
- height: "100%",
1812
- },
1813
- props: {
1814
- originX: "left",
1815
- originY: "top",
1816
- fill: visual.outerBackground,
1817
- selectable: false,
1818
- evented: false,
1819
- },
1820
- },
1821
- {
1822
- id: "image.cropMask.right",
1823
- type: "rect",
1824
- data: { id: "image.cropMask.right", zIndex: 4 },
1825
- layout: {
1826
- reference: "custom",
1827
- referenceRect: visibleFrameBandRect,
1828
- alignX: "end",
1829
- alignY: "start",
1830
- width: rightW,
1831
- height: "100%",
1832
- },
1833
- props: {
1834
- originX: "left",
1835
- originY: "top",
1836
- fill: visual.outerBackground,
1837
- selectable: false,
1838
- evented: false,
1839
- },
1545
+ const specs = buildImageSessionOverlaySpecs({
1546
+ viewport: {
1547
+ left: viewport.left,
1548
+ top: viewport.top,
1549
+ width: viewport.width,
1550
+ height: viewport.height,
1840
1551
  },
1841
- ];
1842
-
1843
- const frameSpec: RenderObjectSpec = {
1844
- id: "image.cropFrame",
1845
- type: "rect",
1846
- data: { id: "image.cropFrame", zIndex: 7 },
1847
- layout: {
1848
- reference: "custom",
1849
- referenceRect: frameRect,
1850
- alignX: "start",
1851
- alignY: "start",
1852
- width: "100%",
1853
- height: "100%",
1854
- },
1855
- props: {
1856
- originX: "left",
1857
- originY: "top",
1858
- fill: visual.innerBackground,
1859
- stroke:
1860
- visual.strokeStyle === "hidden"
1861
- ? "rgba(0,0,0,0)"
1862
- : visual.strokeColor,
1863
- strokeWidth: visual.strokeStyle === "hidden" ? 0 : strokeWidthScene,
1864
- strokeDashArray:
1865
- visual.strokeStyle === "dashed"
1866
- ? [dashLengthScene, dashLengthScene]
1867
- : undefined,
1868
- selectable: false,
1869
- evented: false,
1870
- },
1871
- };
1872
-
1873
- const specs =
1874
- shapeOverlay.length > 0
1875
- ? [...mask, ...shapeOverlay]
1876
- : [...mask, ...shapeOverlay, frameSpec];
1552
+ layout: overlayState.layout,
1553
+ geometry: overlayState.geometry,
1554
+ visual,
1555
+ hatchPattern: this.getCropShapeHatchPattern(),
1556
+ });
1877
1557
  this.debug("overlay:built", {
1878
- frame,
1879
- shape: sceneGeometry?.shape,
1558
+ cutRect: overlayState.layout.cutRect,
1559
+ shape: overlayState.geometry.shape,
1880
1560
  overlayIds: specs.map((spec) => ({
1881
1561
  id: spec.id,
1882
1562
  zIndex: spec.data?.zIndex,
@@ -1907,11 +1587,10 @@ export class ImageTool implements Extension {
1907
1587
  const imageSpecs = await this.buildImageSpecs(renderItems, frame);
1908
1588
  if (seq !== this.renderSeq) return;
1909
1589
 
1910
- const sceneGeometry = await this.resolveSceneGeometryForOverlay();
1911
- if (seq !== this.renderSeq) return;
1590
+ const overlayState = this.resolveSessionOverlayState();
1912
1591
 
1913
1592
  this.imageSpecs = imageSpecs;
1914
- this.overlaySpecs = this.buildOverlaySpecs(frame, sceneGeometry);
1593
+ this.overlaySpecs = this.buildOverlaySpecs(overlayState);
1915
1594
  await this.canvasService.flushRenderFromProducers();
1916
1595
  if (seq !== this.renderSeq) return;
1917
1596
  this.refreshImageObjectInteractionState();
@@ -1942,6 +1621,7 @@ export class ImageTool implements Extension {
1942
1621
  isImageSelectionActive: this.isImageSelectionActive,
1943
1622
  focusedImageId: this.focusedImageId,
1944
1623
  });
1624
+ this.emitImageStateChange();
1945
1625
  this.canvasService.requestRenderAll();
1946
1626
  }
1947
1627
 
@@ -1949,6 +1629,40 @@ export class ImageTool implements Extension {
1949
1629
  return Math.max(-1, Math.min(2, value));
1950
1630
  }
1951
1631
 
1632
+ private async setImageTransform(
1633
+ id: string,
1634
+ updates: ImageTransformUpdates,
1635
+ options: UpdateImageOptions = {},
1636
+ ) {
1637
+ const next: Partial<ImageItem> = {};
1638
+
1639
+ if (Number.isFinite(updates.scale as number)) {
1640
+ next.scale = Math.max(0.05, Number(updates.scale));
1641
+ }
1642
+ if (Number.isFinite(updates.angle as number)) {
1643
+ next.angle = Number(updates.angle);
1644
+ }
1645
+ if (Number.isFinite(updates.left as number)) {
1646
+ next.left = this.clampNormalized(Number(updates.left));
1647
+ }
1648
+ if (Number.isFinite(updates.top as number)) {
1649
+ next.top = this.clampNormalized(Number(updates.top));
1650
+ }
1651
+ if (Number.isFinite(updates.opacity as number)) {
1652
+ next.opacity = Math.max(0, Math.min(1, Number(updates.opacity)));
1653
+ }
1654
+
1655
+ if (!Object.keys(next).length) return;
1656
+ await this.updateImage(id, next, options);
1657
+ }
1658
+
1659
+ private resetImageSession() {
1660
+ this.workingItems = this.cloneItems(this.items);
1661
+ this.hasWorkingChanges = false;
1662
+ this.updateImages();
1663
+ this.emitWorkingChange();
1664
+ }
1665
+
1952
1666
  private onObjectModified = (e: any) => {
1953
1667
  if (!this.isToolActive) return;
1954
1668
  const target = e?.target;
@@ -2024,10 +1738,6 @@ export class ImageTool implements Extension {
2024
1738
  url: replacingUrl,
2025
1739
  sourceUrl: replacingUrl,
2026
1740
  committedUrl: undefined,
2027
- scale: updates.scale ?? 1,
2028
- angle: updates.angle ?? 0,
2029
- left: updates.left ?? 0.5,
2030
- top: updates.top ?? 0.5,
2031
1741
  }
2032
1742
  : {}),
2033
1743
  });
@@ -2035,14 +1745,7 @@ export class ImageTool implements Extension {
2035
1745
  this.updateConfig(next);
2036
1746
 
2037
1747
  if (replacingSource) {
2038
- this.debug("replace:image:begin", { id, replacingUrl });
2039
1748
  this.purgeSourceSizeCacheForItem(base);
2040
- const loaded = await this.waitImageLoaded(id, true);
2041
- this.debug("replace:image:loaded", { id, loaded });
2042
- if (loaded) {
2043
- await this.refitImageToFrame(id);
2044
- this.setImageFocus(id);
2045
- }
2046
1749
  }
2047
1750
  }
2048
1751
 
@@ -2064,93 +1767,62 @@ export class ImageTool implements Extension {
2064
1767
  });
2065
1768
  }
2066
1769
 
2067
- private async refitImageToFrame(id: string) {
1770
+ private async resolveImageSourceSize(
1771
+ id: string,
1772
+ src: string,
1773
+ ): Promise<SourceSize | null> {
2068
1774
  const obj = this.getImageObject(id);
2069
- if (!obj || !this.canvasService) return;
2070
- const current = this.items.find((item) => item.id === id);
2071
- if (!current) return;
2072
- const render = this.resolveRenderImageState(current);
2073
-
2074
- this.rememberSourceSize(render.src, obj);
2075
- const source = this.getSourceSize(render.src, obj);
2076
- const frame = this.getFrameRect();
2077
- const coverScale = this.getCoverScale(frame, source);
2078
-
2079
- const currentScale = this.toSceneObjectScale(obj.scaleX || 1);
2080
- const zoom = Math.max(0.05, currentScale / coverScale);
2081
-
2082
- const updated: Partial<ImageItem> = {
2083
- scale: Number.isFinite(zoom) ? zoom : 1,
2084
- angle: 0,
2085
- left: 0.5,
2086
- top: 0.5,
2087
- };
2088
-
2089
- const index = this.items.findIndex((item) => item.id === id);
2090
- if (index < 0) return;
1775
+ if (obj) {
1776
+ this.rememberSourceSize(src, obj);
1777
+ }
1778
+ const ensured = await this.ensureSourceSize(src);
1779
+ if (ensured) return ensured;
1780
+ if (!obj) return null;
2091
1781
 
2092
- const next = [...this.items];
2093
- next[index] = this.normalizeItem({ ...next[index], ...updated });
2094
- this.updateConfig(next);
2095
- this.workingItems = this.cloneItems(next);
2096
- this.hasWorkingChanges = false;
2097
- this.updateImages();
2098
- this.emitWorkingChange(id);
1782
+ const width = Number(obj?.width || 0);
1783
+ const height = Number(obj?.height || 0);
1784
+ if (width <= 0 || height <= 0) return null;
1785
+ return { width, height };
2099
1786
  }
2100
1787
 
2101
- private async fitImageToArea(
1788
+ private async applyImageOperation(
2102
1789
  id: string,
2103
- area: { width: number; height: number; left?: number; top?: number },
1790
+ operation: ImageOperation,
1791
+ options: UpdateImageOptions = {},
2104
1792
  ) {
2105
1793
  if (!this.canvasService) return;
2106
1794
 
2107
- const loaded = await this.waitImageLoaded(id, false);
2108
- if (!loaded) return;
2109
-
2110
- const obj = this.getImageObject(id);
2111
- if (!obj) return;
2112
- const renderItems = this.isToolActive ? this.workingItems : this.items;
1795
+ this.syncToolActiveFromWorkbench();
1796
+ const target = options.target || "auto";
1797
+ const renderItems =
1798
+ target === "working" || (target === "auto" && this.isToolActive)
1799
+ ? this.workingItems
1800
+ : this.items;
2113
1801
  const current = renderItems.find((item) => item.id === id);
2114
1802
  if (!current) return;
1803
+
2115
1804
  const render = this.resolveRenderImageState(current);
1805
+ const source = await this.resolveImageSourceSize(id, render.src);
1806
+ if (!source) return;
2116
1807
 
2117
- this.rememberSourceSize(render.src, obj);
2118
- const source = this.getSourceSize(render.src, obj);
2119
1808
  const frame = this.getFrameRect();
2120
- const baseCover = this.getCoverScale(frame, source);
2121
-
2122
- const desiredScale = Math.max(
2123
- Math.max(1, area.width) / Math.max(1, source.width),
2124
- Math.max(1, area.height) / Math.max(1, source.height),
2125
- );
2126
-
2127
1809
  const viewport = this.canvasService.getSceneViewportRect();
2128
- const canvasW = viewport.width || 1;
2129
- const canvasH = viewport.height || 1;
2130
-
2131
- const areaLeftInput = area.left ?? 0.5;
2132
- const areaTopInput = area.top ?? 0.5;
2133
-
2134
- const areaLeftPx =
2135
- areaLeftInput <= 1.5
2136
- ? viewport.left + areaLeftInput * canvasW
2137
- : areaLeftInput;
2138
- const areaTopPx =
2139
- areaTopInput <= 1.5
2140
- ? viewport.top + areaTopInput * canvasH
2141
- : areaTopInput;
2142
-
2143
- const updates: Partial<ImageItem> = {
2144
- scale: Math.max(0.05, desiredScale / baseCover),
2145
- left: this.clampNormalized(
2146
- (areaLeftPx - frame.left) / Math.max(1, frame.width),
2147
- ),
2148
- top: this.clampNormalized(
2149
- (areaTopPx - frame.top) / Math.max(1, frame.height),
2150
- ),
2151
- };
1810
+ const area =
1811
+ operation.type === "resetTransform"
1812
+ ? resolveImageOperationArea({ frame, viewport })
1813
+ : resolveImageOperationArea({
1814
+ frame,
1815
+ viewport,
1816
+ area: operation.area,
1817
+ });
1818
+ const updates = computeImageOperationUpdates({
1819
+ frame,
1820
+ source,
1821
+ operation,
1822
+ area,
1823
+ });
2152
1824
 
2153
- if (this.isToolActive) {
1825
+ if (target === "working" || (target === "auto" && this.isToolActive)) {
2154
1826
  this.updateImageInWorking(id, updates);
2155
1827
  return;
2156
1828
  }