@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.
@@ -39,6 +39,12 @@ import {
39
39
  } from "../../shared/constants/layers";
40
40
  import { createImageCommands } from "./commands";
41
41
  import { createImageConfigurations } from "./config";
42
+ import {
43
+ computeImageOperationUpdates,
44
+ resolveImageOperationArea,
45
+ type ImageOperation,
46
+ } from "./imageOperations";
47
+ import { validateImagePlacement } from "./imagePlacement";
42
48
  import { buildImageSessionOverlaySpecs } from "./sessionOverlay";
43
49
 
44
50
  export interface ImageItem {
@@ -53,6 +59,37 @@ export interface ImageItem {
53
59
  committedUrl?: string;
54
60
  }
55
61
 
62
+ export interface ImageTransformUpdates {
63
+ scale?: number;
64
+ angle?: number;
65
+ left?: number;
66
+ top?: number;
67
+ opacity?: number;
68
+ }
69
+
70
+ export interface ImageViewState {
71
+ items: ImageItem[];
72
+ hasAnyImage: boolean;
73
+ focusedId: string | null;
74
+ focusedItem: ImageItem | null;
75
+ isToolActive: boolean;
76
+ isImageSelectionActive: boolean;
77
+ hasWorkingChanges: boolean;
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;
91
+ }
92
+
56
93
  interface RenderImageState {
57
94
  src: string;
58
95
  left: number;
@@ -92,14 +129,7 @@ interface UpsertImageOptions {
92
129
  id?: string;
93
130
  mode?: "replace" | "add";
94
131
  addOptions?: Partial<ImageItem>;
95
- fitOnAdd?: boolean;
96
- }
97
-
98
- interface DielineFitArea {
99
- width: number;
100
- height: number;
101
- left: number;
102
- top: number;
132
+ operation?: ImageOperation;
103
133
  }
104
134
 
105
135
  interface UpdateImageOptions {
@@ -221,6 +251,7 @@ export class ImageTool implements Extension {
221
251
  private activeSnapX: SnapMatch | null = null;
222
252
  private activeSnapY: SnapMatch | null = null;
223
253
  private movingImageId: string | null = null;
254
+ private sessionNotice: ImageSessionNotice | null = null;
224
255
  private hasRenderedSnapGuides = false;
225
256
  private canvasObjectMovingHandler?: (e: any) => void;
226
257
  private canvasMouseUpHandler?: (e: any) => void;
@@ -332,8 +363,12 @@ export class ImageTool implements Extension {
332
363
  if (
333
364
  e.key.startsWith("size.") ||
334
365
  e.key.startsWith("image.frame.") ||
366
+ e.key.startsWith("image.session.") ||
335
367
  e.key.startsWith("image.control.")
336
368
  ) {
369
+ if (e.key === "image.session.placementPolicy") {
370
+ this.clearSessionNotice();
371
+ }
337
372
  if (e.key.startsWith("image.control.")) {
338
373
  this.imageControlsByCapabilityKey.clear();
339
374
  }
@@ -370,6 +405,7 @@ export class ImageTool implements Extension {
370
405
  this.clearRenderedImages();
371
406
  this.renderProducerDisposable?.dispose();
372
407
  this.renderProducerDisposable = undefined;
408
+ this.emitImageStateChange();
373
409
  if (this.canvasService) {
374
410
  void this.canvasService.flushRenderFromProducers();
375
411
  this.canvasService = undefined;
@@ -932,9 +968,10 @@ export class ImageTool implements Extension {
932
968
  name: "Image",
933
969
  interaction: "session",
934
970
  commands: {
935
- begin: "resetWorkingImages",
971
+ begin: "imageSessionReset",
972
+ validate: "validateImageSession",
936
973
  commit: "completeImages",
937
- rollback: "resetWorkingImages",
974
+ rollback: "imageSessionReset",
938
975
  },
939
976
  session: {
940
977
  autoBegin: true,
@@ -979,6 +1016,77 @@ export class ImageTool implements Extension {
979
1016
  return this.normalizeItems((items || []).map((i) => ({ ...i })));
980
1017
  }
981
1018
 
1019
+ private getViewItems(): ImageItem[] {
1020
+ return this.isToolActive ? this.workingItems : this.items;
1021
+ }
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
+
1064
+ private getImageViewState(): ImageViewState {
1065
+ this.syncToolActiveFromWorkbench();
1066
+ const items = this.cloneItems(this.getViewItems());
1067
+ const focusedItem =
1068
+ this.focusedImageId == null
1069
+ ? null
1070
+ : items.find((item) => item.id === this.focusedImageId) || null;
1071
+
1072
+ return {
1073
+ items,
1074
+ hasAnyImage: items.length > 0,
1075
+ focusedId: this.focusedImageId,
1076
+ focusedItem,
1077
+ isToolActive: this.isToolActive,
1078
+ isImageSelectionActive: this.isImageSelectionActive,
1079
+ hasWorkingChanges: this.hasWorkingChanges,
1080
+ source: this.isToolActive ? "working" : "committed",
1081
+ placementPolicy: this.getPlacementPolicy(),
1082
+ sessionNotice: this.sessionNotice,
1083
+ };
1084
+ }
1085
+
1086
+ private emitImageStateChange() {
1087
+ this.context?.eventBus.emit("image:state:change", this.getImageViewState());
1088
+ }
1089
+
982
1090
  private emitWorkingChange(changedId: string | null = null) {
983
1091
  this.context?.eventBus.emit("image:working:change", {
984
1092
  changedId,
@@ -1026,6 +1134,8 @@ export class ImageTool implements Extension {
1026
1134
 
1027
1135
  if (!options.skipRender) {
1028
1136
  this.updateImages();
1137
+ } else {
1138
+ this.emitImageStateChange();
1029
1139
  }
1030
1140
 
1031
1141
  return { ok: true, id };
@@ -1034,8 +1144,10 @@ export class ImageTool implements Extension {
1034
1144
  private async addImageEntry(
1035
1145
  url: string,
1036
1146
  options?: Partial<ImageItem>,
1037
- fitOnAdd = true,
1147
+ operation?: ImageOperation,
1038
1148
  ): Promise<string> {
1149
+ this.syncToolActiveFromWorkbench();
1150
+ this.clearSessionNotice({ emit: false });
1039
1151
  const id = this.generateId();
1040
1152
  const newItem = this.normalizeItem({
1041
1153
  id,
@@ -1044,13 +1156,20 @@ export class ImageTool implements Extension {
1044
1156
  ...options,
1045
1157
  } as ImageItem);
1046
1158
 
1047
- const sessionDirtyBeforeAdd = this.isToolActive && this.hasWorkingChanges;
1048
1159
  const waitLoaded = this.waitImageLoaded(id, true);
1049
- this.updateConfig([...this.items, newItem]);
1050
- this.addItemToWorkingSessionIfNeeded(newItem, sessionDirtyBeforeAdd);
1160
+ if (this.isToolActive) {
1161
+ this.workingItems = this.cloneItems([...this.workingItems, newItem]);
1162
+ this.hasWorkingChanges = true;
1163
+ this.updateImages();
1164
+ this.emitWorkingChange(id);
1165
+ } else {
1166
+ this.updateConfig([...this.items, newItem]);
1167
+ }
1051
1168
  const loaded = await waitLoaded;
1052
- if (loaded && fitOnAdd) {
1053
- await this.fitImageToDefaultArea(id);
1169
+ if (loaded && operation) {
1170
+ await this.applyImageOperation(id, operation, {
1171
+ target: this.isToolActive ? "working" : "config",
1172
+ });
1054
1173
  }
1055
1174
  if (loaded) {
1056
1175
  this.setImageFocus(id);
@@ -1062,8 +1181,8 @@ export class ImageTool implements Extension {
1062
1181
  url: string,
1063
1182
  options: UpsertImageOptions = {},
1064
1183
  ): Promise<{ id: string; mode: "replace" | "add" }> {
1184
+ this.syncToolActiveFromWorkbench();
1065
1185
  const mode = options.mode || (options.id ? "replace" : "add");
1066
- const fitOnAdd = options.fitOnAdd !== false;
1067
1186
  if (mode === "replace") {
1068
1187
  if (!options.id) {
1069
1188
  throw new Error("replace-target-id-required");
@@ -1072,25 +1191,39 @@ export class ImageTool implements Extension {
1072
1191
  if (!this.hasImageItem(targetId)) {
1073
1192
  throw new Error("replace-target-not-found");
1074
1193
  }
1075
- await this.updateImageInConfig(targetId, { url });
1194
+ if (this.isToolActive) {
1195
+ const current =
1196
+ this.workingItems.find((item) => item.id === targetId) ||
1197
+ this.items.find((item) => item.id === targetId);
1198
+ this.purgeSourceSizeCacheForItem(current);
1199
+ this.updateImageInWorking(targetId, {
1200
+ url,
1201
+ sourceUrl: url,
1202
+ committedUrl: undefined,
1203
+ });
1204
+ } else {
1205
+ await this.updateImageInConfig(targetId, { url });
1206
+ }
1207
+ const loaded = await this.waitImageLoaded(targetId, true);
1208
+ if (loaded && options.operation) {
1209
+ await this.applyImageOperation(targetId, options.operation, {
1210
+ target: this.isToolActive ? "working" : "config",
1211
+ });
1212
+ }
1213
+ if (loaded) {
1214
+ this.setImageFocus(targetId);
1215
+ }
1076
1216
  return { id: targetId, mode: "replace" };
1077
1217
  }
1078
1218
 
1079
- const id = await this.addImageEntry(url, options.addOptions, fitOnAdd);
1219
+ const id = await this.addImageEntry(
1220
+ url,
1221
+ options.addOptions,
1222
+ options.operation,
1223
+ );
1080
1224
  return { id, mode: "add" };
1081
1225
  }
1082
1226
 
1083
- private addItemToWorkingSessionIfNeeded(
1084
- item: ImageItem,
1085
- sessionDirtyBeforeAdd: boolean,
1086
- ) {
1087
- if (!sessionDirtyBeforeAdd || !this.isToolActive) return;
1088
- if (this.workingItems.some((existing) => existing.id === item.id)) return;
1089
- this.workingItems = this.cloneItems([...this.workingItems, item]);
1090
- this.updateImages();
1091
- this.emitWorkingChange(item.id);
1092
- }
1093
-
1094
1227
  private async updateImage(
1095
1228
  id: string,
1096
1229
  updates: Partial<ImageItem>,
@@ -1134,6 +1267,7 @@ export class ImageTool implements Extension {
1134
1267
 
1135
1268
  private updateConfig(newItems: ImageItem[], skipCanvasUpdate = false) {
1136
1269
  if (!this.context) return;
1270
+ this.clearSessionNotice({ emit: false });
1137
1271
  this.applyCommittedItems(newItems);
1138
1272
  runDeferredConfigUpdate(
1139
1273
  this,
@@ -1165,38 +1299,6 @@ export class ImageTool implements Extension {
1165
1299
  return this.canvasService.toScreenRect(frame || this.getFrameRect());
1166
1300
  }
1167
1301
 
1168
- private async resolveDefaultFitArea(): Promise<DielineFitArea | null> {
1169
- if (!this.canvasService) return null;
1170
- const frame = this.getFrameRect();
1171
- if (frame.width <= 0 || frame.height <= 0) return null;
1172
- return {
1173
- width: Math.max(1, frame.width),
1174
- height: Math.max(1, frame.height),
1175
- left: frame.left + frame.width / 2,
1176
- top: frame.top + frame.height / 2,
1177
- };
1178
- }
1179
-
1180
- private async fitImageToDefaultArea(id: string) {
1181
- if (!this.canvasService) return;
1182
- const area = await this.resolveDefaultFitArea();
1183
-
1184
- if (area) {
1185
- await this.fitImageToArea(id, area);
1186
- return;
1187
- }
1188
-
1189
- const viewport = this.canvasService.getSceneViewportRect();
1190
- const canvasW = Math.max(1, viewport.width || 0);
1191
- const canvasH = Math.max(1, viewport.height || 0);
1192
- await this.fitImageToArea(id, {
1193
- width: canvasW,
1194
- height: canvasH,
1195
- left: viewport.left + canvasW / 2,
1196
- top: viewport.top + canvasH / 2,
1197
- });
1198
- }
1199
-
1200
1302
  private getImageObjects(): any[] {
1201
1303
  if (!this.canvasService) return [];
1202
1304
  return this.canvasService.canvas.getObjects().filter((obj: any) => {
@@ -1279,6 +1381,82 @@ export class ImageTool implements Extension {
1279
1381
  return getCoverScaleFromRect(frame, size);
1280
1382
  }
1281
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
+
1282
1460
  private getFrameVisualConfig(): FrameVisualConfig {
1283
1461
  const strokeStyleRaw = (this.getConfig<string>(
1284
1462
  "image.frame.strokeStyle",
@@ -1587,6 +1765,7 @@ export class ImageTool implements Extension {
1587
1765
  isImageSelectionActive: this.isImageSelectionActive,
1588
1766
  focusedImageId: this.focusedImageId,
1589
1767
  });
1768
+ this.emitImageStateChange();
1590
1769
  this.canvasService.requestRenderAll();
1591
1770
  }
1592
1771
 
@@ -1594,6 +1773,41 @@ export class ImageTool implements Extension {
1594
1773
  return Math.max(-1, Math.min(2, value));
1595
1774
  }
1596
1775
 
1776
+ private async setImageTransform(
1777
+ id: string,
1778
+ updates: ImageTransformUpdates,
1779
+ options: UpdateImageOptions = {},
1780
+ ) {
1781
+ const next: Partial<ImageItem> = {};
1782
+
1783
+ if (Number.isFinite(updates.scale as number)) {
1784
+ next.scale = Math.max(0.05, Number(updates.scale));
1785
+ }
1786
+ if (Number.isFinite(updates.angle as number)) {
1787
+ next.angle = Number(updates.angle);
1788
+ }
1789
+ if (Number.isFinite(updates.left as number)) {
1790
+ next.left = this.clampNormalized(Number(updates.left));
1791
+ }
1792
+ if (Number.isFinite(updates.top as number)) {
1793
+ next.top = this.clampNormalized(Number(updates.top));
1794
+ }
1795
+ if (Number.isFinite(updates.opacity as number)) {
1796
+ next.opacity = Math.max(0, Math.min(1, Number(updates.opacity)));
1797
+ }
1798
+
1799
+ if (!Object.keys(next).length) return;
1800
+ await this.updateImage(id, next, options);
1801
+ }
1802
+
1803
+ private resetImageSession() {
1804
+ this.clearSessionNotice({ emit: false });
1805
+ this.workingItems = this.cloneItems(this.items);
1806
+ this.hasWorkingChanges = false;
1807
+ this.updateImages();
1808
+ this.emitWorkingChange();
1809
+ }
1810
+
1597
1811
  private onObjectModified = (e: any) => {
1598
1812
  if (!this.isToolActive) return;
1599
1813
  const target = e?.target;
@@ -1637,6 +1851,7 @@ export class ImageTool implements Extension {
1637
1851
  const index = this.workingItems.findIndex((item) => item.id === id);
1638
1852
  if (index < 0) return;
1639
1853
 
1854
+ this.clearSessionNotice({ emit: false });
1640
1855
  const next = [...this.workingItems];
1641
1856
  next[index] = this.normalizeItem({ ...next[index], ...updates });
1642
1857
  this.workingItems = next;
@@ -1655,6 +1870,7 @@ export class ImageTool implements Extension {
1655
1870
  const index = this.items.findIndex((item) => item.id === id);
1656
1871
  if (index < 0) return;
1657
1872
 
1873
+ this.clearSessionNotice({ emit: false });
1658
1874
  const replacingSource =
1659
1875
  typeof updates.url === "string" && updates.url.length > 0;
1660
1876
  const next = [...this.items];
@@ -1669,10 +1885,6 @@ export class ImageTool implements Extension {
1669
1885
  url: replacingUrl,
1670
1886
  sourceUrl: replacingUrl,
1671
1887
  committedUrl: undefined,
1672
- scale: updates.scale ?? 1,
1673
- angle: updates.angle ?? 0,
1674
- left: updates.left ?? 0.5,
1675
- top: updates.top ?? 0.5,
1676
1888
  }
1677
1889
  : {}),
1678
1890
  });
@@ -1680,14 +1892,7 @@ export class ImageTool implements Extension {
1680
1892
  this.updateConfig(next);
1681
1893
 
1682
1894
  if (replacingSource) {
1683
- this.debug("replace:image:begin", { id, replacingUrl });
1684
1895
  this.purgeSourceSizeCacheForItem(base);
1685
- const loaded = await this.waitImageLoaded(id, true);
1686
- this.debug("replace:image:loaded", { id, loaded });
1687
- if (loaded) {
1688
- await this.refitImageToFrame(id);
1689
- this.setImageFocus(id);
1690
- }
1691
1896
  }
1692
1897
  }
1693
1898
 
@@ -1709,93 +1914,62 @@ export class ImageTool implements Extension {
1709
1914
  });
1710
1915
  }
1711
1916
 
1712
- private async refitImageToFrame(id: string) {
1917
+ private async resolveImageSourceSize(
1918
+ id: string,
1919
+ src: string,
1920
+ ): Promise<SourceSize | null> {
1713
1921
  const obj = this.getImageObject(id);
1714
- if (!obj || !this.canvasService) return;
1715
- const current = this.items.find((item) => item.id === id);
1716
- if (!current) return;
1717
- const render = this.resolveRenderImageState(current);
1718
-
1719
- this.rememberSourceSize(render.src, obj);
1720
- const source = this.getSourceSize(render.src, obj);
1721
- const frame = this.getFrameRect();
1722
- const coverScale = this.getCoverScale(frame, source);
1723
-
1724
- const currentScale = this.toSceneObjectScale(obj.scaleX || 1);
1725
- const zoom = Math.max(0.05, currentScale / coverScale);
1726
-
1727
- const updated: Partial<ImageItem> = {
1728
- scale: Number.isFinite(zoom) ? zoom : 1,
1729
- angle: 0,
1730
- left: 0.5,
1731
- top: 0.5,
1732
- };
1733
-
1734
- const index = this.items.findIndex((item) => item.id === id);
1735
- if (index < 0) return;
1922
+ if (obj) {
1923
+ this.rememberSourceSize(src, obj);
1924
+ }
1925
+ const ensured = await this.ensureSourceSize(src);
1926
+ if (ensured) return ensured;
1927
+ if (!obj) return null;
1736
1928
 
1737
- const next = [...this.items];
1738
- next[index] = this.normalizeItem({ ...next[index], ...updated });
1739
- this.updateConfig(next);
1740
- this.workingItems = this.cloneItems(next);
1741
- this.hasWorkingChanges = false;
1742
- this.updateImages();
1743
- this.emitWorkingChange(id);
1929
+ const width = Number(obj?.width || 0);
1930
+ const height = Number(obj?.height || 0);
1931
+ if (width <= 0 || height <= 0) return null;
1932
+ return { width, height };
1744
1933
  }
1745
1934
 
1746
- private async fitImageToArea(
1935
+ private async applyImageOperation(
1747
1936
  id: string,
1748
- area: { width: number; height: number; left?: number; top?: number },
1937
+ operation: ImageOperation,
1938
+ options: UpdateImageOptions = {},
1749
1939
  ) {
1750
1940
  if (!this.canvasService) return;
1751
1941
 
1752
- const loaded = await this.waitImageLoaded(id, false);
1753
- if (!loaded) return;
1754
-
1755
- const obj = this.getImageObject(id);
1756
- if (!obj) return;
1757
- const renderItems = this.isToolActive ? this.workingItems : this.items;
1942
+ this.syncToolActiveFromWorkbench();
1943
+ const target = options.target || "auto";
1944
+ const renderItems =
1945
+ target === "working" || (target === "auto" && this.isToolActive)
1946
+ ? this.workingItems
1947
+ : this.items;
1758
1948
  const current = renderItems.find((item) => item.id === id);
1759
1949
  if (!current) return;
1950
+
1760
1951
  const render = this.resolveRenderImageState(current);
1952
+ const source = await this.resolveImageSourceSize(id, render.src);
1953
+ if (!source) return;
1761
1954
 
1762
- this.rememberSourceSize(render.src, obj);
1763
- const source = this.getSourceSize(render.src, obj);
1764
1955
  const frame = this.getFrameRect();
1765
- const baseCover = this.getCoverScale(frame, source);
1766
-
1767
- const desiredScale = Math.max(
1768
- Math.max(1, area.width) / Math.max(1, source.width),
1769
- Math.max(1, area.height) / Math.max(1, source.height),
1770
- );
1771
-
1772
1956
  const viewport = this.canvasService.getSceneViewportRect();
1773
- const canvasW = viewport.width || 1;
1774
- const canvasH = viewport.height || 1;
1775
-
1776
- const areaLeftInput = area.left ?? 0.5;
1777
- const areaTopInput = area.top ?? 0.5;
1778
-
1779
- const areaLeftPx =
1780
- areaLeftInput <= 1.5
1781
- ? viewport.left + areaLeftInput * canvasW
1782
- : areaLeftInput;
1783
- const areaTopPx =
1784
- areaTopInput <= 1.5
1785
- ? viewport.top + areaTopInput * canvasH
1786
- : areaTopInput;
1787
-
1788
- const updates: Partial<ImageItem> = {
1789
- scale: Math.max(0.05, desiredScale / baseCover),
1790
- left: this.clampNormalized(
1791
- (areaLeftPx - frame.left) / Math.max(1, frame.width),
1792
- ),
1793
- top: this.clampNormalized(
1794
- (areaTopPx - frame.top) / Math.max(1, frame.height),
1795
- ),
1796
- };
1957
+ const area =
1958
+ operation.type === "resetTransform"
1959
+ ? resolveImageOperationArea({ frame, viewport })
1960
+ : resolveImageOperationArea({
1961
+ frame,
1962
+ viewport,
1963
+ area: operation.area,
1964
+ });
1965
+ const updates = computeImageOperationUpdates({
1966
+ frame,
1967
+ source,
1968
+ operation,
1969
+ area,
1970
+ });
1797
1971
 
1798
- if (this.isToolActive) {
1972
+ if (target === "working" || (target === "auto" && this.isToolActive)) {
1799
1973
  this.updateImageInWorking(id, updates);
1800
1974
  return;
1801
1975
  }
@@ -1841,12 +2015,44 @@ export class ImageTool implements Extension {
1841
2015
  }
1842
2016
 
1843
2017
  this.hasWorkingChanges = false;
2018
+ this.clearSessionNotice({ emit: false });
1844
2019
  this.workingItems = this.cloneItems(next);
1845
2020
  this.updateConfig(next);
1846
2021
  this.emitWorkingChange(this.focusedImageId);
1847
2022
  return { ok: true };
1848
2023
  }
1849
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
+
1850
2056
  private async exportCroppedImageByIds(
1851
2057
  imageIds: string[],
1852
2058
  options: ExportCroppedImageOptions,