@hankchen/canvas-editor 0.4.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);
@@ -602,7 +645,6 @@ function compileRefNode(raw, rawRef, declPath, ctx) {
602
645
  }
603
646
  function compileInlineNode(raw, declPath, ctx) {
604
647
  const node = annotationsOf(raw);
605
- if (raw["additionalProperties"] === true) node.open = true;
606
648
  const rawProperties = raw["properties"];
607
649
  if (isPlainObject(rawProperties)) {
608
650
  const properties = /* @__PURE__ */ new Map();
@@ -616,6 +658,23 @@ function compileInlineNode(raw, declPath, ctx) {
616
658
  if (isPlainObject(rawItems)) node.items = compileSchemaNode(rawItems, `${declPath}/items`, ctx);
617
659
  return node;
618
660
  }
661
+ var OPEN_MAP_DETAIL_SUFFIX = "\u2014\u2014\u5F00\u653E\u6620\u5C04\u5951\u7EA6\u5DF2\u5E9F\u9664\uFF0C\u52A8\u6001\u952E\u8BF7\u7531\u5BBF\u4E3B\u7EC4\u88C5\u6210\u5177\u4F53 properties \u540E\u6CE8\u5165";
662
+ function findOpenMapping(raw, path) {
663
+ if (Array.isArray(raw)) {
664
+ for (let i = 0; i < raw.length; i += 1) {
665
+ const hit = findOpenMapping(raw[i], `${path}/${i}`);
666
+ if (hit !== null) return hit;
667
+ }
668
+ return null;
669
+ }
670
+ if (!isPlainObject(raw)) return null;
671
+ if (raw["additionalProperties"] === true) return path;
672
+ for (const [key, value] of Object.entries(raw)) {
673
+ const hit = findOpenMapping(value, `${path}/${pointerEscape(key)}`);
674
+ if (hit !== null) return hit;
675
+ }
676
+ return null;
677
+ }
619
678
  function parseExpressionSchema(raw) {
620
679
  if (!isPlainObject(raw)) {
621
680
  return { ok: false, reason: "invalid_schema", detail: "schema \u6839\u5FC5\u987B\u662F\u5BF9\u8C61\u58F0\u660E" };
@@ -631,6 +690,10 @@ function parseExpressionSchema(raw) {
631
690
  if (!isPlainObject(rawProperties)) {
632
691
  return { ok: false, reason: "invalid_schema", detail: "\u6839\u58F0\u660E\u7F3A properties \u5C42\u7EA7\uFF08schema \u5373\u8F7D\u8377\u5F62\u72B6\uFF0C\u5FC5\u987B\u58F0\u660E\u9876\u5C42\u952E\uFF09" };
633
692
  }
693
+ const openPath = findOpenMapping(raw, "#");
694
+ if (openPath !== null) {
695
+ return { ok: false, reason: "open_map_unsupported", detail: `"${openPath}" \u58F0\u660E\u4E86 additionalProperties: true${OPEN_MAP_DETAIL_SUFFIX}` };
696
+ }
634
697
  const diagnostics = [];
635
698
  const schema = compileSchemaNode(raw, "#", { root: raw, diagnostics, activePath: /* @__PURE__ */ new Set() });
636
699
  return { ok: true, schema, diagnostics };
@@ -672,7 +735,7 @@ function schemaNodeAtPath(node, segments) {
672
735
  function schemaChildEntries(node) {
673
736
  if (node.type === "array" || node.items !== void 0) return { entries: [] };
674
737
  const entries = node.properties !== void 0 ? [...node.properties].map(([key, child]) => ({ key, node: child })) : [];
675
- return node.open === true ? { entries, open: true } : { entries };
738
+ return { entries };
676
739
  }
677
740
  function resolveRowSchema(root, rowsPath) {
678
741
  const segments = rowsPath.split(".");
@@ -712,6 +775,9 @@ function skipWs(expr, pos) {
712
775
  while (pos < expr.length && WS_CHARS.includes(expr[pos])) pos += 1;
713
776
  return pos;
714
777
  }
778
+ function fail(kind, start, end) {
779
+ return { ok: false, error: "expression_syntax_error", kind, at: { start, end } };
780
+ }
715
781
  function isCallFragment(expr) {
716
782
  let head = 0;
717
783
  while (head < expr.length && !HEAD_STOP_CHARS.includes(expr[head])) head += 1;
@@ -723,26 +789,30 @@ function isCallFragment(expr) {
723
789
  function parseExpressionCall(expr) {
724
790
  let head = 0;
725
791
  while (head < expr.length && !HEAD_STOP_CHARS.includes(expr[head])) head += 1;
726
- if (head === 0) return { ok: false, error: "expression_syntax_error" };
792
+ if (head === 0) return fail("invalid_path", 0, expr.length);
727
793
  const name = expr.slice(0, head);
728
794
  let pos = skipWs(expr, head);
729
- if (pos >= expr.length || expr[pos] !== "(") return { ok: false, error: "expression_syntax_error" };
795
+ if (pos >= expr.length || expr[pos] !== "(") return fail("missing_open_paren", head, expr.length);
796
+ const openParen = pos;
730
797
  pos = skipWs(expr, pos + 1);
731
798
  const args = [];
799
+ const spans = [];
732
800
  if (pos < expr.length && expr[pos] === ")") {
733
801
  pos += 1;
734
802
  } else {
735
803
  for (; ; ) {
736
- if (pos >= expr.length) return { ok: false, error: "expression_syntax_error" };
804
+ if (pos >= expr.length) return fail("unbalanced_paren", openParen, expr.length);
737
805
  const arg = parseCallArg(expr, pos);
738
806
  if (!arg.ok) return arg;
739
807
  args.push(arg.value);
808
+ spans.push({ start: arg.start, end: arg.end });
740
809
  pos = skipWs(expr, arg.end);
741
- if (pos >= expr.length) return { ok: false, error: "expression_syntax_error" };
810
+ if (pos >= expr.length) return fail("unbalanced_paren", openParen, expr.length);
742
811
  if (expr[pos] === ",") {
812
+ const comma = pos;
743
813
  pos = skipWs(expr, pos + 1);
744
814
  if (pos < expr.length && expr[pos] === ")") {
745
- return { ok: false, error: "expression_syntax_error" };
815
+ return fail("empty_arg_slot", comma + 1, pos);
746
816
  }
747
817
  continue;
748
818
  }
@@ -750,26 +820,35 @@ function parseExpressionCall(expr) {
750
820
  pos += 1;
751
821
  break;
752
822
  }
753
- return { ok: false, error: "expression_syntax_error" };
823
+ return fail("bad_arg_syntax", pos, pos + 1);
754
824
  }
755
825
  }
756
- if (skipWs(expr, pos) !== expr.length) return { ok: false, error: "expression_syntax_error" };
757
- if (args[0] !== void 0 && args[0].type !== "path") return { ok: false, error: "expression_syntax_error" };
758
- for (const arg of args) {
826
+ pos = skipWs(expr, pos);
827
+ if (pos !== expr.length) return fail("trailing_after_call", pos, expr.length);
828
+ if (args[0] !== void 0 && args[0].type !== "path") {
829
+ const span = spans[0];
830
+ return fail("first_arg_not_path", span.start, span.end);
831
+ }
832
+ for (let i = 0; i < args.length; i += 1) {
833
+ const arg = args[i];
834
+ const span = spans[i];
759
835
  if (arg.type === "path" && !parseExpressionPath(arg.value).ok) {
760
- return { ok: false, error: "expression_syntax_error" };
836
+ return fail("invalid_path_arg", span.start, span.end);
761
837
  }
762
838
  }
763
839
  return { ok: true, name, args };
764
840
  }
765
841
  function parseCallArg(expr, pos) {
766
842
  const len = expr.length;
843
+ const start = pos;
767
844
  const ch = expr[pos];
768
845
  if (ch === "'") {
769
846
  let cursor = pos + 1;
770
847
  let buf = "";
771
848
  for (; ; ) {
772
- if (cursor >= len) return { ok: false, error: "expression_syntax_error" };
849
+ if (cursor >= len) {
850
+ return { ok: false, error: "expression_syntax_error", kind: "unterminated_string", at: { start, end: len } };
851
+ }
773
852
  if (expr[cursor] === "'") {
774
853
  if (cursor + 1 < len && expr[cursor + 1] === "'") {
775
854
  buf += "'";
@@ -782,10 +861,9 @@ function parseCallArg(expr, pos) {
782
861
  buf += expr[cursor];
783
862
  cursor += 1;
784
863
  }
785
- return { ok: true, value: { type: "string", value: buf }, end: cursor };
864
+ return { ok: true, value: { type: "string", value: buf }, start, end: cursor };
786
865
  }
787
866
  if (ch >= "0" && ch <= "9") {
788
- const start2 = pos;
789
867
  const isDigitAt = (index) => {
790
868
  const c = expr[index];
791
869
  return c !== void 0 && c >= "0" && c <= "9";
@@ -795,13 +873,14 @@ function parseCallArg(expr, pos) {
795
873
  pos += 1;
796
874
  while (isDigitAt(pos)) pos += 1;
797
875
  }
798
- return { ok: true, value: { type: "number", value: expr.slice(start2, pos) }, end: pos };
876
+ return { ok: true, value: { type: "number", value: expr.slice(start, pos) }, start, end: pos };
799
877
  }
800
- const start = pos;
801
878
  while (pos < len && expr[pos] !== "," && expr[pos] !== ")") pos += 1;
802
879
  const raw = phpTrim(expr.slice(start, pos));
803
- if (raw === "") return { ok: false, error: "expression_syntax_error" };
804
- return { ok: true, value: { type: "path", value: raw }, end: pos };
880
+ if (raw === "") {
881
+ return { ok: false, error: "expression_syntax_error", kind: "empty_arg_slot", at: { start, end: pos } };
882
+ }
883
+ return { ok: true, value: { type: "path", value: raw }, start, end: pos };
805
884
  }
806
885
  function expressionCallSlotAtEnd(expr) {
807
886
  let head = 0;
@@ -854,12 +933,20 @@ function phpTrim2(value) {
854
933
  while (end > start && PHP_TRIM_CHARS.includes(value[end - 1])) end -= 1;
855
934
  return value.slice(start, end);
856
935
  }
857
- function fragmentSyntaxError(expr) {
858
- if (expr === "") return "expression_syntax_error";
936
+ function fragmentDiagnostic(expr) {
937
+ if (expr === "") return { kind: "empty_fragment", at: { start: 0, end: 0 } };
859
938
  if (isCallFragment(expr)) {
860
- return parseExpressionCall(expr).ok ? null : "expression_syntax_error";
939
+ const parsed = parseExpressionCall(expr);
940
+ if (parsed.ok) return null;
941
+ return { kind: parsed.kind, at: parsed.at };
861
942
  }
862
- return parseExpressionPath(expr).ok ? null : "expression_syntax_error";
943
+ if (parseExpressionPath(expr).ok) return null;
944
+ return { kind: "invalid_path", at: { start: 0, end: expr.length } };
945
+ }
946
+ function leadingPhpTrim(value) {
947
+ let start = 0;
948
+ while (start < value.length && PHP_TRIM_CHARS.includes(value[start])) start += 1;
949
+ return start;
863
950
  }
864
951
  function scanExpressionFragments(template) {
865
952
  const parts = [];
@@ -880,8 +967,26 @@ function scanExpressionFragments(template) {
880
967
  }
881
968
  const raw = template.slice(open + 2, close);
882
969
  const expr = phpTrim2(raw);
883
- const error = fragmentSyntaxError(expr);
884
- parts.push({ kind: "fragment", start: open, end: close + 2, raw, expr, error });
970
+ let diagnostic = null;
971
+ const found = fragmentDiagnostic(expr);
972
+ if (found) {
973
+ const exprStart = open + 2 + leadingPhpTrim(raw);
974
+ diagnostic = {
975
+ kind: found.kind,
976
+ start: open,
977
+ end: close + 2,
978
+ at: { start: exprStart + found.at.start, end: exprStart + found.at.end }
979
+ };
980
+ }
981
+ parts.push({
982
+ kind: "fragment",
983
+ start: open,
984
+ end: close + 2,
985
+ raw,
986
+ expr,
987
+ error: diagnostic !== null ? "expression_syntax_error" : null,
988
+ diagnostic
989
+ });
885
990
  pos = close + 2;
886
991
  open = template.indexOf("{{", pos);
887
992
  }
@@ -1113,6 +1218,27 @@ function defaultNextTextStyle() {
1113
1218
  lineHeight: base.shape.lineHeight
1114
1219
  };
1115
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
+ }
1116
1242
  function pickNextTextStylePatch(raw) {
1117
1243
  const out = {};
1118
1244
  if (raw === null || typeof raw !== "object") return out;
@@ -1181,7 +1307,8 @@ function createDefaultLayer(type, textDefaults) {
1181
1307
  }
1182
1308
  }
1183
1309
  function createTemplateTable(options) {
1184
- 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;
1185
1312
  const templateBase = createDefaultLayer("TableRowTemplate");
1186
1313
  const cellBase = createDefaultLayer("TableCellLayer");
1187
1314
  const contentBase = createDefaultLayer("TextLayer");
@@ -1614,11 +1741,13 @@ var EditorStore = class {
1614
1741
  drag: null,
1615
1742
  resize: null,
1616
1743
  armedCreate: null,
1744
+ armedCreateRowsPath: "",
1617
1745
  create: null,
1618
1746
  editing: null,
1619
1747
  renaming: null,
1620
1748
  anchorExpanded: false,
1621
1749
  nextTextStyle: defaultNextTextStyle(),
1750
+ nextLayerRatio: defaultNextLayerRatio(),
1622
1751
  dataSourceSchema: null,
1623
1752
  guides: [],
1624
1753
  rulersVisible: true,
@@ -1650,7 +1779,7 @@ var EditorStore = class {
1650
1779
  }
1651
1780
  /** 打开/替换文档:ui 选择/编辑/重命名会话与双向历史一并重置(新文档不继承旧路径/旧事务);
1652
1781
  * 参考线/锁定集合/查找会话/建层武装与画拉会话随当次会话清空(对位轴/文档内容防护/
1653
- * 查询词/待命层型不跨文档),标尺显隐/锚点折叠/新层样式作为偏好保留 */
1782
+ * 查询词/待命层型不跨文档),标尺显隐/锚点折叠/新层样式/建层比例作为偏好保留 */
1654
1783
  openDocument(canvas) {
1655
1784
  this.docValue = canvas;
1656
1785
  this.undoSteps = [];
@@ -1662,6 +1791,7 @@ var EditorStore = class {
1662
1791
  drag: null,
1663
1792
  resize: null,
1664
1793
  armedCreate: null,
1794
+ armedCreateRowsPath: "",
1665
1795
  create: null,
1666
1796
  editing: null,
1667
1797
  renaming: null,
@@ -1758,10 +1888,15 @@ var EditorStore = class {
1758
1888
  this.uiValue = { ...this.uiValue, resize: gesture };
1759
1889
  this.notify({ scope: "ui", branch: "resize" });
1760
1890
  }
1761
- /** 建层武装置/解除(null,drag-create 工单 01);标量值等短路(重复武装同型不通知) */
1762
- setArmedCreate(type) {
1763
- if (this.uiValue.armedCreate === type) return;
1764
- 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 };
1765
1900
  this.notify({ scope: "ui", branch: "armedCreate" });
1766
1901
  }
1767
1902
  /** 画拉橡皮筋会话开始/更新/结束(null);会话对象住 ui 分支,落库在 endCreate 单事务 */
@@ -1794,6 +1929,15 @@ var EditorStore = class {
1794
1929
  this.uiValue = { ...this.uiValue, nextTextStyle: next };
1795
1930
  this.notify({ scope: "ui", branch: "nextTextStyle" });
1796
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
+ }
1797
1941
  /**
1798
1942
  * 数据源 schema 声明替换(归一产物或降级 null,校验收口在注入缝不在这里):
1799
1943
  * 同引用短路(重复注入同一归一产物不惊动订阅方);仅 ui 通知,不参与重绘
@@ -2678,8 +2822,17 @@ var EditorSession = class {
2678
2822
  *
2679
2823
  * 拖不动写不动的同门守卫:无会话、目标层被结构编辑移除(悬空路径)整体
2680
2824
  * 空转(不发事务、不发布命中轴)。
2825
+ *
2826
+ * Shift 等比(image-ratio-preset 工单 01,spec 决策 5):仅 ImageLayer 生效
2827
+ * (其余层型 shift 空转走现状——自适应与尺寸耦合面不被触碰),且须双轴皆在
2828
+ * 可缩放面(格内容等强同步面派生轴写不出去,不进等比模式)、起始盒零维空转
2829
+ * (无比例可参照,回落自由)。参照手势起始盒实际宽高比;驱动轴 = 角柄取位
2830
+ * 移较大轴、边中柄取被拖轴;派生维 = round(驱动维 × 短比)(钳后驱动维跟随,
2831
+ * 最小钳位随动);对缘/对角固定照旧;吸附只在驱动轴移动缘候选(派生轴是算
2832
+ * 术产物不独立吸附,钳先于吸附同门)。等比模式双轴都落声明值(派生轴写算
2833
+ * 术积并清 auto 标志),canonicalize 以驱动轴键收口。
2681
2834
  */
2682
- resizeTo(sceneX, sceneY) {
2835
+ resizeTo(sceneX, sceneY, shift = false) {
2683
2836
  const gesture = this.store.ui.resize;
2684
2837
  if (!gesture) return;
2685
2838
  const doc = this.store.doc;
@@ -2689,12 +2842,21 @@ var EditorSession = class {
2689
2842
  const dy = sceneY - gesture.startScene.y;
2690
2843
  const widthActive = handleResizesWidth(gesture.handle);
2691
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;
2692
2847
  const resolution = resolveSnapPoints(
2693
- 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),
2694
2849
  this.snapAxesForGesture(gesture.path),
2695
2850
  snapThresholdScene(this.store.ui.viewport.zoom)
2696
2851
  );
2697
- 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(
2698
2860
  gesture.startBox,
2699
2861
  gesture.handle,
2700
2862
  widthActive ? dx + resolution.dx : dx,
@@ -2705,11 +2867,11 @@ var EditorSession = class {
2705
2867
  const layer = resolveLayer(draft, gesture.path);
2706
2868
  if (!layer) return;
2707
2869
  const shape = layer.shape;
2708
- if (widthActive) {
2870
+ if (widthActive || proportionalDriving !== null) {
2709
2871
  shape.width = target.width;
2710
2872
  shape.autoWidth = false;
2711
2873
  }
2712
- if (heightActive) {
2874
+ if (heightActive || proportionalDriving !== null) {
2713
2875
  shape.height = target.height;
2714
2876
  shape.autoHeight = false;
2715
2877
  }
@@ -2717,7 +2879,7 @@ var EditorSession = class {
2717
2879
  canonicalizeTableSyncInDraft(
2718
2880
  draft,
2719
2881
  gesture.path,
2720
- widthActive ? ["shape", "width"] : ["shape", "height"],
2882
+ proportionalDriving === "height" || proportionalDriving === null && !widthActive ? ["shape", "height"] : ["shape", "width"],
2721
2883
  this.textPolicies
2722
2884
  );
2723
2885
  const resolvedWidth = layerWidth(layer, this.textPolicies);
@@ -2752,22 +2914,42 @@ var EditorSession = class {
2752
2914
  * 分支待命层型,下一次画布按下即开画拉;建层提交或 Esc 即解除——一次性
2753
2915
  * 待命态,无粘性工具(spec 决策 2)。重复武装同型值等短路零通知。
2754
2916
  *
2755
- * preset(text-defaults 工单 01,CONTEXT「文本预设」词条):文本预设 = 写
2756
- * 新层样式槽的快捷方式——命中档位先把档位值并入槽(v1 只 fontSize),再
2757
- * 武装;仅 TextLayer 生效;未知 id = 跳过写槽、武装照常(评审口径统一:
2758
- * 「空转」只指写槽,武装层型本身仍合法)。
2917
+ * preset(text-defaults 工单 01 + image-ratio-preset 工单 01):层型相关预设 =
2918
+ * 写槽的快捷方式——命中档位先把档位值并入对应偏好槽再武装;未知 id = 跳过
2919
+ * 写槽、武装照常(评审口径统一:「空转」只指写槽,武装层型本身仍合法)。
2920
+ * - TextLayer + 文本预设档:写新层样式槽(v1 只 fontSize);
2921
+ * - ImageLayer + 比例预设档:写建层比例槽('free' 即显式重置档);QR 恒方
2922
+ * 是层型硬钳、无比例槽(spec 豁免面);
2923
+ * - 交叉类型(文本层带比例档/图片层带文本档)各在对方清单查无此档、天然
2924
+ * 跳写槽。
2759
2925
  */
2760
2926
  armLayerCreate(type, preset) {
2761
2927
  if (preset !== void 0 && type === "TextLayer") {
2762
2928
  const entry = TEXT_PRESETS.find((candidate) => candidate.id === preset);
2763
2929
  if (entry) this.store.setNextTextStyle({ fontSize: entry.fontSize });
2764
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
+ }
2765
2935
  this.store.setArmedCreate(type);
2766
2936
  }
2767
- /** 解除建层武装(取消入口):仅清待命态,零副作用。 */
2937
+ /** 解除建层武装(取消入口):仅清待命态(含模板表风味载荷),零副作用。 */
2768
2938
  cancelArmLayerCreate() {
2769
2939
  this.store.setArmedCreate(null);
2770
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
+ }
2771
2953
  /**
2772
2954
  * 开始画拉会话:须已武装(未武装空转防御——画布手势态机的分派前置,此守卫
2773
2955
  * 兜其余调用来源),与拖动/缩放会话互斥(手势起点一次性定死,beginDrag/
@@ -2780,8 +2962,10 @@ var EditorSession = class {
2780
2962
  if (armed === null || this.store.ui.drag || this.store.ui.resize || this.store.ui.create) return false;
2781
2963
  if (!this.store.doc) return false;
2782
2964
  const point = { x: sceneX, y: sceneY };
2965
+ const templateRowsPath = armed === "TableLayer" && this.store.ui.armedCreateRowsPath !== "" ? this.store.ui.armedCreateRowsPath : void 0;
2783
2966
  this.store.setCreate({
2784
2967
  type: armed,
2968
+ templateRowsPath,
2785
2969
  startScene: point,
2786
2970
  currentScene: point,
2787
2971
  rect: { x: sceneX, y: sceneY, width: 0, height: 0 }
@@ -2790,22 +2974,31 @@ var EditorSession = class {
2790
2974
  }
2791
2975
  /**
2792
2976
  * 画拉进行中:橡皮筋求位(spatial/create.createRubberBandRect——两点正规化
2793
- * + QR 钳 height := width + 左右/上下缘各两点进 resolveSnapPoints,吸附数学
2794
- * 零新增)写回会话 rect 供 overlay 直读(虚线矩形 + W×H 气泡),命中轴回写
2795
- * ui.snapAxes 走 GuidesOverlay 既有通道回显(dragTo 同门)。吸附排除面为空
2796
- * (snapAxesForGesture(null)——新层尚未入库,全部可见根层供轴,copyMode
2797
- * 首移同门)。零文档事务:修正只进会话几何,落库在 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 惯例的手势瞬时语义)。
2798
2988
  */
2799
- createTo(sceneX, sceneY) {
2989
+ createTo(sceneX, sceneY, shift = false) {
2800
2990
  const gesture = this.store.ui.create;
2801
2991
  const doc = this.store.doc;
2802
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;
2803
2995
  const resolution = createRubberBandRect(
2804
2996
  gesture.type,
2805
2997
  gesture.startScene,
2806
2998
  { x: sceneX, y: sceneY },
2807
2999
  this.snapAxesForGesture(null),
2808
- snapThresholdScene(this.store.ui.viewport.zoom)
3000
+ snapThresholdScene(this.store.ui.viewport.zoom),
3001
+ ratio
2809
3002
  );
2810
3003
  this.store.setCreate({
2811
3004
  ...gesture,
@@ -2819,12 +3012,19 @@ var EditorSession = class {
2819
3012
  * + priority)。先清会话再落文档(commitTextEdit 同门——退出路径级联时幂等):
2820
3013
  * - 死区外:单事务写入「createDefaultLayer 缺省形态 + position 左上角对准
2821
3014
  * 橡皮筋矩形(含吸附修正)+ shape.width/height」并 insertRootLayerInDraft
2822
- * 置顶(priority min−1、push 数组尾);
3015
+ * 置顶(priority min−1、push 数组尾)——尺寸取手势末态钳制结果(含 Shift
3016
+ * 反转末态,image-ratio-preset 工单 01 spec 决策 4);
2823
3017
  * - 死区内(点击兜底,位移按 CREATE_DEAD_ZONE_SCREEN_PX 屏幕 px 随 zoom 折算,
2824
- * ALT_DRAG_DEAD_ZONE_SCREEN_PX 同门):缺省尺寸、左上角对准点击点。
3018
+ * ALT_DRAG_DEAD_ZONE_SCREEN_PX 同门):左上角对准点击点;尺寸按槽派生缺省
3019
+ * 盒(image-ratio-preset 工单 01:ImageLayer 长边 200 定短边,自由 = 工厂
3020
+ * 200×150 现状;忽略 shift——无位移可供等比约束;其余层型保持工厂缺省)。
2825
3021
  * 落库后自动选中新层并解除武装;返回新层路径(绑定层消费:文本层据此自动
2826
3022
  * 进文本编辑,spec 决策 6)。无会话/无文档空转返回 null。点击兜底不产生
2827
3023
  * 零尺寸层(缺省形态自带可见尺寸)。
3024
+ *
3025
+ * 模板表风味(template-table-drag-create 工单 01):会话载荷 templateRowsPath
3026
+ * 非空即分派 createTemplateTable 工厂(死区外 shape = 手势正规化矩形,死区内
3027
+ * 缺省壳 400×120、左上角对准点击点);模板层不自动进文本编辑(文本层专属)。
2828
3028
  */
2829
3029
  endCreate() {
2830
3030
  const gesture = this.store.ui.create;
@@ -2838,9 +3038,23 @@ var EditorSession = class {
2838
3038
  const deadZoneScene = CREATE_DEAD_ZONE_SCREEN_PX / this.store.ui.viewport.zoom;
2839
3039
  const clicked = Math.hypot(dx, dy) <= deadZoneScene;
2840
3040
  const index = { v: -1 };
3041
+ const templateRowsPath = gesture.templateRowsPath;
2841
3042
  this.store.transact((draft) => {
2842
- 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);
2843
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
+ }
2844
3058
  layer.position = { ...layer.position, x: gesture.startScene.x, y: gesture.startScene.y };
2845
3059
  } else {
2846
3060
  layer.position = { ...layer.position, x: gesture.rect.x, y: gesture.rect.y };
@@ -3028,19 +3242,23 @@ var EditorSession = class {
3028
3242
  return {
3029
3243
  rulersVisible: ui.rulersVisible,
3030
3244
  anchorExpanded: ui.anchorExpanded,
3031
- nextTextStyle: ui.nextTextStyle
3245
+ nextTextStyle: ui.nextTextStyle,
3246
+ nextLayerRatio: ui.nextLayerRatio
3032
3247
  };
3033
3248
  }
3034
3249
  /**
3035
3250
  * 应用偏好(会话建立时一次性,跨会话恢复 prefs 的写入口):整体替换
3036
3251
  * rulersVisible/anchorExpanded,不进历史;值等写入经 store 短路零通知。
3037
3252
  * 新层样式(text-defaults 工单 01)走宽容读逐字段合并(pickNextTextStylePatch
3038
- * 共享谓词——与捕获同门,缺字/类型不符保留现值)。
3253
+ * 共享谓词——与捕获同门,缺字/类型不符保留现值);建层比例(image-ratio-preset
3254
+ * 工单 01)走宽容读枚举收口(pickNextLayerRatio——非六档枚举值/旧快照缺字段
3255
+ * 回落 'free')。
3039
3256
  */
3040
3257
  applyEditorPrefs(prefs) {
3041
3258
  this.store.setRulersVisible(prefs.rulersVisible);
3042
3259
  this.store.setAnchorExpanded(prefs.anchorExpanded);
3043
3260
  this.store.setNextTextStyle(pickNextTextStylePatch(prefs.nextTextStyle));
3261
+ this.store.setNextLayerRatio(pickNextLayerRatio(prefs.nextLayerRatio));
3044
3262
  }
3045
3263
  // ---- 对齐画布(layer-align-snap 工单 01):图层盒整体对齐画布几何 ----
3046
3264
  /**
@@ -3084,11 +3302,10 @@ var EditorSession = class {
3084
3302
  * 进入文本编辑:路径必须解析到 TextLayer。会话只进 ui 分支(不进历史),
3085
3303
  * live 文本住在绑定层的 textarea,内容层随即跳绘该层文本(防重影)。
3086
3304
  *
3087
- * 内容类型会话标志(canvas-web-expression-editing 工单 01,spec 决策 2)按
3088
- * 进入瞬间 `layer.expression !== null` 锚定进会话快照——「进入时计、编辑中
3089
- * 稳定」(verticalAnchorY 同门):编辑中面板改标记不回写标志,提交去向由
3090
- * 快照裁决(打标是显式动作,内容判定制已否决)。同路径重复进入短路(快照
3091
- * 不重锚);换层进入按新层现状重锚。
3305
+ * 内容模式活标志(ADR 0019)初值按进入瞬间 `layer.expression !== null` 取定
3306
+ * (与文档现态一致),此后不再冻结——pill 切换与内核 toggleDataMode 会话期
3307
+ * 都只翻标志,提交去向由退出时现态裁决(见 commitTextEdit)。同路径重复进入
3308
+ * 短路(会话不重建,接管序号延续);换层进入按新层现状取初值。
3092
3309
  */
3093
3310
  beginTextEdit(path) {
3094
3311
  const layer = this.textLayerAt(path);
@@ -3104,14 +3321,14 @@ var EditorSession = class {
3104
3321
  * 提交与随后 blur 竞态),会话清空后后续调用幂等,保证一次退出恰一步历史。
3105
3322
  *
3106
3323
  * 提交矩阵(canvas-web-expression-editing 工单 01,spec 决策 3,空串判定先于
3107
- * 片段判定):
3108
- * - 串与文档一致:不进历史(进出编辑零噪声),返回 false——对比对象随会话
3109
- * 模式取:表达式会话对比 `layer.expression`、字面会话对比 `layer.text`(现行
3110
- * 语义);
3324
+ * 片段判定;对比对象与去向裁决按会话标志**退出时现态**——活标志语义,ADR
3325
+ * 0019,取代旧「进入时快照」):
3326
+ * - 串与文档一致:不进历史(进出编辑零噪声),返回 false——对比对象随退出时
3327
+ * 会话标志取:表达式现态对比 `layer.expression`、静态现态对比 `layer.text`;
3111
3328
  * - 空串:删除该图层(复用 deleteLayer 的 splice/重映射语义),任何模式同语义;
3112
- * - 表达式会话且串含 ≥1 语法有效闭合片段(hasValidExpressionFragment):
3329
+ * - 表达式现态且串含 ≥1 语法有效闭合片段(hasValidExpressionFragment):
3113
3330
  * updateDataExpression 标记写(保持标记、text 自动重镜像);
3114
- * - 其余(字面会话恒走此支;表达式会话无合法片段回落):updateData 字面写、
3331
+ * - 其余(静态现态恒走此支;表达式现态无合法片段回落):updateData 字面写、
3115
3332
  * 字面接管解标——含 `{{...}}` 按字面保留,杜绝无片段标记层。
3116
3333
  */
3117
3334
  commitTextEdit(text) {
@@ -3135,8 +3352,10 @@ var EditorSession = class {
3135
3352
  /**
3136
3353
  * 编辑 overlay 的布局描述:与绘制/命中同一套布局策略求出的盒几何与排版字段
3137
3354
  * (textarea 以盒左上对位、盒尺寸占位,字号/行高/内边距按场景像素交给 CSS
3138
- * transform 缩放)。verticalAnchorY 按进入编辑时的文档文本计算,编辑中保持
3139
- * 稳定不随键入跳动。非文本层/路径失效返回 null。
3355
+ * transform 缩放)。对当前文档现算(canvas-web-edit-overlay-toolbar 工单 01,
3356
+ * ADR 0019 布局现算):会话期样式/几何权威写实时反映到查询值;verticalAnchorY
3357
+ * 按当前文档文本计算——live 键入只住 textarea 草稿不落文档,键入不触发文档
3358
+ * 变更,锚点天然稳定不跳。非文本层/路径失效返回 null。
3140
3359
  */
3141
3360
  textEditLayout(path) {
3142
3361
  const layer = this.textLayerAt(path);
@@ -3213,7 +3432,8 @@ var EditorSession = class {
3213
3432
  * 原地不动——事务后派生列表在被替换处缩一位,原下标即「下一处」(「替换后
3214
3433
  * 自动跳下一处」靠派生天然成立,末处替换则钳位停在尾)。写值经 replaceHitsInDraft
3215
3434
  * 与文本编辑提交同门(text 直写 + expression 保持 null)。无文档/空 query/
3216
- * 无命中空转返回 false。
3435
+ * 无命中空转返回 false。命中编辑层时草稿接管(会话内容独占,工单 02——
3436
+ * 替换结果出现在正在编辑的框里)。
3217
3437
  */
3218
3438
  replaceOne() {
3219
3439
  const doc = this.store.doc;
@@ -3222,7 +3442,7 @@ var EditorSession = class {
3222
3442
  const matches = scanTextMatches(doc, query);
3223
3443
  const hit = matches[clampFindCursor(this.store.ui.find.cursor, matches.length)];
3224
3444
  if (!hit) return false;
3225
- this.store.transact((draft) => {
3445
+ this.transactContent([hit.path], (draft) => {
3226
3446
  replaceHitsInDraft(draft, [hit], this.store.ui.find.replacement);
3227
3447
  });
3228
3448
  return true;
@@ -3231,7 +3451,8 @@ var EditorSession = class {
3231
3451
  * 全部替换(查找条「全部替换」按钮):命中(path + 偏移对)在事务前按原文
3232
3452
  * 快照一次算定,单事务内按同字段偏移降序应用、替换产物不重扫(replacement
3233
3453
  * 含查询串不死循环)——一次调用 = 一步历史,undo 一次全回。返回应用处数
3234
- * (计数反馈「已替换 N 处」);无文档/空 query 空转返回 0。
3454
+ * (计数反馈「已替换 N 处」);无文档/空 query 空转返回 0。命中编辑层时
3455
+ * 草稿接管(会话内容独占,工单 02)。
3235
3456
  */
3236
3457
  replaceAll() {
3237
3458
  const doc = this.store.doc;
@@ -3240,7 +3461,7 @@ var EditorSession = class {
3240
3461
  const hits = scanTextMatches(doc, query);
3241
3462
  if (hits.length === 0) return 0;
3242
3463
  let applied = 0;
3243
- this.store.transact((draft) => {
3464
+ this.transactContent(hits.map((hit) => hit.path), (draft) => {
3244
3465
  applied = replaceHitsInDraft(draft, hits, this.store.ui.find.replacement);
3245
3466
  });
3246
3467
  return applied;
@@ -3294,9 +3515,13 @@ var EditorSession = class {
3294
3515
  * null,值与标记的镜像关系随之解除——标记态文档经字面编辑退化为字面态,
3295
3516
  * 不可自动恢复属预期(spec §3.7 兼容面)。
3296
3517
  * 表/行/格无数据字段,空转。
3518
+ *
3519
+ * 会话内容独占(工单 02,ADR 0019 内核底线面):命中编辑层的写落文档的同时
3520
+ * 草稿接管(draftSeq 递增,绑定层全量替换 textarea 草稿)——旁门程序化写的
3521
+ * 结果进编辑框草稿而非被退出提交覆盖,任何方向不丢字。
3297
3522
  */
3298
3523
  updateData(path, value, options = {}) {
3299
- this.store.transact((draft) => {
3524
+ this.transactContent([path], (draft) => {
3300
3525
  const layer = resolveLayer(draft, path);
3301
3526
  if (!layer) return;
3302
3527
  switch (layer.type) {
@@ -3327,9 +3552,11 @@ var EditorSession = class {
3327
3552
  * 字面态空值归一交给 updateData/decode 分支(这里不清值,字面接管语义)。
3328
3553
  * - 仅三内容层生效,表/行/格/行模板空转(与 updateData 同门)。
3329
3554
  * - updateData 语义不变:字面写解除标记仍成立(spec §1 明示两者并排)。
3555
+ * - 命中编辑层的旁门打标同走草稿接管(draftSeq 递增;会话标志不随动——
3556
+ * 活标志只归 pill/toggleDataMode 翻,见 toggleDataMode)。
3330
3557
  */
3331
3558
  updateDataExpression(path, expression, options = {}) {
3332
- this.store.transact((draft) => {
3559
+ this.transactContent([path], (draft) => {
3333
3560
  const layer = resolveLayer(draft, path);
3334
3561
  if (!layer) return;
3335
3562
  switch (layer.type) {
@@ -3350,6 +3577,63 @@ var EditorSession = class {
3350
3577
  }
3351
3578
  }, options);
3352
3579
  }
3580
+ /**
3581
+ * 数据字段取值方式切换(静态 ⇆ 表达式,工单 02 自面板收编内核):表达式现态
3582
+ * → 字面接管(updateData,值保持现镜像);静态现态 → 打标(updateDataExpression,
3583
+ * 初值 = 值字段原文)。各为独立一步历史(无 mergeKey)。三内容层分派各取值
3584
+ * 字段,表/行/格/悬空路径空转。
3585
+ *
3586
+ * 活标志防御路由(ADR 0019):路径即编辑中的层时只翻会话活标志(零文档变更
3587
+ * 零历史步)——会话期内容模式切换的唯一写入口是会话标志,文档面锁定归编辑框
3588
+ * 草稿;面板锁定段/pill 显式调用与宿主旁路调用都安全收敛到同一语义。
3589
+ */
3590
+ toggleDataMode(path) {
3591
+ const editing = this.store.ui.editing;
3592
+ if (editing && pathsEqual(editing.path, path)) {
3593
+ this.store.setEditing({ ...editing, expression: !editing.expression });
3594
+ return;
3595
+ }
3596
+ const layer = this.store.doc ? resolveLayer(this.store.doc, path) : null;
3597
+ if (!layer) return;
3598
+ switch (layer.type) {
3599
+ case "TextLayer":
3600
+ if (layer.expression !== null) this.updateData(path, layer.text);
3601
+ else this.updateDataExpression(path, layer.text);
3602
+ return;
3603
+ case "ImageLayer":
3604
+ if (layer.expression !== null) this.updateData(path, layer.src ?? "");
3605
+ else this.updateDataExpression(path, layer.src ?? "");
3606
+ return;
3607
+ case "QrCodeLayer":
3608
+ if (layer.expression !== null) this.updateData(path, layer.value);
3609
+ else this.updateDataExpression(path, layer.value);
3610
+ return;
3611
+ default:
3612
+ return;
3613
+ }
3614
+ }
3615
+ /**
3616
+ * 内容写事务的统一形(会话内容独占工单 02):transact 落库 + 命中编辑层的
3617
+ * 草稿接管收口——updateData/updateDataExpression/replaceOne/replaceAll 四个
3618
+ * 旁门写入口同门(接管判定见 takeoverDraftIfNeeded;零变更事务天然短路)。
3619
+ */
3620
+ transactContent(paths, recipe, options = {}) {
3621
+ const docBefore = this.store.doc;
3622
+ this.store.transact(recipe, options);
3623
+ this.takeoverDraftIfNeeded(paths, docBefore);
3624
+ }
3625
+ /**
3626
+ * 草稿接管收口(工单 02):paths 中有编辑层且文档确实变更(引用比较——零变更
3627
+ * 事务 transact 空转不通知,接管序号也不动)时递增会话 draftSeq 并广播
3628
+ * ui.editing。绑定层据此全量替换 textarea 草稿为文档新值并刷新表达式诊断。
3629
+ * commitTextEdit 先清会话再落库,天然不复入接管。
3630
+ */
3631
+ takeoverDraftIfNeeded(paths, docBefore) {
3632
+ const editing = this.store.ui.editing;
3633
+ if (!editing || this.store.doc === docBefore) return;
3634
+ if (!paths.some((path) => pathsEqual(path, editing.path))) return;
3635
+ this.store.setEditing({ ...editing, draftSeq: (editing.draftSeq ?? 0) + 1 });
3636
+ }
3353
3637
  /** 更新画布级字段(未选中图层时面板的宽/高写入口);键由类型收窄为 width/height */
3354
3638
  updateCanvasProp(key, value, options = {}) {
3355
3639
  this.store.transact((draft) => {
@@ -3361,12 +3645,14 @@ var EditorSession = class {
3361
3645
  * 注入数据源 schema 声明(载荷形态,D1 钉定:声明即 compile(canvas, dataset)
3362
3646
  * 收到的 data 载荷形状,根上下文候选 = 载荷顶层键)。声明经
3363
3647
  * normalizeExpressionSchemaSource 一次性编译收口——根级结构性问题(保留键
3364
- * row/$ 前缀,前移填充期 reserved_root_key;根非对象/缺 properties 层级)降级
3365
- * 为无候选 + console 警告,局部故障($ref 断链/外部指针/环引用/目标形态不符)
3366
- * 不拒绝:该节点降叶子 + 注入期汇总告警一次,树其余部分照常服务,不抛错不
3367
- * 弹错(辅助声明不作权威);null/undefined = 清除声明。声明只住 store ui 分支:
3368
- * 不进 graph、不落 localStorage、不进 wire;openDocument 换文档不重置,
3369
- * 重注入/清除走同一入口。签名与「注入即整体替换」语义不变(D6)。
3648
+ * row/$ 前缀,前移填充期 reserved_root_key;根非对象/缺 properties 层级)
3649
+ * 与开放映射契约废除(open_map_unsupported,ADR 0020:文档任意层
3650
+ * additionalProperties: true 显式拒绝,动态键归宿主组装成具体 properties 后
3651
+ * 注入)降级为无候选 + console 警告,局部故障($ref 断链/外部指针/环引用/
3652
+ * 目标形态不符)不拒绝:该节点降叶子 + 注入期汇总告警一次,树其余部分照常
3653
+ * 服务,不抛错不弹错(辅助声明不作权威);null/undefined = 清除声明。声明只
3654
+ * 住 store ui 分支:不进 graph、不落 localStorage、不进 wire;openDocument
3655
+ * 换文档不重置,重注入/清除走同一入口。签名与「注入即整体替换」语义不变(D6)。
3370
3656
  */
3371
3657
  setDataSourceSchema(raw) {
3372
3658
  this.store.setDataSourceSchema(
@@ -3928,19 +4214,6 @@ var EditorSession = class {
3928
4214
  });
3929
4215
  if (index.v >= 0) this.store.setSelection([...rowPath, "cells", index.v]);
3930
4216
  }
3931
- /**
3932
- * 新增模板表(「+」选择器入口,spec §2.1):createTemplateTable 缺省形态 +
3933
- * 置顶 + 自动选中;一次调用 = 一步历史。rowsPath 必填(解码 rows_path_missing
3934
- * 硬约束的对齐兜底,表单校验前置),空串 no-op。
3935
- */
3936
- addTemplateTable(rowsPath) {
3937
- if (!this.store.doc || rowsPath === "") return;
3938
- const index = { v: -1 };
3939
- this.store.transact((draft) => {
3940
- index.v = insertRootLayerInDraft(draft, createTemplateTable({ rowsPath }));
3941
- });
3942
- if (index.v >= 0) this.store.setSelection(["layers", index.v]);
3943
- }
3944
4217
  /**
3945
4218
  * 模板行加格(spec §3.1):addTemplateCellInDraft 重建路径(末格宽/首格行宽、
3946
4219
  * 零高度耦合),自动选中新格;一次调用 = 一步历史。非模板态 no-op。
@@ -4241,6 +4514,29 @@ function classifyWheel(input) {
4241
4514
  return "pan";
4242
4515
  }
4243
4516
 
4517
+ // src/shared/expressionDiagnostics.ts
4518
+ function expressionDiagnostics(template) {
4519
+ const found = [];
4520
+ for (const part of scanExpressionFragments(template).parts) {
4521
+ if (part.kind === "fragment" && part.diagnostic !== null) found.push(part.diagnostic);
4522
+ }
4523
+ return found;
4524
+ }
4525
+
4526
+ // src/shared/resourcePath.ts
4527
+ function wholeTemplateDiagnostic(template, kind) {
4528
+ return { kind, start: 0, end: template.length, at: { start: 0, end: template.length } };
4529
+ }
4530
+ function resourcePathDiagnostics(template) {
4531
+ const anchored = template.endsWith("\n") ? template.slice(0, -1) : template;
4532
+ const matched = /^\{\{([^{}]*)\}\}$/.exec(anchored);
4533
+ if (matched === null) return [wholeTemplateDiagnostic(template, "resource_not_single_ref")];
4534
+ const path = phpTrim2(matched[1] ?? "");
4535
+ if (path === "") return [wholeTemplateDiagnostic(template, "empty_fragment")];
4536
+ if (path.includes("(")) return [wholeTemplateDiagnostic(template, "resource_function_call")];
4537
+ return [];
4538
+ }
4539
+
4244
4540
  // src/shared/expressionFunctions.ts
4245
4541
  var EXPRESSION_FUNCTIONS = [
4246
4542
  { name: "date", signature: "date(path[, format])", summary: "\u65E5\u671F\u683C\u5F0F\u5316\uFF08moment \u98CE\u683C token\uFF0C\u9884\u8BBE date/datetime\uFF09" },
@@ -4272,7 +4568,7 @@ function candidateMeta(node) {
4272
4568
  }
4273
4569
  function childCandidate(entry, prefix) {
4274
4570
  const path = prefix === "" ? entry.key : `${prefix}.${entry.key}`;
4275
- return { path, segment: entry.key, ...candidateMeta(entry.node) };
4571
+ return { path, segment: entry.key, kind: "path", ...candidateMeta(entry.node) };
4276
4572
  }
4277
4573
  function callCandidatesFor(entry, path, exprLen) {
4278
4574
  const extras = [];
@@ -4283,6 +4579,7 @@ function callCandidatesFor(entry, path, exprLen) {
4283
4579
  cursorBack: 2,
4284
4580
  replaceLen: exprLen,
4285
4581
  filterKey: entry.key,
4582
+ kind: "function",
4286
4583
  description: "\u65E5\u671F\u683C\u5F0F\u5316",
4287
4584
  type: "\u51FD\u6570"
4288
4585
  });
@@ -4293,6 +4590,7 @@ function callCandidatesFor(entry, path, exprLen) {
4293
4590
  segment: `${fn.name}(${path})`,
4294
4591
  replaceLen: exprLen,
4295
4592
  filterKey: entry.key,
4593
+ kind: "function",
4296
4594
  description: fn.summary,
4297
4595
  type: "\u51FD\u6570"
4298
4596
  });
@@ -4310,6 +4608,7 @@ function functionCandidates() {
4310
4608
  path: fn.signature,
4311
4609
  segment: `${fn.name}()`,
4312
4610
  cursorBack: 1,
4611
+ kind: "function",
4313
4612
  description: fn.summary,
4314
4613
  type: "\u51FD\u6570"
4315
4614
  }));
@@ -4325,12 +4624,12 @@ function dateFormatCandidates() {
4325
4624
  function headCandidates(schema, context, rowSchema, exprLen, withBuckets) {
4326
4625
  const view = schemaChildEntries(schema);
4327
4626
  const candidates = view.entries.flatMap((entry) => childCandidates(entry, "", exprLen, withBuckets));
4328
- candidates.push({ path: "$root", segment: "$root" });
4627
+ candidates.push({ path: "$root", segment: "$root", kind: "path" });
4329
4628
  if (context === "row") {
4330
- candidates.push(rowSchema ? { path: "row", segment: "row", ...candidateMeta(rowSchema) } : { path: "row", segment: "row" });
4331
- candidates.push({ path: "$index", segment: "$index" });
4629
+ candidates.push(rowSchema ? { path: "row", segment: "row", kind: "path", ...candidateMeta(rowSchema) } : { path: "row", segment: "row", kind: "path" });
4630
+ candidates.push({ path: "$index", segment: "$index", kind: "path" });
4332
4631
  }
4333
- return view.open === true ? { candidates, open: true } : { candidates };
4632
+ return candidates;
4334
4633
  }
4335
4634
  function callSlotResult(schema, query) {
4336
4635
  const slot = expressionCallSlotAtEnd(query.expr);
@@ -4359,7 +4658,7 @@ function enumeratePathCandidates(schema, query, expr, allowCallSuggestions) {
4359
4658
  ok: true,
4360
4659
  prefix: "",
4361
4660
  partial: "",
4362
- ...headCandidates(schema, query.context, query.rowSchema, expr.length, false)
4661
+ candidates: headCandidates(schema, query.context, query.rowSchema, expr.length, false)
4363
4662
  };
4364
4663
  }
4365
4664
  return { ok: false, reason: "expression_syntax_error" };
@@ -4389,8 +4688,7 @@ function enumeratePathCandidates(schema, query, expr, allowCallSuggestions) {
4389
4688
  ok: true,
4390
4689
  prefix: "",
4391
4690
  partial,
4392
- candidates: [...view2.candidates, ...fns],
4393
- ...view2.open === true ? { open: true } : {}
4691
+ candidates: [...view2, ...fns]
4394
4692
  };
4395
4693
  }
4396
4694
  let base;
@@ -4420,8 +4718,7 @@ function enumeratePathCandidates(schema, query, expr, allowCallSuggestions) {
4420
4718
  ok: true,
4421
4719
  prefix,
4422
4720
  partial,
4423
- candidates: view.entries.flatMap((entry) => childCandidates(entry, prefix, expr.length, withBuckets)),
4424
- ...view.open === true ? { open: true } : {}
4721
+ candidates: view.entries.flatMap((entry) => childCandidates(entry, prefix, expr.length, withBuckets))
4425
4722
  };
4426
4723
  }
4427
4724
  function enumerateExpressionCandidates(schema, query) {
@@ -4439,7 +4736,7 @@ function isArrayNode(node) {
4439
4736
  return node.type === "array" || node.items !== void 0;
4440
4737
  }
4441
4738
  function isScalarNode(node) {
4442
- return !isArrayNode(node) && node.properties === void 0 && node.open !== true;
4739
+ return !isArrayNode(node) && node.properties === void 0;
4443
4740
  }
4444
4741
  function childCandidate2(key, node, prefix) {
4445
4742
  const candidate = {
@@ -4459,7 +4756,7 @@ function levelCandidates(base, prefix) {
4459
4756
  candidates.push(childCandidate2(key, child, prefix));
4460
4757
  }
4461
4758
  }
4462
- return base.open === true ? { candidates, open: true } : { candidates };
4759
+ return candidates;
4463
4760
  }
4464
4761
  function enumerateRowsPathCandidates(startSchema, input) {
4465
4762
  if (startSchema === null) return null;
@@ -4475,11 +4772,7 @@ function enumerateRowsPathCandidates(startSchema, input) {
4475
4772
  }
4476
4773
  if (isArrayNode(base) || isScalarNode(base)) return null;
4477
4774
  const view = levelCandidates(base, completed.join("."));
4478
- return {
4479
- partial,
4480
- candidates: view.candidates,
4481
- ...view.open === true ? { open: true } : {}
4482
- };
4775
+ return { partial, candidates: view };
4483
4776
  }
4484
4777
  function resolveRowsPathStartSchema(doc, path, rootSchema) {
4485
4778
  if (rootSchema === null) return null;
@@ -4598,6 +4891,7 @@ export {
4598
4891
  PAN_TO_BOX_MARGIN_PX,
4599
4892
  PASTE_OFFSET_PX,
4600
4893
  PHP_TRIM_CHARS,
4894
+ RATIO_PRESETS,
4601
4895
  RESIZE_HANDLES,
4602
4896
  RESIZE_HANDLE_HIT_PX,
4603
4897
  RESIZE_MIN_SIZE_PX,
@@ -4627,11 +4921,14 @@ export {
4627
4921
  createDefaultLayer,
4628
4922
  createRubberBandRect,
4629
4923
  createTemplateTable,
4924
+ defaultNextLayerRatio,
4630
4925
  defaultNextTextStyle,
4926
+ defaultSizeForRatio,
4631
4927
  deleteLayerInDraft,
4632
4928
  enumerateExpressionCandidates,
4633
4929
  enumerateRowsPathCandidates,
4634
4930
  expressionCallSlotAtEnd,
4931
+ expressionDiagnostics,
4635
4932
  expressionFragmentAtCursor,
4636
4933
  fitRect,
4637
4934
  fitViewport,
@@ -4667,22 +4964,28 @@ export {
4667
4964
  pathStartsWith,
4668
4965
  pathsEqual,
4669
4966
  phpTrim2 as phpTrim,
4967
+ pickNextLayerRatio,
4670
4968
  pickNextTextStylePatch,
4671
4969
  prepareRootPaste,
4970
+ proportionalDrivingAxis,
4971
+ ratioValue,
4672
4972
  readSpecField,
4673
4973
  remapPathAfterSplice,
4674
4974
  replaceHitsInDraft,
4675
4975
  resizableAxesAt,
4676
4976
  resizeBox,
4977
+ resizeBoxProportional,
4677
4978
  resizeHandleAt,
4678
4979
  resizeHandlePoint,
4679
4980
  resizeHandlesAt,
4981
+ resizeProportionalSnapPoints,
4680
4982
  resizeSnapPoints,
4681
4983
  resolveLayer,
4682
4984
  resolveRowSchema,
4683
4985
  resolveRowsPathStartSchema,
4684
4986
  resolveSnap,
4685
4987
  resolveSnapPoints,
4988
+ resourcePathDiagnostics,
4686
4989
  rootLayerOf,
4687
4990
  scanExpressionFragments,
4688
4991
  scanTextMatches,