@hankchen/canvas-editor 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -382,11 +382,22 @@ function resolveSnap(box, axes, threshold) {
382
382
 
383
383
  // src/spatial/create.ts
384
384
  var CREATE_DEAD_ZONE_SCREEN_PX = 4;
385
- function createRubberBandRect(type, start, current, axes, threshold) {
385
+ function createRubberBandRect(type, start, current, axes, threshold, ratio) {
386
386
  const x = Math.min(start.x, current.x);
387
387
  const y = Math.min(start.y, current.y);
388
- const width = Math.abs(current.x - start.x);
389
- const height = type === "QrCodeLayer" ? width : Math.abs(current.y - start.y);
388
+ const dx = Math.abs(current.x - start.x);
389
+ const dy = Math.abs(current.y - start.y);
390
+ let width = dx;
391
+ let height = dy;
392
+ if (type === "QrCodeLayer") {
393
+ height = width;
394
+ } else if (ratio) {
395
+ if (dx >= dy) {
396
+ height = Math.round(dx * ratio.h / ratio.w);
397
+ } else {
398
+ width = Math.round(dy * ratio.w / ratio.h);
399
+ }
400
+ }
390
401
  const resolution = resolveSnapPoints(
391
402
  { vertical: [x, x + width], horizontal: [y, y + height] },
392
403
  axes,
@@ -435,6 +446,38 @@ function resizeSnapPoints(start, handle, dx, dy) {
435
446
  if (SOUTH_HANDLES.has(handle)) horizontal.push(start.y + start.height + dy);
436
447
  return { vertical, horizontal };
437
448
  }
449
+ function proportionalDrivingAxis(handle, dx, dy) {
450
+ const corner = handleResizesWidth(handle) && handleResizesHeight(handle);
451
+ if (corner) return Math.abs(dx) >= Math.abs(dy) ? "width" : "height";
452
+ return handleResizesWidth(handle) ? "width" : "height";
453
+ }
454
+ function resizeBoxProportional(start, handle, reference, driving, dx, dy, minSize = RESIZE_MIN_SIZE_PX) {
455
+ const west = WEST_HANDLES.has(handle);
456
+ const north = NORTH_HANDLES.has(handle);
457
+ let width;
458
+ let height;
459
+ if (driving === "width") {
460
+ width = Math.max(west ? start.width - dx : start.width + dx, minSize);
461
+ height = Math.max(Math.round(width * reference.h / reference.w), minSize);
462
+ } else {
463
+ height = Math.max(north ? start.height - dy : start.height + dy, minSize);
464
+ width = Math.max(Math.round(height * reference.w / reference.h), minSize);
465
+ }
466
+ return {
467
+ x: west ? start.x + start.width - width : start.x,
468
+ y: north ? start.y + start.height - height : start.y,
469
+ width,
470
+ height
471
+ };
472
+ }
473
+ function resizeProportionalSnapPoints(start, handle, driving, dx, dy) {
474
+ if (driving === "width") {
475
+ const vertical = WEST_HANDLES.has(handle) ? [start.x + dx] : EAST_HANDLES.has(handle) ? [start.x + start.width + dx] : [];
476
+ return { vertical, horizontal: [] };
477
+ }
478
+ const horizontal = NORTH_HANDLES.has(handle) ? [start.y + dy] : SOUTH_HANDLES.has(handle) ? [start.y + start.height + dy] : [];
479
+ return { vertical: [], horizontal };
480
+ }
438
481
  var NO_AXES = { width: false, height: false };
439
482
  function resizableAxesAt(doc, path) {
440
483
  const layer = resolveLayer(doc, path);
@@ -1175,6 +1218,27 @@ function defaultNextTextStyle() {
1175
1218
  lineHeight: base.shape.lineHeight
1176
1219
  };
1177
1220
  }
1221
+ var RATIO_PRESETS = [
1222
+ { id: "free", ratio: null },
1223
+ { id: "1:1", ratio: { w: 1, h: 1 } },
1224
+ { id: "4:3", ratio: { w: 4, h: 3 } },
1225
+ { id: "3:4", ratio: { w: 3, h: 4 } },
1226
+ { id: "16:9", ratio: { w: 16, h: 9 } },
1227
+ { id: "9:16", ratio: { w: 9, h: 16 } }
1228
+ ];
1229
+ function defaultNextLayerRatio() {
1230
+ return "free";
1231
+ }
1232
+ function ratioValue(id) {
1233
+ const entry = RATIO_PRESETS.find((candidate) => candidate.id === id);
1234
+ return entry?.ratio ? { ...entry.ratio } : null;
1235
+ }
1236
+ function pickNextLayerRatio(raw) {
1237
+ return RATIO_PRESETS.some((candidate) => candidate.id === raw) ? raw : "free";
1238
+ }
1239
+ function defaultSizeForRatio(ratio) {
1240
+ return ratio.w >= ratio.h ? { width: 200, height: Math.round(200 * ratio.h / ratio.w) } : { width: Math.round(200 * ratio.w / ratio.h), height: 200 };
1241
+ }
1178
1242
  function pickNextTextStylePatch(raw) {
1179
1243
  const out = {};
1180
1244
  if (raw === null || typeof raw !== "object") return out;
@@ -1243,7 +1307,8 @@ function createDefaultLayer(type, textDefaults) {
1243
1307
  }
1244
1308
  }
1245
1309
  function createTemplateTable(options) {
1246
- const table = createDefaultLayer("TableLayer");
1310
+ const defaultTable = createDefaultLayer("TableLayer");
1311
+ const table = options.shape ? { ...defaultTable, shape: { ...defaultTable.shape, width: options.shape.width, height: options.shape.height } } : defaultTable;
1247
1312
  const templateBase = createDefaultLayer("TableRowTemplate");
1248
1313
  const cellBase = createDefaultLayer("TableCellLayer");
1249
1314
  const contentBase = createDefaultLayer("TextLayer");
@@ -1676,11 +1741,13 @@ var EditorStore = class {
1676
1741
  drag: null,
1677
1742
  resize: null,
1678
1743
  armedCreate: null,
1744
+ armedCreateRowsPath: "",
1679
1745
  create: null,
1680
1746
  editing: null,
1681
1747
  renaming: null,
1682
1748
  anchorExpanded: false,
1683
1749
  nextTextStyle: defaultNextTextStyle(),
1750
+ nextLayerRatio: defaultNextLayerRatio(),
1684
1751
  dataSourceSchema: null,
1685
1752
  guides: [],
1686
1753
  rulersVisible: true,
@@ -1712,7 +1779,7 @@ var EditorStore = class {
1712
1779
  }
1713
1780
  /** 打开/替换文档:ui 选择/编辑/重命名会话与双向历史一并重置(新文档不继承旧路径/旧事务);
1714
1781
  * 参考线/锁定集合/查找会话/建层武装与画拉会话随当次会话清空(对位轴/文档内容防护/
1715
- * 查询词/待命层型不跨文档),标尺显隐/锚点折叠/新层样式作为偏好保留 */
1782
+ * 查询词/待命层型不跨文档),标尺显隐/锚点折叠/新层样式/建层比例作为偏好保留 */
1716
1783
  openDocument(canvas) {
1717
1784
  this.docValue = canvas;
1718
1785
  this.undoSteps = [];
@@ -1724,6 +1791,7 @@ var EditorStore = class {
1724
1791
  drag: null,
1725
1792
  resize: null,
1726
1793
  armedCreate: null,
1794
+ armedCreateRowsPath: "",
1727
1795
  create: null,
1728
1796
  editing: null,
1729
1797
  renaming: null,
@@ -1820,10 +1888,15 @@ var EditorStore = class {
1820
1888
  this.uiValue = { ...this.uiValue, resize: gesture };
1821
1889
  this.notify({ scope: "ui", branch: "resize" });
1822
1890
  }
1823
- /** 建层武装置/解除(null,drag-create 工单 01);标量值等短路(重复武装同型不通知) */
1824
- setArmedCreate(type) {
1825
- if (this.uiValue.armedCreate === type) return;
1826
- this.uiValue = { ...this.uiValue, armedCreate: type };
1891
+ /**
1892
+ * 建层武装置/解除(null,drag-create 工单 01);标量值等短路(重复武装同型
1893
+ * 不通知)。rowsPath(template-table-drag-create 工单 01):模板表风味载荷
1894
+ * 与层型一体写——同层型同载荷才短路(重复武装同型换载荷要更新),解除
1895
+ * (null)一律清载荷。
1896
+ */
1897
+ setArmedCreate(type, rowsPath = "") {
1898
+ if (this.uiValue.armedCreate === type && this.uiValue.armedCreateRowsPath === rowsPath) return;
1899
+ this.uiValue = { ...this.uiValue, armedCreate: type, armedCreateRowsPath: type === null ? "" : rowsPath };
1827
1900
  this.notify({ scope: "ui", branch: "armedCreate" });
1828
1901
  }
1829
1902
  /** 画拉橡皮筋会话开始/更新/结束(null);会话对象住 ui 分支,落库在 endCreate 单事务 */
@@ -1856,6 +1929,15 @@ var EditorStore = class {
1856
1929
  this.uiValue = { ...this.uiValue, nextTextStyle: next };
1857
1930
  this.notify({ scope: "ui", branch: "nextTextStyle" });
1858
1931
  }
1932
+ /**
1933
+ * 建层比例槽写入(image-ratio-preset 工单 01:比例预设应用 / prefs 恢复的
1934
+ * 统一写入口):值等短路(重复写同档零通知),仅 ui 通知、不进历史。
1935
+ */
1936
+ setNextLayerRatio(id) {
1937
+ if (this.uiValue.nextLayerRatio === id) return;
1938
+ this.uiValue = { ...this.uiValue, nextLayerRatio: id };
1939
+ this.notify({ scope: "ui", branch: "nextLayerRatio" });
1940
+ }
1859
1941
  /**
1860
1942
  * 数据源 schema 声明替换(归一产物或降级 null,校验收口在注入缝不在这里):
1861
1943
  * 同引用短路(重复注入同一归一产物不惊动订阅方);仅 ui 通知,不参与重绘
@@ -2740,8 +2822,17 @@ var EditorSession = class {
2740
2822
  *
2741
2823
  * 拖不动写不动的同门守卫:无会话、目标层被结构编辑移除(悬空路径)整体
2742
2824
  * 空转(不发事务、不发布命中轴)。
2825
+ *
2826
+ * Shift 等比(image-ratio-preset 工单 01,spec 决策 5):仅 ImageLayer 生效
2827
+ * (其余层型 shift 空转走现状——自适应与尺寸耦合面不被触碰),且须双轴皆在
2828
+ * 可缩放面(格内容等强同步面派生轴写不出去,不进等比模式)、起始盒零维空转
2829
+ * (无比例可参照,回落自由)。参照手势起始盒实际宽高比;驱动轴 = 角柄取位
2830
+ * 移较大轴、边中柄取被拖轴;派生维 = round(驱动维 × 短比)(钳后驱动维跟随,
2831
+ * 最小钳位随动);对缘/对角固定照旧;吸附只在驱动轴移动缘候选(派生轴是算
2832
+ * 术产物不独立吸附,钳先于吸附同门)。等比模式双轴都落声明值(派生轴写算
2833
+ * 术积并清 auto 标志),canonicalize 以驱动轴键收口。
2743
2834
  */
2744
- resizeTo(sceneX, sceneY) {
2835
+ resizeTo(sceneX, sceneY, shift = false) {
2745
2836
  const gesture = this.store.ui.resize;
2746
2837
  if (!gesture) return;
2747
2838
  const doc = this.store.doc;
@@ -2751,12 +2842,21 @@ var EditorSession = class {
2751
2842
  const dy = sceneY - gesture.startScene.y;
2752
2843
  const widthActive = handleResizesWidth(gesture.handle);
2753
2844
  const heightActive = handleResizesHeight(gesture.handle);
2845
+ const axes = resizableAxesAt(doc, gesture.path);
2846
+ const proportionalDriving = shift && current.type === "ImageLayer" && gesture.startBox.width > 0 && gesture.startBox.height > 0 && axes.width && axes.height ? proportionalDrivingAxis(gesture.handle, dx, dy) : null;
2754
2847
  const resolution = resolveSnapPoints(
2755
- resizeSnapPoints(gesture.startBox, gesture.handle, dx, dy),
2848
+ proportionalDriving !== null ? resizeProportionalSnapPoints(gesture.startBox, gesture.handle, proportionalDriving, dx, dy) : resizeSnapPoints(gesture.startBox, gesture.handle, dx, dy),
2756
2849
  this.snapAxesForGesture(gesture.path),
2757
2850
  snapThresholdScene(this.store.ui.viewport.zoom)
2758
2851
  );
2759
- const target = resizeBox(
2852
+ const target = proportionalDriving !== null ? resizeBoxProportional(
2853
+ gesture.startBox,
2854
+ gesture.handle,
2855
+ { w: gesture.startBox.width, h: gesture.startBox.height },
2856
+ proportionalDriving,
2857
+ dx + resolution.dx,
2858
+ dy + resolution.dy
2859
+ ) : resizeBox(
2760
2860
  gesture.startBox,
2761
2861
  gesture.handle,
2762
2862
  widthActive ? dx + resolution.dx : dx,
@@ -2767,11 +2867,11 @@ var EditorSession = class {
2767
2867
  const layer = resolveLayer(draft, gesture.path);
2768
2868
  if (!layer) return;
2769
2869
  const shape = layer.shape;
2770
- if (widthActive) {
2870
+ if (widthActive || proportionalDriving !== null) {
2771
2871
  shape.width = target.width;
2772
2872
  shape.autoWidth = false;
2773
2873
  }
2774
- if (heightActive) {
2874
+ if (heightActive || proportionalDriving !== null) {
2775
2875
  shape.height = target.height;
2776
2876
  shape.autoHeight = false;
2777
2877
  }
@@ -2779,7 +2879,7 @@ var EditorSession = class {
2779
2879
  canonicalizeTableSyncInDraft(
2780
2880
  draft,
2781
2881
  gesture.path,
2782
- widthActive ? ["shape", "width"] : ["shape", "height"],
2882
+ proportionalDriving === "height" || proportionalDriving === null && !widthActive ? ["shape", "height"] : ["shape", "width"],
2783
2883
  this.textPolicies
2784
2884
  );
2785
2885
  const resolvedWidth = layerWidth(layer, this.textPolicies);
@@ -2814,22 +2914,42 @@ var EditorSession = class {
2814
2914
  * 分支待命层型,下一次画布按下即开画拉;建层提交或 Esc 即解除——一次性
2815
2915
  * 待命态,无粘性工具(spec 决策 2)。重复武装同型值等短路零通知。
2816
2916
  *
2817
- * preset(text-defaults 工单 01,CONTEXT「文本预设」词条):文本预设 = 写
2818
- * 新层样式槽的快捷方式——命中档位先把档位值并入槽(v1 只 fontSize),再
2819
- * 武装;仅 TextLayer 生效;未知 id = 跳过写槽、武装照常(评审口径统一:
2820
- * 「空转」只指写槽,武装层型本身仍合法)。
2917
+ * preset(text-defaults 工单 01 + image-ratio-preset 工单 01):层型相关预设 =
2918
+ * 写槽的快捷方式——命中档位先把档位值并入对应偏好槽再武装;未知 id = 跳过
2919
+ * 写槽、武装照常(评审口径统一:「空转」只指写槽,武装层型本身仍合法)。
2920
+ * - TextLayer + 文本预设档:写新层样式槽(v1 只 fontSize);
2921
+ * - ImageLayer + 比例预设档:写建层比例槽('free' 即显式重置档);QR 恒方
2922
+ * 是层型硬钳、无比例槽(spec 豁免面);
2923
+ * - 交叉类型(文本层带比例档/图片层带文本档)各在对方清单查无此档、天然
2924
+ * 跳写槽。
2821
2925
  */
2822
2926
  armLayerCreate(type, preset) {
2823
2927
  if (preset !== void 0 && type === "TextLayer") {
2824
2928
  const entry = TEXT_PRESETS.find((candidate) => candidate.id === preset);
2825
2929
  if (entry) this.store.setNextTextStyle({ fontSize: entry.fontSize });
2826
2930
  }
2931
+ if (preset !== void 0 && type === "ImageLayer") {
2932
+ const entry = RATIO_PRESETS.find((candidate) => candidate.id === preset);
2933
+ if (entry) this.store.setNextLayerRatio(entry.id);
2934
+ }
2827
2935
  this.store.setArmedCreate(type);
2828
2936
  }
2829
- /** 解除建层武装(取消入口):仅清待命态,零副作用。 */
2937
+ /** 解除建层武装(取消入口):仅清待命态(含模板表风味载荷),零副作用。 */
2830
2938
  cancelArmLayerCreate() {
2831
2939
  this.store.setArmedCreate(null);
2832
2940
  }
2941
+ /**
2942
+ * 武装模板表建层(canvas-web-template-table-drag-create 工单 01,spec 决策 3):
2943
+ * 行路径表单确认即武装——rowsPath 随武装进 ui 瞬态载荷(armedCreate 仍为
2944
+ * 'TableLayer'、不新增 LayerType、不进 graph),下一次画拉或死区点击落模板表。
2945
+ * 空串 no-op(内核防御,表单层已拦);重复武装同 rowsPath 值等短路,换
2946
+ * rowsPath 更新载荷。TableLayer 的 G 武装语义(V1 普通表,armLayerCreate 走
2947
+ * 空载荷)不受染指。
2948
+ */
2949
+ armTemplateTableCreate(rowsPath) {
2950
+ if (rowsPath === "") return;
2951
+ this.store.setArmedCreate("TableLayer", rowsPath);
2952
+ }
2833
2953
  /**
2834
2954
  * 开始画拉会话:须已武装(未武装空转防御——画布手势态机的分派前置,此守卫
2835
2955
  * 兜其余调用来源),与拖动/缩放会话互斥(手势起点一次性定死,beginDrag/
@@ -2842,8 +2962,10 @@ var EditorSession = class {
2842
2962
  if (armed === null || this.store.ui.drag || this.store.ui.resize || this.store.ui.create) return false;
2843
2963
  if (!this.store.doc) return false;
2844
2964
  const point = { x: sceneX, y: sceneY };
2965
+ const templateRowsPath = armed === "TableLayer" && this.store.ui.armedCreateRowsPath !== "" ? this.store.ui.armedCreateRowsPath : void 0;
2845
2966
  this.store.setCreate({
2846
2967
  type: armed,
2968
+ templateRowsPath,
2847
2969
  startScene: point,
2848
2970
  currentScene: point,
2849
2971
  rect: { x: sceneX, y: sceneY, width: 0, height: 0 }
@@ -2852,22 +2974,31 @@ var EditorSession = class {
2852
2974
  }
2853
2975
  /**
2854
2976
  * 画拉进行中:橡皮筋求位(spatial/create.createRubberBandRect——两点正规化
2855
- * + QR 钳 height := width + 左右/上下缘各两点进 resolveSnapPoints,吸附数学
2856
- * 零新增)写回会话 rect 供 overlay 直读(虚线矩形 + W×H 气泡),命中轴回写
2857
- * ui.snapAxes 走 GuidesOverlay 既有通道回显(dragTo 同门)。吸附排除面为空
2858
- * (snapAxesForGesture(null)——新层尚未入库,全部可见根层供轴,copyMode
2859
- * 首移同门)。零文档事务:修正只进会话几何,落库在 endCreate。
2977
+ * + QR 钳 height := width / 比例钳长轴驱动 + 左右/上下缘各两点进
2978
+ * resolveSnapPoints,吸附数学零新增)写回会话 rect 供 overlay 直读(虚线
2979
+ * 矩形 + W×H 气泡),命中轴回写 ui.snapAxes 走 GuidesOverlay 既有通道回显
2980
+ * (dragTo 同门)。吸附排除面为空(snapAxesForGesture(null)——新层尚未入库,
2981
+ * 全部可见根层供轴,copyMode 首移同门)。零文档事务:修正只进会话几何,
2982
+ * 落库在 endCreate。
2983
+ *
2984
+ * 建层比例槽(image-ratio-preset 工单 01,spec 决策 3/4):仅 ImageLayer 按槽
2985
+ * 钳制(QR 层不传 ratio、恒方语义不动,两钳互斥;其余层型无槽);Shift 瞬时
2986
+ * 反转槽态——槽非自由按 Shift = 临时自由、槽自由按 Shift = 临时钳 1:1,按住
2987
+ * 生效松开还原(逐帧求位、不写槽、不改武装态,Figma 惯例的手势瞬时语义)。
2860
2988
  */
2861
- createTo(sceneX, sceneY) {
2989
+ createTo(sceneX, sceneY, shift = false) {
2862
2990
  const gesture = this.store.ui.create;
2863
2991
  const doc = this.store.doc;
2864
2992
  if (!gesture || !doc) return;
2993
+ const slotRatio = gesture.type === "ImageLayer" ? ratioValue(this.store.ui.nextLayerRatio) : null;
2994
+ const ratio = gesture.type === "ImageLayer" ? shift ? slotRatio === null ? { w: 1, h: 1 } : null : slotRatio : null;
2865
2995
  const resolution = createRubberBandRect(
2866
2996
  gesture.type,
2867
2997
  gesture.startScene,
2868
2998
  { x: sceneX, y: sceneY },
2869
2999
  this.snapAxesForGesture(null),
2870
- snapThresholdScene(this.store.ui.viewport.zoom)
3000
+ snapThresholdScene(this.store.ui.viewport.zoom),
3001
+ ratio
2871
3002
  );
2872
3003
  this.store.setCreate({
2873
3004
  ...gesture,
@@ -2881,12 +3012,19 @@ var EditorSession = class {
2881
3012
  * + priority)。先清会话再落文档(commitTextEdit 同门——退出路径级联时幂等):
2882
3013
  * - 死区外:单事务写入「createDefaultLayer 缺省形态 + position 左上角对准
2883
3014
  * 橡皮筋矩形(含吸附修正)+ shape.width/height」并 insertRootLayerInDraft
2884
- * 置顶(priority min−1、push 数组尾);
3015
+ * 置顶(priority min−1、push 数组尾)——尺寸取手势末态钳制结果(含 Shift
3016
+ * 反转末态,image-ratio-preset 工单 01 spec 决策 4);
2885
3017
  * - 死区内(点击兜底,位移按 CREATE_DEAD_ZONE_SCREEN_PX 屏幕 px 随 zoom 折算,
2886
- * ALT_DRAG_DEAD_ZONE_SCREEN_PX 同门):缺省尺寸、左上角对准点击点。
3018
+ * ALT_DRAG_DEAD_ZONE_SCREEN_PX 同门):左上角对准点击点;尺寸按槽派生缺省
3019
+ * 盒(image-ratio-preset 工单 01:ImageLayer 长边 200 定短边,自由 = 工厂
3020
+ * 200×150 现状;忽略 shift——无位移可供等比约束;其余层型保持工厂缺省)。
2887
3021
  * 落库后自动选中新层并解除武装;返回新层路径(绑定层消费:文本层据此自动
2888
3022
  * 进文本编辑,spec 决策 6)。无会话/无文档空转返回 null。点击兜底不产生
2889
3023
  * 零尺寸层(缺省形态自带可见尺寸)。
3024
+ *
3025
+ * 模板表风味(template-table-drag-create 工单 01):会话载荷 templateRowsPath
3026
+ * 非空即分派 createTemplateTable 工厂(死区外 shape = 手势正规化矩形,死区内
3027
+ * 缺省壳 400×120、左上角对准点击点);模板层不自动进文本编辑(文本层专属)。
2890
3028
  */
2891
3029
  endCreate() {
2892
3030
  const gesture = this.store.ui.create;
@@ -2900,9 +3038,23 @@ var EditorSession = class {
2900
3038
  const deadZoneScene = CREATE_DEAD_ZONE_SCREEN_PX / this.store.ui.viewport.zoom;
2901
3039
  const clicked = Math.hypot(dx, dy) <= deadZoneScene;
2902
3040
  const index = { v: -1 };
3041
+ const templateRowsPath = gesture.templateRowsPath;
2903
3042
  this.store.transact((draft) => {
2904
- const layer = createDefaultLayer(gesture.type, this.store.ui.nextTextStyle);
3043
+ const layer = templateRowsPath !== void 0 ? createTemplateTable(
3044
+ clicked ? { rowsPath: templateRowsPath } : {
3045
+ rowsPath: templateRowsPath,
3046
+ shape: { width: gesture.rect.width, height: gesture.rect.height }
3047
+ }
3048
+ ) : createDefaultLayer(gesture.type, this.store.ui.nextTextStyle);
2905
3049
  if (clicked) {
3050
+ if (gesture.type === "ImageLayer") {
3051
+ const ratio = ratioValue(this.store.ui.nextLayerRatio);
3052
+ if (ratio) {
3053
+ const size = defaultSizeForRatio(ratio);
3054
+ layer.shape.width = size.width;
3055
+ layer.shape.height = size.height;
3056
+ }
3057
+ }
2906
3058
  layer.position = { ...layer.position, x: gesture.startScene.x, y: gesture.startScene.y };
2907
3059
  } else {
2908
3060
  layer.position = { ...layer.position, x: gesture.rect.x, y: gesture.rect.y };
@@ -3090,19 +3242,23 @@ var EditorSession = class {
3090
3242
  return {
3091
3243
  rulersVisible: ui.rulersVisible,
3092
3244
  anchorExpanded: ui.anchorExpanded,
3093
- nextTextStyle: ui.nextTextStyle
3245
+ nextTextStyle: ui.nextTextStyle,
3246
+ nextLayerRatio: ui.nextLayerRatio
3094
3247
  };
3095
3248
  }
3096
3249
  /**
3097
3250
  * 应用偏好(会话建立时一次性,跨会话恢复 prefs 的写入口):整体替换
3098
3251
  * rulersVisible/anchorExpanded,不进历史;值等写入经 store 短路零通知。
3099
3252
  * 新层样式(text-defaults 工单 01)走宽容读逐字段合并(pickNextTextStylePatch
3100
- * 共享谓词——与捕获同门,缺字/类型不符保留现值)。
3253
+ * 共享谓词——与捕获同门,缺字/类型不符保留现值);建层比例(image-ratio-preset
3254
+ * 工单 01)走宽容读枚举收口(pickNextLayerRatio——非六档枚举值/旧快照缺字段
3255
+ * 回落 'free')。
3101
3256
  */
3102
3257
  applyEditorPrefs(prefs) {
3103
3258
  this.store.setRulersVisible(prefs.rulersVisible);
3104
3259
  this.store.setAnchorExpanded(prefs.anchorExpanded);
3105
3260
  this.store.setNextTextStyle(pickNextTextStylePatch(prefs.nextTextStyle));
3261
+ this.store.setNextLayerRatio(pickNextLayerRatio(prefs.nextLayerRatio));
3106
3262
  }
3107
3263
  // ---- 对齐画布(layer-align-snap 工单 01):图层盒整体对齐画布几何 ----
3108
3264
  /**
@@ -4058,19 +4214,6 @@ var EditorSession = class {
4058
4214
  });
4059
4215
  if (index.v >= 0) this.store.setSelection([...rowPath, "cells", index.v]);
4060
4216
  }
4061
- /**
4062
- * 新增模板表(「+」选择器入口,spec §2.1):createTemplateTable 缺省形态 +
4063
- * 置顶 + 自动选中;一次调用 = 一步历史。rowsPath 必填(解码 rows_path_missing
4064
- * 硬约束的对齐兜底,表单校验前置),空串 no-op。
4065
- */
4066
- addTemplateTable(rowsPath) {
4067
- if (!this.store.doc || rowsPath === "") return;
4068
- const index = { v: -1 };
4069
- this.store.transact((draft) => {
4070
- index.v = insertRootLayerInDraft(draft, createTemplateTable({ rowsPath }));
4071
- });
4072
- if (index.v >= 0) this.store.setSelection(["layers", index.v]);
4073
- }
4074
4217
  /**
4075
4218
  * 模板行加格(spec §3.1):addTemplateCellInDraft 重建路径(末格宽/首格行宽、
4076
4219
  * 零高度耦合),自动选中新格;一次调用 = 一步历史。非模板态 no-op。
@@ -4748,6 +4891,7 @@ export {
4748
4891
  PAN_TO_BOX_MARGIN_PX,
4749
4892
  PASTE_OFFSET_PX,
4750
4893
  PHP_TRIM_CHARS,
4894
+ RATIO_PRESETS,
4751
4895
  RESIZE_HANDLES,
4752
4896
  RESIZE_HANDLE_HIT_PX,
4753
4897
  RESIZE_MIN_SIZE_PX,
@@ -4777,7 +4921,9 @@ export {
4777
4921
  createDefaultLayer,
4778
4922
  createRubberBandRect,
4779
4923
  createTemplateTable,
4924
+ defaultNextLayerRatio,
4780
4925
  defaultNextTextStyle,
4926
+ defaultSizeForRatio,
4781
4927
  deleteLayerInDraft,
4782
4928
  enumerateExpressionCandidates,
4783
4929
  enumerateRowsPathCandidates,
@@ -4818,16 +4964,21 @@ export {
4818
4964
  pathStartsWith,
4819
4965
  pathsEqual,
4820
4966
  phpTrim2 as phpTrim,
4967
+ pickNextLayerRatio,
4821
4968
  pickNextTextStylePatch,
4822
4969
  prepareRootPaste,
4970
+ proportionalDrivingAxis,
4971
+ ratioValue,
4823
4972
  readSpecField,
4824
4973
  remapPathAfterSplice,
4825
4974
  replaceHitsInDraft,
4826
4975
  resizableAxesAt,
4827
4976
  resizeBox,
4977
+ resizeBoxProportional,
4828
4978
  resizeHandleAt,
4829
4979
  resizeHandlePoint,
4830
4980
  resizeHandlesAt,
4981
+ resizeProportionalSnapPoints,
4831
4982
  resizeSnapPoints,
4832
4983
  resolveLayer,
4833
4984
  resolveRowSchema,