@fulate/tools 1.0.11 → 1.0.13

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/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # @fulate/tools Editor integration
1
+ # @fulate/tools Editor integration
2
+
3
+ > 文档角色:`GUIDE`。说明当前 Tools 用法;Editor 时序的唯一合同见 [Editor 规范](../../docs/specs/editor-interaction.md)。
2
4
 
3
5
  每个活动 Root 只有一个 `EditorInteractionController`。它拥有 selection、输入仲裁、Transform/Point
4
6
  gesture、Content session、Line/LineTree branch draft 与 semantic History 边界;`Select` 只保留 command/paint
@@ -130,7 +132,51 @@ History 安装后排队一次 `select:configured-change` 通知;标量 noop
130
132
  width/height/x/y 仍使用 `Select.resize()` 或其他 Transform 命令,不能用普通 authored patch 绕过
131
133
  父级矩阵反算。
132
134
 
133
- `Select.resize()` 是属性面板使用的公开尺寸命令:
135
+ ### 应用组合命令
136
+
137
+ 不同 owner 的属性和结构组合使用 `select.applyCommand(createCommand)`。构造函数在旧交互结束、
138
+ pending 几何稳定后同步运行,参数是当时的 live selection,返回公开的 `SelectCommand`:
139
+
140
+ ```ts
141
+ const changed = select.applyCommand(() => ({
142
+ ownerChanges: [{ owner: workspace, patch: { backgroundColor: "#ffffff" } }],
143
+ recordHistory: false
144
+ }));
145
+
146
+ select.applyCommand(() => ({
147
+ structureChanges: [{ owner: layer, children: [...layer.children, newShape] }],
148
+ selectionAfter: [newShape],
149
+ afterInstall: () => attachApplicationData(newShape)
150
+ }));
151
+ ```
152
+
153
+ `recordHistory: false` 只关闭本命令的记录;默认仍服从 `select.history.enabled`。返回值只表示本命令
154
+ Canvas configured 属性或结构的实际净变化,与 History、监听器是否开启无关。已有拖拽完成和随后命令
155
+ 是两个操作,各自有适用的 History/配置通知;本命令 noop 不撤销前一个操作。
156
+
157
+ `selectionAfter` 省略时保留当前选择意图;结构提交后过滤已移除节点和被选中祖先覆盖的元素,成员未变
158
+ 时保留当前 Point 等选择状态。`[]` 清空。显式选择结果或实际选择修复排队一次 `select:end`;
159
+ 仅选择变化返回 false,不生成 History/配置通知。构造返回 undefined、空命令、相同值
160
+ 和同 owner 最终覆盖回原值都是普通 noop,`afterInstall` 不运行。
161
+
162
+ `afterInstall` 位于 Core 字段/结构、lifecycle、hook、scene observers 及选择安装之后,frame drain、
163
+ History finish 和排队配置完成通知之前。它只在实际 Scene 安装后同步调用一次,不随 undo/redo 重放;
164
+ 应用自己的数据对应关系仍由自己的注册/清理链维护。回调异常直接传播,已安装的正常变化保留。
165
+ 构造阶段销毁 Select/Root 时停止本命令;安装后销毁则保留已安装变化并停止旧 controller 的后续完成。
166
+
167
+ 命令 caller 不需要 prepare 属性、操作 History scope 或驱动帧。只需单独结束当前交互时使用
168
+ `select.finishCurrentInteraction()`:完成已接受交互,取消尚未提交的画线草稿,无活动交互时无操作。
169
+
170
+ 需要当前 Workspace 作为对齐参考范围时,延迟到内置 align 的稳定阶段读取:
171
+
172
+ ```ts
173
+ select.align(AlignType.JustifyEnd, () => workspace.transform.getRenderCoverageBounds());
174
+ ```
175
+
176
+ 参考函数最多同步调用一次,几何稳定后读取;静态 world Rect 仍直接传入。矩阵/children 的构造同样应
177
+ 放入 `applyCommand` 的同步构造函数。完整参数与时序见 [Editor 规范](../../docs/specs/editor-interaction.md#64-应用公开命令边界)。
178
+
179
+ `Select.resize()` 是属性面板使用的公开尺寸命令:
134
180
 
135
181
  ```ts
136
182
  const bounds = element.transform.getWorldVisualBoundsView();
@@ -3,7 +3,7 @@ import { LineTree, type LineTreeBranchTailSource, type LineDecoration } from "@f
3
3
  import { type PointView, type Rect } from "@fulate/share";
4
4
  import type { HistoryManager, HistoryScope } from "../../history";
5
5
  import type { LineTool } from "../../line";
6
- import type { Select, SelectedPropertyPatch } from "../../select";
6
+ import type { Select, SelectCommand, SelectedPropertyPatch } from "../../select";
7
7
  import { type SelectionOverlayProjection, type SelectionOverlayTarget } from "../../select/projection";
8
8
  import { type FlipDirection, type ResizeOptions } from "../../select/transform";
9
9
  import type { Snap } from "../../select/snap";
@@ -159,7 +159,9 @@ export declare class EditorInteractionController {
159
159
  snap: import("../../select/snap").PortSnapResult | null;
160
160
  };
161
161
  hasLineToolSession(tool: LineTool): boolean;
162
- applyCommand(ownerEffects: readonly OwnerPropertyChange[], structureEffects: readonly StructureChange[], scope: HistoryScope | undefined, selectionAfter: readonly Element[], options?: ApplyCommandOptions): void;
162
+ /** 同一命令 owner 的公开入口,构造只在当前交互和几何稳定后运行。 */
163
+ executeCommand(createCommand: (selection: readonly Element[]) => SelectCommand | undefined): boolean;
164
+ applyCommand(ownerEffects: readonly OwnerPropertyChange[], structureEffects: readonly StructureChange[], scope: HistoryScope | undefined, selectionAfter: readonly Element[] | undefined, options?: ApplyCommandOptions): boolean;
163
165
  finishCurrentInteraction(): void;
164
166
  destroy(): void;
165
167
  }
@@ -25,7 +25,7 @@ export interface PointerGesture {
25
25
  finish(intent?: PointerIntent, cancelled?: boolean): void;
26
26
  }
27
27
  export interface ApplyCommandOptions {
28
- readonly beforeDrain?: () => void;
28
+ readonly afterInstall?: () => void;
29
29
  readonly afterHistory?: () => void;
30
30
  readonly installSelection?: () => boolean;
31
31
  }
@@ -65,7 +65,10 @@ export declare class HistoryManager {
65
65
  get undoCount(): number;
66
66
  get redoCount(): number;
67
67
  bindSelection(adapter: HistorySelectionAdapter | null): void;
68
- openScope(selectionBefore?: readonly Element<import("@fulate/core").ElementProperties, import("@fulate/core").BaseElementOption<any>, Partial<Omit<import("@fulate/core").BaseElementOption<any>, "key" | "children">>>[]): HistoryScope | undefined;
68
+ openScope(selectionBefore?: readonly Element<import("@fulate/core").ElementProperties, import("@fulate/core").BaseElementOption<any>, Partial<Omit<import("@fulate/core").BaseElementOption<any>, "key" | "children">>>[], options?: {
69
+ readonly recordHistory?: boolean;
70
+ readonly trackChanges?: boolean;
71
+ }): HistoryScope | undefined;
69
72
  /** @internal Publishes one completed actual configured semantic change. */
70
73
  publishConfiguredChange(changed: boolean | undefined): void;
71
74
  /** @internal 只由存在Canvas净变化或外部change的scope完成时写入。 */
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Select } from "./select/index";
2
- export type { ConnectionGesture, CopyContext, ClipboardDataProducer, DeleteContext, FlipDirection, PasteContext, PasteOptions, PasteParentResolver, ResizeAnchor, ResizeOptions, SelectedPropertyPatch, SelectKeyboardCommands, SelectOption, ToolsConnectionIntent } from "./select/index";
2
+ export type { ConnectionGesture, CopyContext, ClipboardDataProducer, DeleteContext, FlipDirection, PasteContext, PasteOptions, PasteParentResolver, ResizeAnchor, ResizeOptions, SelectedPropertyPatch, SelectCommand, SelectKeyboardCommands, SelectOption, ToolsConnectionIntent } from "./select/index";
3
3
  export { Snap } from "./select/snap";
4
4
  export { HistoryManager } from "./history/index";
5
5
  export type { EditorHistoryEntry, HistoryChange, HistoryPlacementChange, HistoryPropertyChange, HistoryScope } from "./history/index";
@@ -9,6 +9,7 @@ export { Rule } from "./rule/index";
9
9
  export type { RuleOption } from "./rule/index";
10
10
  export { EditorLayer } from "./editor-layer";
11
11
  export { alignElements } from "./select/align";
12
- export type { AlignType } from "./select/align";
12
+ export { AlignType } from "./select/align";
13
+ export type { AlignReference } from "./select/align";
13
14
  export { ArrangeType } from "./select/arrange";
14
15
  export type { ClipboardPlugin } from "./select/clipboard";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Element, ElementProperties, ElementTransform, Layer, isFormControlTarget, projectConnectionPortPoint, resolveOrigin, resolveReferenceTransform } from "@fulate/core";
2
2
  import { BaseLine, ContentEditingCapabilityToken, DEFAULT_LINE_TREE_BRANCH_STYLE, Group, Line, LineTree, PointEditingCapabilityToken, SelectionBehaviorCapabilityToken, SelectionBehaviorMode, SelectionDiveMode, SelectionSnapMode, SnapSourceCapabilityToken, TransformInteractionCapabilityToken, addLineTreeBranchAtTail, applyLineTreeTopologyDelta, connectionPortAccepts, createLineTreeTopology, createLineTreeTopologyDelta, getEditorCapability, getEditorFrameFlush, getEndpointRelationIndex, hasConnectionPortCapacity, isEndpointRelationSource, moveLineTreePoint, remapLineTreeRelations, resolveConnectEndpointChange, resolveConnectionPortChanges, resolveDisconnectEndpointChanges, resolveSelectionFrameBounds, workspaceFocusInsetsKey } from "@fulate/ui";
3
- import { Bound, Intersection, isIdentityMatrix, qrDecompose, transformPoint } from "@fulate/share";
3
+ import { Bound, Intersection, isIdentityMatrix, makeBoundingBoxFromRects, qrDecompose, transformPoint } from "@fulate/share";
4
4
  import { cloneDeep, has, isNull, isString, isUndefined, last } from "lodash-es";
5
5
  import { deserializeElement } from "@fulate/import";
6
6
  import RBush from "rbush";
@@ -114,11 +114,15 @@ function rectCorners$1(rect) {
114
114
  * capability is authoritative (for example Text/KeyValue authored bounds);
115
115
  * owners without one use the shared local visual/layout fallback.
116
116
  */
117
- function worldSelectionFramePoints(owner) {
118
- const localFrame = resolveSelectionFrameBounds(owner.element, owner.transform);
119
- const matrix = owner.element.transform.getWorldMatrixView();
117
+ function worldSelectionFramePoints(element, transform) {
118
+ const localFrame = resolveSelectionFrameBounds(element, transform);
119
+ const matrix = element.transform.getWorldMatrixView();
120
120
  return rectCorners$1(localFrame).map((point) => transformPoint(matrix, point));
121
121
  }
122
+ /** @internal Resolve the world AABB used by the selection frame. */
123
+ function getWorldSelectionBounds(element, transform) {
124
+ return Bound.fromPoints(worldSelectionFramePoints(element, transform));
125
+ }
122
126
  var ROTATED_RESIZE_CURSORS = [
123
127
  "ns-resize",
124
128
  "nesw-resize",
@@ -178,9 +182,7 @@ function createWorldSelectionFrame(owners, fallback) {
178
182
  if (fallback) return fallback;
179
183
  return frameFromSingleOwner(owners[0], ownerFrame);
180
184
  }
181
- const bounds = resolveSelectionFrameBounds(owners[0].element, owners[0].transform);
182
- const matrix = owners[0].element.transform.getWorldMatrixView();
183
- const worldBounds = Bound.fromPoints(rectCorners$1(bounds).map((point) => transformPoint(matrix, point)));
185
+ const worldBounds = getWorldSelectionBounds(owners[0].element, owners[0].transform);
184
186
  if ((worldBounds.width === 0 || worldBounds.height === 0) && fallback) return fallback;
185
187
  return createSelectionFrameProjection({
186
188
  left: worldBounds.minX,
@@ -190,19 +192,9 @@ function createWorldSelectionFrame(owners, fallback) {
190
192
  angle: 0
191
193
  });
192
194
  }
193
- let left = Infinity;
194
- let top = Infinity;
195
- let right = -Infinity;
196
- let bottom = -Infinity;
197
- for (const owner of owners) for (const point of worldSelectionFramePoints(owner)) {
198
- if (point.x < left) left = point.x;
199
- if (point.y < top) top = point.y;
200
- if (point.x > right) right = point.x;
201
- if (point.y > bottom) bottom = point.y;
202
- }
203
- if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(right) || !Number.isFinite(bottom)) return fallback ?? null;
204
- const width = right - left;
205
- const height = bottom - top;
195
+ const bounds = makeBoundingBoxFromRects(owners.map(({ element, transform }) => getWorldSelectionBounds(element, transform)));
196
+ if (!Number.isFinite(bounds.left) || !Number.isFinite(bounds.top) || !Number.isFinite(bounds.width) || !Number.isFinite(bounds.height)) return fallback ?? null;
197
+ const { left, top, width, height } = bounds;
206
198
  if ((width === 0 || height === 0) && fallback) return fallback;
207
199
  return createSelectionFrameProjection({
208
200
  left,
@@ -1290,18 +1282,19 @@ var EditorInteractionController = class {
1290
1282
  restoreSelectionForHistory(elements) {
1291
1283
  this.replaceSelection(elements);
1292
1284
  }
1293
- replaceSelection(elements) {
1285
+ replaceSelection(elements, onlyIfChanged = false) {
1294
1286
  this.pendingSelectionRepair = null;
1295
- if (this.selectState?.kind !== "content") this.installSelectState({
1296
- kind: "idle",
1297
- gesture: null
1298
- });
1299
1287
  const selected = /* @__PURE__ */ new Set();
1300
1288
  for (const element of elements) if (element.activeRoot === this.root) selected.add(element);
1301
1289
  const topLevel = [...selected].filter((element) => {
1302
1290
  for (let parent = element.parent; parent; parent = parent.parent) if (selected.has(parent)) return false;
1303
1291
  return true;
1304
1292
  });
1293
+ if (onlyIfChanged && topLevel.length === this.selection.length && topLevel.every((element, index) => element === this.selection[index])) return;
1294
+ if (this.selectState?.kind !== "content") this.installSelectState({
1295
+ kind: "idle",
1296
+ gesture: null
1297
+ });
1305
1298
  const owners = [];
1306
1299
  for (const element of topLevel) {
1307
1300
  const behavior = getEditorCapability(element, SelectionBehaviorCapabilityToken);
@@ -1527,7 +1520,7 @@ var EditorInteractionController = class {
1527
1520
  this.select.transform.markPaintDirty();
1528
1521
  }
1529
1522
  applyAuthoredBatch(ownerEffects, structureEffects, scope) {
1530
- this.root.applyChangeBatch(ownerEffects, structureEffects, scope);
1523
+ return this.root.applyChangeBatch(ownerEffects, structureEffects, scope);
1531
1524
  }
1532
1525
  onPointerDown(event) {
1533
1526
  if (this.activeGesture) return;
@@ -2558,26 +2551,53 @@ var EditorInteractionController = class {
2558
2551
  hasLineToolSession(tool) {
2559
2552
  return this.lineSession?.tool === tool || this.lineTreeSession?.tool === tool;
2560
2553
  }
2554
+ /** 同一命令 owner 的公开入口,构造只在当前交互和几何稳定后运行。 */
2555
+ executeCommand(createCommand) {
2556
+ this.finishCurrentInteraction();
2557
+ if (this.select.interactionController !== this) return false;
2558
+ this.root.frameRuntime.drain();
2559
+ if (this.select.interactionController !== this) return false;
2560
+ const command = createCommand(this.selection);
2561
+ if (this.select.interactionController !== this || !command) return false;
2562
+ const scope = this.history.openScope(this.selection, {
2563
+ recordHistory: command.recordHistory,
2564
+ trackChanges: true
2565
+ });
2566
+ return this.applyCommand(resolveConnectionPortChanges(this.root, command.ownerChanges ?? []), command.structureChanges ?? [], scope, command.selectionAfter, command);
2567
+ }
2561
2568
  applyCommand(ownerEffects, structureEffects, scope, selectionAfter, options) {
2562
2569
  const selectionOwners = this.owners;
2570
+ const selectionBefore = this.selection;
2563
2571
  let installedSelection;
2564
- this.applyAuthoredBatch(ownerEffects, structureEffects, scope);
2565
- if (options?.installSelection ? options.installSelection() : this.owners === selectionOwners || this.pendingSelectionRepair === this.owners) {
2566
- this.replaceSelection(selectionAfter);
2572
+ const installed = this.applyAuthoredBatch(ownerEffects, structureEffects, scope);
2573
+ if (this.select.interactionController !== this) {
2574
+ scope?.discard();
2575
+ return installed;
2576
+ }
2577
+ const canInstallSelection = (selectionAfter !== void 0 || installed && structureEffects.length > 0) && (options?.installSelection ? options.installSelection() : this.owners === selectionOwners || this.pendingSelectionRepair === this.owners);
2578
+ if (canInstallSelection) {
2579
+ this.replaceSelection(selectionAfter ?? this.selection, selectionAfter === void 0);
2567
2580
  if (options?.installSelection) installedSelection = {
2568
2581
  owners: this.owners,
2569
2582
  interaction: this.controllerState
2570
2583
  };
2571
2584
  }
2572
- options?.beforeDrain?.();
2585
+ const selectionRepaired = canInstallSelection && this.selection.length !== selectionBefore.length;
2586
+ if (installed) options?.afterInstall?.();
2573
2587
  if (this.select.interactionController !== this) {
2574
2588
  scope?.discard();
2575
- return;
2589
+ return installed;
2576
2590
  }
2577
2591
  this.root.frameRuntime.drain();
2578
- this.publishConfiguredChange(scope?.finish(this.selection));
2592
+ if (this.select.interactionController !== this) {
2593
+ scope?.discard();
2594
+ return installed;
2595
+ }
2596
+ const changed = scope?.finish(this.selection) ?? installed;
2597
+ this.publishConfiguredChange(changed);
2579
2598
  options?.afterHistory?.();
2580
- if (!options?.installSelection || installedSelection?.owners === this.owners && installedSelection.interaction === this.controllerState) this.notifySelect("select:end");
2599
+ if ((selectionAfter !== void 0 || selectionRepaired) && (!options?.installSelection || installedSelection?.owners === this.owners && installedSelection.interaction === this.controllerState)) this.notifySelect("select:end");
2600
+ return changed;
2581
2601
  }
2582
2602
  finishCurrentInteraction() {
2583
2603
  if (this.contentSession) {
@@ -2749,9 +2769,9 @@ var HistoryManager = class {
2749
2769
  bindSelection(adapter) {
2750
2770
  this.selectionAdapter = adapter;
2751
2771
  }
2752
- openScope(selectionBefore = this.selectionAdapter?.getSelection() ?? []) {
2753
- const recordHistory = this.enabled;
2754
- if (!recordHistory && !this.selectionAdapter?.hasConfiguredChangeListener()) return void 0;
2772
+ openScope(selectionBefore = this.selectionAdapter?.getSelection() ?? [], options) {
2773
+ const recordHistory = this.enabled && options?.recordHistory !== false;
2774
+ if (!recordHistory && !options?.trackChanges && !this.selectionAdapter?.hasConfiguredChangeListener()) return void 0;
2755
2775
  return new ActiveHistoryScope(this, recordHistory ? selectionKeys(selectionBefore) : null, recordHistory);
2756
2776
  }
2757
2777
  /** @internal Publishes one completed actual configured semantic change. */
@@ -2887,86 +2907,142 @@ var HistoryManager = class {
2887
2907
  };
2888
2908
  //#endregion
2889
2909
  //#region packages/tools/src/select/align.ts
2890
- function resolveOffsets(rects, type) {
2891
- if (type === "justify-start") {
2892
- const target = Math.min(...rects.map(({ left }) => left));
2910
+ /** Select 支持的对齐与分布方向。 */
2911
+ var AlignType = /* @__PURE__ */ function(AlignType) {
2912
+ AlignType["JustifyStart"] = "justify-start";
2913
+ AlignType["JustifyCenter"] = "justify-center";
2914
+ AlignType["JustifyEnd"] = "justify-end";
2915
+ AlignType["JustifyBetween"] = "justify-between";
2916
+ AlignType["AlignStart"] = "align-start";
2917
+ AlignType["AlignCenter"] = "align-center";
2918
+ AlignType["AlignEnd"] = "align-end";
2919
+ AlignType["AlignBetween"] = "align-between";
2920
+ return AlignType;
2921
+ }({});
2922
+ function compareElementKeys(left, right) {
2923
+ if (left.element.key < right.element.key) return -1;
2924
+ if (left.element.key > right.element.key) return 1;
2925
+ return 0;
2926
+ }
2927
+ function compareDistributionRects(left, right, axis) {
2928
+ const primary = left[axis.position] - right[axis.position];
2929
+ if (primary !== 0) return primary;
2930
+ const secondary = left[axis.crossPosition] - right[axis.crossPosition];
2931
+ if (secondary !== 0) return secondary;
2932
+ return compareElementKeys(left, right);
2933
+ }
2934
+ function resolveDistributionOffsets(rects, reference, axis) {
2935
+ const { position, size, offset } = axis;
2936
+ const start = reference[position];
2937
+ const referenceEnd = start + reference[size];
2938
+ let totalSize = 0;
2939
+ for (const rect of rects) totalSize += rect[size];
2940
+ const roundoff = Math.max(1, Math.abs(start), Math.abs(referenceEnd), Math.abs(totalSize)) * Number.EPSILON * rects.length * 4;
2941
+ const orderStep = roundoff * 8;
2942
+ const freeSpace = reference[size] - totalSize;
2943
+ const fittedFreeSpace = Math.abs(freeSpace) <= roundoff ? 0 : freeSpace;
2944
+ const gap = fittedFreeSpace / (rects.length - 1);
2945
+ const distributionEnd = start + totalSize + fittedFreeSpace;
2946
+ rects.sort((left, right) => compareDistributionRects(left, right, axis));
2947
+ const lastIndex = rects.length - 1;
2948
+ const latestStarts = new Array(rects.length);
2949
+ latestStarts[lastIndex] = distributionEnd - rects[lastIndex][size];
2950
+ for (let index = lastIndex - 1; index >= 0; index--) latestStarts[index] = Math.min(distributionEnd - rects[index][size], latestStarts[index + 1] - orderStep);
2951
+ if (latestStarts[0] < start) return;
2952
+ let idealTarget = start;
2953
+ let previousTarget = start;
2954
+ for (let index = 0; index <= lastIndex; index++) {
2955
+ let target;
2956
+ if (index === 0) target = start;
2957
+ else if (index === lastIndex) target = latestStarts[lastIndex];
2958
+ else {
2959
+ idealTarget += rects[index - 1][size] + gap;
2960
+ target = Math.min(latestStarts[index], Math.max(previousTarget + orderStep, idealTarget));
2961
+ }
2962
+ const delta = target - rects[index][position];
2963
+ rects[index][offset] = Math.abs(delta) <= roundoff ? 0 : delta;
2964
+ previousTarget = target;
2965
+ }
2966
+ }
2967
+ function resolveOffsets(rects, type, reference) {
2968
+ if (type === AlignType.JustifyStart) {
2969
+ const target = reference.left;
2893
2970
  rects.forEach((rect) => {
2894
2971
  rect.dx = target - rect.left;
2895
2972
  });
2896
2973
  return;
2897
2974
  }
2898
- if (type === "justify-center") {
2899
- const target = (Math.min(...rects.map((rect) => rect.left)) + Math.max(...rects.map((rect) => rect.left + rect.width))) / 2;
2975
+ if (type === AlignType.JustifyCenter) {
2976
+ const target = reference.left + reference.width / 2;
2900
2977
  rects.forEach((rect) => {
2901
2978
  rect.dx = target - (rect.left + rect.width / 2);
2902
2979
  });
2903
2980
  return;
2904
2981
  }
2905
- if (type === "justify-end") {
2906
- const target = Math.max(...rects.map((rect) => rect.left + rect.width));
2982
+ if (type === AlignType.JustifyEnd) {
2983
+ const target = reference.left + reference.width;
2907
2984
  rects.forEach((rect) => {
2908
2985
  rect.dx = target - (rect.left + rect.width);
2909
2986
  });
2910
2987
  return;
2911
2988
  }
2912
- if (type === "justify-between") {
2913
- rects.sort((left, right) => left.left - right.left);
2914
- const start = rects[0].left;
2915
- const end = last(rects);
2916
- const total = rects.reduce((sum, rect) => sum + rect.width, 0);
2917
- const gap = (end.left + end.width - start - total) / (rects.length - 1);
2918
- let x = start;
2919
- for (const rect of rects) {
2920
- rect.dx = x - rect.left;
2921
- x += rect.width + gap;
2922
- }
2989
+ if (type === AlignType.JustifyBetween) {
2990
+ resolveDistributionOffsets(rects, reference, {
2991
+ position: "left",
2992
+ crossPosition: "top",
2993
+ size: "width",
2994
+ offset: "dx"
2995
+ });
2923
2996
  return;
2924
2997
  }
2925
- if (type === "align-start") {
2926
- const target = Math.min(...rects.map(({ top }) => top));
2998
+ if (type === AlignType.AlignStart) {
2999
+ const target = reference.top;
2927
3000
  rects.forEach((rect) => {
2928
3001
  rect.dy = target - rect.top;
2929
3002
  });
2930
3003
  return;
2931
3004
  }
2932
- if (type === "align-center") {
2933
- const target = (Math.min(...rects.map((rect) => rect.top)) + Math.max(...rects.map((rect) => rect.top + rect.height))) / 2;
3005
+ if (type === AlignType.AlignCenter) {
3006
+ const target = reference.top + reference.height / 2;
2934
3007
  rects.forEach((rect) => {
2935
3008
  rect.dy = target - (rect.top + rect.height / 2);
2936
3009
  });
2937
3010
  return;
2938
3011
  }
2939
- if (type === "align-end") {
2940
- const target = Math.max(...rects.map((rect) => rect.top + rect.height));
3012
+ if (type === AlignType.AlignEnd) {
3013
+ const target = reference.top + reference.height;
2941
3014
  rects.forEach((rect) => {
2942
3015
  rect.dy = target - (rect.top + rect.height);
2943
3016
  });
2944
3017
  return;
2945
3018
  }
2946
- rects.sort((left, right) => left.top - right.top);
2947
- const start = rects[0].top;
2948
- const end = last(rects);
2949
- const total = rects.reduce((sum, rect) => sum + rect.height, 0);
2950
- const gap = (end.top + end.height - start - total) / (rects.length - 1);
2951
- let y = start;
2952
- for (const rect of rects) {
2953
- rect.dy = y - rect.top;
2954
- y += rect.height + gap;
2955
- }
3019
+ resolveDistributionOffsets(rects, reference, {
3020
+ position: "top",
3021
+ crossPosition: "left",
3022
+ size: "height",
3023
+ offset: "dy"
3024
+ });
2956
3025
  }
2957
- function alignElements(select, type) {
3026
+ function alignElements(select, type, referenceRect) {
2958
3027
  const controller = select.interactionController;
2959
3028
  let elements = controller.selection;
2960
- if (elements.length < 2 || type.endsWith("between") && elements.length < 3) return;
3029
+ const between = type === AlignType.JustifyBetween || type === AlignType.AlignBetween;
3030
+ const minimum = referenceRect ? between ? 2 : 1 : between ? 3 : 2;
3031
+ if (elements.length < minimum) return;
2961
3032
  controller.finishCurrentInteraction();
3033
+ if (select.interactionController !== controller) return;
2962
3034
  controller.root.frameRuntime.drain();
2963
3035
  if (select.interactionController !== controller) return;
2964
3036
  elements = controller.selection;
2965
- if (elements.length < 2 || type.endsWith("between") && elements.length < 3) return;
3037
+ if (elements.length < minimum) return;
3038
+ const reference = typeof referenceRect === "function" ? referenceRect() : referenceRect;
3039
+ if (select.interactionController !== controller) return;
2966
3040
  const rects = elements.map((element) => {
2967
- const bounds = element.transform.getWorldVisualBoundsView();
3041
+ const transform = getEditorCapability(element, TransformInteractionCapabilityToken);
3042
+ const bounds = getWorldSelectionBounds(element, transform);
2968
3043
  return {
2969
3044
  element,
3045
+ transform,
2970
3046
  left: bounds.left,
2971
3047
  top: bounds.top,
2972
3048
  width: bounds.width,
@@ -2975,14 +3051,21 @@ function alignElements(select, type) {
2975
3051
  dy: 0
2976
3052
  };
2977
3053
  });
2978
- resolveOffsets(rects, type);
3054
+ resolveOffsets(rects, type, reference ?? makeBoundingBoxFromRects(rects));
2979
3055
  const changes = [];
2980
3056
  for (const rect of rects) {
2981
3057
  if (rect.dx === 0 && rect.dy === 0) continue;
2982
- const capability = getEditorCapability(rect.element, TransformInteractionCapabilityToken);
2983
- if (!capability) continue;
2984
- const baseline = capability.captureTransformBaseline();
2985
- changes.push(...capability.resolveTransformChange(baseline, { worldDelta: new DOMMatrix().translate(rect.dx, rect.dy) }));
3058
+ const { transform } = rect;
3059
+ if (!transform) continue;
3060
+ const baseline = transform.captureTransformBaseline();
3061
+ changes.push(...transform.resolveTransformChange(baseline, { worldDelta: new DOMMatrix([
3062
+ 1,
3063
+ 0,
3064
+ 0,
3065
+ 1,
3066
+ rect.dx,
3067
+ rect.dy
3068
+ ]) }));
2986
3069
  }
2987
3070
  if (changes.length === 0) return;
2988
3071
  const scope = select.history.openScope(elements);
@@ -3357,7 +3440,7 @@ function pasteData(select, controller, pasteSelection, task, data, deserializers
3357
3440
  const scope = controller.history.openScope(controller.selection);
3358
3441
  const installSelection = () => controller.ownsPasteSelection(pasteSelection, task);
3359
3442
  controller.applyCommand(changes, appendStructure(placements), scope, elements, options?.afterInstall ? {
3360
- beforeDrain: () => options.afterInstall({
3443
+ afterInstall: () => options.afterInstall({
3361
3444
  applicationData: data.applicationData,
3362
3445
  keyMap,
3363
3446
  history: recordHistory ? scope : void 0
@@ -3432,7 +3515,7 @@ function deleteElements(select, afterInstall) {
3432
3515
  const recordsHistory = select.history.enabled;
3433
3516
  const scope = select.history.openScope(selected);
3434
3517
  controller.applyCommand([], structure, scope, [], {
3435
- beforeDrain: afterInstall ? () => afterInstall({
3518
+ afterInstall: afterInstall ? () => afterInstall({
3436
3519
  elements: subjects,
3437
3520
  history: recordsHistory ? scope : void 0
3438
3521
  }) : void 0,
@@ -3847,6 +3930,19 @@ var Select = class extends Element {
3847
3930
  select(elements) {
3848
3931
  this.controller?.setSelection(elements);
3849
3932
  }
3933
+ /** 完成已接受的当前交互,取消未提交的画线草稿;无活动交互时无操作。 */
3934
+ finishCurrentInteraction() {
3935
+ this.controller?.finishCurrentInteraction();
3936
+ }
3937
+ /**
3938
+ * 先结束旧交互并稳定几何,再同步调用 createCommand,传入 live selection。
3939
+ * 返回本命令 Canvas configured 的净变化,与 History/监听器是否开启无关;不包含旧交互。
3940
+ * 返回 undefined 的构造或空/相同值命令是普通 noop。未激活的 Select 不调用构造。
3941
+ * 普通选区同一 patch 使用 setProperties;此入口用于 owner-specific 与结构组合操作。
3942
+ */
3943
+ applyCommand(createCommand) {
3944
+ return this.controller?.executeCommand(createCommand) ?? false;
3945
+ }
3850
3946
  /**
3851
3947
  * Groups the live multi-selection. Accepted active Transform work finishes
3852
3948
  * before the independent Group command; stale pointer continuation is ignored.
@@ -3863,8 +3959,20 @@ var Select = class extends Element {
3863
3959
  unGroup() {
3864
3960
  return unGroup(this);
3865
3961
  }
3866
- align(type) {
3867
- return alignElements(this, type);
3962
+ /**
3963
+ * Aligns the live selection in world space. Without `referenceRect`, the
3964
+ * current selection-frame AABB is the reference. A supplied world Rect is
3965
+ * used directly; a reference callback runs once after the previous interaction
3966
+ * and pending geometry finish. Distribution keeps strict edge gaps when they are numerically
3967
+ * possible and uses bounded overlap when the selected sizes exceed the reference,
3968
+ * while keeping every selection-frame AABB inside it and preserving order. The
3969
+ * current axis is a noop only when an element cannot fit or no ordered layout is
3970
+ * representable. A successful non-noop creates one Transform batch and one Select
3971
+ * History entry; a noop creates neither a transform, History entry, nor configured
3972
+ * change.
3973
+ */
3974
+ align(type, referenceRect) {
3975
+ return alignElements(this, type, referenceRect);
3868
3976
  }
3869
3977
  /** 按当前 Layer 与 sibling 顺序排列 live selection。 */
3870
3978
  arrange(type) {
@@ -5066,4 +5174,4 @@ var EditorLayer = class extends Layer {
5066
5174
  }
5067
5175
  };
5068
5176
  //#endregion
5069
- export { ArrangeType, EditorLayer, HistoryManager, LineTool, Rule, Select, Snap, alignElements };
5177
+ export { AlignType, ArrangeType, EditorLayer, HistoryManager, LineTool, Rule, Select, Snap, alignElements };
@@ -1,3 +1,16 @@
1
+ import { type Rect } from "@fulate/share";
1
2
  import type { Select } from "./index";
2
- export type AlignType = "justify-start" | "justify-center" | "justify-end" | "justify-between" | "align-start" | "align-center" | "align-end" | "align-between";
3
- export declare function alignElements(select: Select, type: AlignType): void;
3
+ /** Select 支持的对齐与分布方向。 */
4
+ export declare enum AlignType {
5
+ JustifyStart = "justify-start",
6
+ JustifyCenter = "justify-center",
7
+ JustifyEnd = "justify-end",
8
+ JustifyBetween = "justify-between",
9
+ AlignStart = "align-start",
10
+ AlignCenter = "align-center",
11
+ AlignEnd = "align-end",
12
+ AlignBetween = "align-between"
13
+ }
14
+ /** World reference Rect, or a synchronous read after interaction and geometry completion. */
15
+ export type AlignReference = Readonly<Rect> | (() => Readonly<Rect>);
16
+ export declare function alignElements(select: Select, type: AlignType, referenceRect?: AlignReference): void;
@@ -1,7 +1,7 @@
1
- import { Element, ElementProperties, type BaseElementOption, type ConnectionPortRef, type Layer, type Root } from "@fulate/core";
1
+ import { Element, ElementProperties, type BaseElementOption, type ConnectionPortRef, type Layer, type Root, type OwnerPropertyChange, type StructureChange } from "@fulate/core";
2
2
  import { EditorInteractionController } from "../editor/interaction-controller";
3
3
  import { HistoryManager } from "../history";
4
- import { type AlignType } from "./align";
4
+ import { type AlignType, type AlignReference } from "./align";
5
5
  import { type ArrangeType } from "./arrange";
6
6
  import { type ClipboardDataProducer, type ClipboardPlugin, type DeleteContext, type PasteOptions } from "./clipboard";
7
7
  import { Snap } from "./snap";
@@ -31,6 +31,23 @@ export interface SelectOption extends BaseElementOption {
31
31
  }
32
32
  /** 一次 selection property intent;同一 canonical reference 直接交给各 owner。 */
33
33
  export type SelectedPropertyPatch = Readonly<Record<string, unknown>>;
34
+ /** 应用同步构造的 owner-specific 属性与结构命令;值按原引用交给对应 owner。 */
35
+ export interface SelectCommand {
36
+ readonly ownerChanges?: readonly OwnerPropertyChange[];
37
+ readonly structureChanges?: readonly StructureChange[];
38
+ /**
39
+ * 省略保留当前选择意图;结构提交后过滤已移除或被选中祖先覆盖的元素。
40
+ * 空数组清空选择;仅选择变化不进入 History 或配置通知。
41
+ */
42
+ readonly selectionAfter?: readonly Element[];
43
+ /** false 只关闭本命令的 History;true/省略仍服从 history.enabled。 */
44
+ readonly recordHistory?: boolean;
45
+ /**
46
+ * 有实际 Scene 安装时同步调用一次,位于 Core lifecycle/hooks/observers 及选择安装之后,
47
+ * frame drain、History finish 与配置完成通知之前。不会随 undo/redo 重放;异常直接传播。
48
+ */
49
+ readonly afterInstall?: () => void;
50
+ }
34
51
  /**
35
52
  * 轻量 Editor command/paint surface;交互状态由唯一 controller 拥有。
36
53
  *
@@ -63,6 +80,15 @@ export declare class Select extends Element {
63
80
  get interactionController(): EditorInteractionController;
64
81
  /** 以一次独立操作选择给定元素,并在完成后异步通知 `select:end`。 */
65
82
  select(elements: readonly Element[]): void;
83
+ /** 完成已接受的当前交互,取消未提交的画线草稿;无活动交互时无操作。 */
84
+ finishCurrentInteraction(): void;
85
+ /**
86
+ * 先结束旧交互并稳定几何,再同步调用 createCommand,传入 live selection。
87
+ * 返回本命令 Canvas configured 的净变化,与 History/监听器是否开启无关;不包含旧交互。
88
+ * 返回 undefined 的构造或空/相同值命令是普通 noop。未激活的 Select 不调用构造。
89
+ * 普通选区同一 patch 使用 setProperties;此入口用于 owner-specific 与结构组合操作。
90
+ */
91
+ applyCommand(createCommand: (selection: readonly Element[]) => SelectCommand | undefined): boolean;
66
92
  /**
67
93
  * Groups the live multi-selection. Accepted active Transform work finishes
68
94
  * before the independent Group command; stale pointer continuation is ignored.
@@ -75,7 +101,19 @@ export declare class Select extends Element {
75
101
  * An obvious non-Group noop does not drain the frame runtime.
76
102
  */
77
103
  unGroup(): Element<ElementProperties, BaseElementOption<any>, Partial<Omit<BaseElementOption<any>, "key" | "children">>>[];
78
- align(type: AlignType): void;
104
+ /**
105
+ * Aligns the live selection in world space. Without `referenceRect`, the
106
+ * current selection-frame AABB is the reference. A supplied world Rect is
107
+ * used directly; a reference callback runs once after the previous interaction
108
+ * and pending geometry finish. Distribution keeps strict edge gaps when they are numerically
109
+ * possible and uses bounded overlap when the selected sizes exceed the reference,
110
+ * while keeping every selection-frame AABB inside it and preserving order. The
111
+ * current axis is a noop only when an element cannot fit or no ordered layout is
112
+ * representable. A successful non-noop creates one Transform batch and one Select
113
+ * History entry; a noop creates neither a transform, History entry, nor configured
114
+ * change.
115
+ */
116
+ align(type: AlignType, referenceRect?: AlignReference): void;
79
117
  /** 按当前 Layer 与 sibling 顺序排列 live selection。 */
80
118
  arrange(type: ArrangeType): void;
81
119
  /**
@@ -1,6 +1,6 @@
1
1
  import { Element } from "@fulate/core";
2
2
  import { type PointEditTarget, type PointEditingScope, type SnapSourceCapability, type TransformInteractionCapability, type TransformInteractionPolicy } from "@fulate/ui";
3
- import { type PointView, type Rect } from "@fulate/share";
3
+ import { Bound, type PointView, type Rect } from "@fulate/share";
4
4
  export type ResizeHandle = "tl" | "tr" | "br" | "bl" | "mt" | "mr" | "mb" | "ml";
5
5
  export type SelectionRotationPivot = {
6
6
  readonly kind: "selection-center";
@@ -109,14 +109,16 @@ export interface SelectionProjectionTopology {
109
109
  readonly pointScopeOutline: readonly PointView[] | null;
110
110
  readonly pointScopeValid: boolean;
111
111
  }
112
- export declare function createSelectionFrameProjection({ left, top, width, height, angle }: Pick<SelectionFrameProjection, "left" | "top" | "width" | "height" | "angle">): SelectionFrameProjection;
112
+ /** @internal Resolve the world AABB used by the selection frame. */
113
+ export declare function getWorldSelectionBounds(element: Element, transform: TransformInteractionCapability | undefined): Bound;
114
+ export declare function createSelectionFrameProjection({ left, top, width, height, angle, }: Pick<SelectionFrameProjection, "left" | "top" | "width" | "height" | "angle">): SelectionFrameProjection;
113
115
  /** @internal Build a command frame from the current owner bounds. */
114
116
  export declare function createWorldSelectionFrame(owners: readonly ProjectionOwner[], fallback?: SelectionFrameProjection | null): SelectionFrameProjection;
115
117
  export declare function createSelectionRotationPivotSnapMarkers(frame: SelectionFrameProjection): readonly SelectionRotationPivotSnapMarker[];
116
118
  export declare function createSelectionProjectionMembership(owners: readonly ProjectionOwner[], policy: ProjectionPolicy): SelectionProjectionMembership;
117
119
  export declare function createSelectionProjectionTopology(owners: readonly ProjectionOwner[], isCurrent?: (owner: ProjectionOwner) => boolean): SelectionProjectionTopology;
118
120
  export declare function createProjectionOwner(element: Element, frameVisible?: boolean, pointScope?: PointEditingScope | null, pointEnabled?: boolean): ProjectionOwner;
119
- export declare function buildSelectionOverlayProjection({ owners, membership, topology, frame: frameOverride, policy, pivot, showPivot, pivotSnapMarkers, activePointTarget, marquee }: {
121
+ export declare function buildSelectionOverlayProjection({ owners, membership, topology, frame: frameOverride, policy, pivot, showPivot, pivotSnapMarkers, activePointTarget, marquee, }: {
120
122
  readonly owners: readonly ProjectionOwner[];
121
123
  readonly membership: SelectionProjectionMembership;
122
124
  readonly topology: SelectionProjectionTopology;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulate/tools",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",