@jxsuite/studio 8.1.0 → 10.0.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/chunks/{2020-dg097k3e.js → 2020-j56903f1.js} +3 -3
- package/dist/chunks/adapter-wc5zvg9a.js +4 -0
- package/dist/chunks/add-new-flow-snx1s4b0.js +4 -0
- package/dist/chunks/cf-account-picker-xyqw0jex.js +4 -0
- package/dist/chunks/cf-settings-hp1pjm0n.js +4 -0
- package/dist/chunks/color-04361ttk.js +4 -0
- package/dist/chunks/{color-area-vh9cew51.js → color-area-h8dbasp1.js} +2 -2
- package/dist/chunks/{color-field-e78f16bs.js → color-field-qv5r2b16.js} +3 -3
- package/dist/chunks/{color-slider-fg4yhvk6.js → color-slider-nz2mrzky.js} +2 -2
- package/dist/chunks/components-nkwphrx3.js +4 -0
- package/dist/chunks/content-source-6a253wmk.js +4 -0
- package/dist/chunks/{convert-to-repeater-grnmea6c.js → convert-to-repeater-fhdwyg14.js} +3 -3
- package/dist/chunks/editor-wqrc68ca.js +4 -0
- package/dist/chunks/entry-commands-nef0wrmp.js +4 -0
- package/dist/chunks/{extension-sections-rp7fkega.js → extension-sections-8ez2rrk8.js} +3 -3
- package/dist/chunks/file-ops-y3w71k1n.js +4 -0
- package/dist/chunks/files-de90apn9.js +4 -0
- package/dist/chunks/format-host-zfjz4rzm.js +4 -0
- package/dist/chunks/fs-events-77c0mr0x.js +4 -0
- package/dist/chunks/grid-open-4n4fjfpf.js +4 -0
- package/dist/chunks/{grid-view-x26rfpz1.js → grid-view-ka55z38f.js} +10 -10
- package/dist/chunks/{iframe-entry-pkgmcgp0.js → iframe-entry-2x9bn5ng.js} +4 -4
- package/dist/chunks/{iframe-entry-02wzcyvy.js → iframe-entry-btck0e9s.js} +4 -4
- package/dist/chunks/iframe-host-na2mywz4.js +4 -0
- package/dist/chunks/index-ewmgdb7c.js +4 -0
- package/dist/chunks/{index-203h71wy.js → index-qb98xhse.js} +3 -3
- package/dist/chunks/{javascript-ww5pv5ae.js → javascript-ces13wny.js} +2 -2
- package/dist/chunks/{jsonMode-vwbdwwq3.js → jsonMode-6md16d4v.js} +6 -6
- package/dist/chunks/jx-validate-b309c020.js +4 -0
- package/dist/chunks/layers-8rn8y6qv.js +4 -0
- package/dist/chunks/layers-panel-eyah7h9z.js +4 -0
- package/dist/chunks/lit-html-wyqw04y2.js +4 -0
- package/dist/chunks/media-library-gs3np47y.js +4 -0
- package/dist/chunks/media-picker-tt7dk4tb.js +4 -0
- package/dist/chunks/{monaco-binding-63tqze27.js → monaco-binding-p8j80s4w.js} +3 -3
- package/dist/chunks/monaco-buffer-wsf6cmnq.js +4 -0
- package/dist/chunks/{monaco-setup-zyyynnj8.js → monaco-setup-wasktzfg.js} +3 -3
- package/dist/chunks/notify-qwst108r.js +4 -0
- package/dist/chunks/parse-n8dfxnqf.js +4 -0
- package/dist/chunks/platform-c16mqvmp.js +4 -0
- package/dist/chunks/publish-panel-d34bhvwj.js +4 -0
- package/dist/chunks/{publish-quick-ajzdhhs6.js → publish-quick-vr695j26.js} +3 -3
- package/dist/chunks/publish-workspace-2yr60mt4.js +4 -0
- package/dist/chunks/quick-search-e6xsdv0d.js +4 -0
- package/dist/chunks/references-gsj6trdv.js +4 -0
- package/dist/chunks/right-panel-33jv1knc.js +4 -0
- package/dist/chunks/source-control-bj9bejjv.js +4 -0
- package/dist/chunks/studio-03mqh8ax.js +5 -0
- package/dist/chunks/{studio-957jg8g3.js → studio-06j4en5h.js} +4 -4
- package/dist/chunks/studio-0ydxv2mr.js +7 -0
- package/dist/chunks/{studio-v2ek69cb.js → studio-1e8dw2w2.js} +4 -4
- package/dist/chunks/studio-2hsqbq7b.js +7 -0
- package/dist/chunks/studio-2j86y34y.js +6 -0
- package/dist/chunks/studio-2ks6ka4g.js +5 -0
- package/dist/chunks/studio-34jrz12k.js +5 -0
- package/dist/chunks/{studio-1meqbxns.js → studio-35vrtpdq.js} +4 -4
- package/dist/chunks/studio-38g0jdz7.js +5 -0
- package/dist/chunks/{studio-25a5by3h.js → studio-396srjbk.js} +4 -4
- package/dist/chunks/{studio-zm7dnr9g.js → studio-3c8pp7pb.js} +4 -4
- package/dist/chunks/{studio-x3ge4rts.js → studio-3v5cagy9.js} +4 -4
- package/dist/chunks/studio-3w35qvay.js +5 -0
- package/dist/chunks/studio-3zge48z4.js +7 -0
- package/dist/chunks/studio-45gyxpcq.js +5 -0
- package/dist/chunks/{studio-hw60n5dh.js → studio-4kzbrr5t.js} +3 -3
- package/dist/chunks/{studio-533py3yy.js → studio-5tzgq6fx.js} +4 -4
- package/dist/chunks/studio-5wzdy1jc.js +16 -0
- package/dist/chunks/studio-732mw0g8.js +5 -0
- package/dist/chunks/{studio-3q6n9qvq.js → studio-771k2n12.js} +4 -4
- package/dist/chunks/{studio-z42kysg7.js → studio-7dzyz9n8.js} +137 -137
- package/dist/chunks/{studio-fqs1a22a.js → studio-7xag4na8.js} +4 -4
- package/dist/chunks/{studio-jqrg9xxg.js → studio-881z1xrx.js} +4 -4
- package/dist/chunks/studio-88bmt9rh.js +5 -0
- package/dist/chunks/{studio-8nyw10dp.js → studio-9b7177jq.js} +4 -4
- package/dist/chunks/{studio-nm9qev4p.js → studio-9cxmp61c.js} +5 -5
- package/dist/chunks/{studio-4dsdnan2.js → studio-9dz76yqc.js} +5 -5
- package/dist/chunks/{studio-a6x058ew.js → studio-agyvzfm4.js} +4 -4
- package/dist/chunks/{studio-2b8443ms.js → studio-ajw4k8hp.js} +3 -3
- package/dist/chunks/studio-c1pxszat.js +5 -0
- package/dist/chunks/studio-cpkamjdn.js +14 -0
- package/dist/chunks/studio-d038kqbs.js +5 -0
- package/dist/chunks/studio-d83m0wyq.js +5 -0
- package/dist/chunks/studio-dbxv7w4s.js +5 -0
- package/dist/chunks/studio-de1s2x9v.js +5 -0
- package/dist/chunks/{studio-94ppxeqy.js → studio-dx73md4n.js} +8 -8
- package/dist/chunks/studio-ekgtrndg.js +5 -0
- package/dist/chunks/studio-epenqmvk.js +7 -0
- package/dist/chunks/studio-et2q4s8n.js +5 -0
- package/dist/chunks/{studio-txkx6586.js → studio-ewarnzm4.js} +7 -7
- package/dist/chunks/{studio-bf0rgyf7.js → studio-fy7jzj9j.js} +4 -4
- package/dist/chunks/{studio-332hesdk.js → studio-gr36vnte.js} +4 -4
- package/dist/chunks/{studio-rdnezzkd.js → studio-gwrhcrss.js} +5 -5
- package/dist/chunks/{studio-9wyqyfqy.js → studio-h310mbqm.js} +4 -4
- package/dist/chunks/{studio-zht0x0v3.js → studio-h5tpz5ke.js} +4 -4
- package/dist/chunks/studio-h7weqf2q.js +5 -0
- package/dist/chunks/{studio-1gdfsr49.js → studio-hcsba201.js} +7 -7
- package/dist/chunks/studio-hwejq03c.js +5 -0
- package/dist/chunks/{studio-wcakp5bt.js → studio-hy4ye0dx.js} +7 -7
- package/dist/chunks/{studio-02phtmqn.js → studio-jttyqrmh.js} +4 -4
- package/dist/chunks/{studio-j5jqvyn3.js → studio-jw0q5j6k.js} +4 -4
- package/dist/chunks/studio-k3a8rp3d.js +5 -0
- package/dist/chunks/{studio-hnx1h1pr.js → studio-k3bcy02e.js} +4 -4
- package/dist/chunks/studio-m48v13s3.js +4 -0
- package/dist/chunks/{studio-bqgs90r8.js → studio-ndskgqef.js} +4 -4
- package/dist/chunks/studio-p1n2xjda.js +5 -0
- package/dist/chunks/studio-pgm83a2f.js +6 -0
- package/dist/chunks/studio-pqqq6v85.js +5 -0
- package/dist/chunks/studio-psh8mwnw.js +4 -0
- package/dist/chunks/{studio-cpdzrdgp.js → studio-r17asgs0.js} +4 -4
- package/dist/chunks/{studio-6vrxzkba.js → studio-ry81j4q1.js} +4 -4
- package/dist/chunks/studio-rzg8hnkf.js +5 -0
- package/dist/chunks/{studio-nsv7g1qn.js → studio-s0fg2wav.js} +4 -4
- package/dist/chunks/{studio-v0gsjav0.js → studio-tj2s0tks.js} +4 -4
- package/dist/chunks/studio-tyw7tk0x.js +5 -0
- package/dist/chunks/studio-v9ywz2se.js +6 -0
- package/dist/chunks/{studio-dmn7gpba.js → studio-vswcpzk7.js} +5 -5
- package/dist/chunks/studio-vzgmyt0v.js +5 -0
- package/dist/chunks/{studio-2dtc90zs.js → studio-wxdcd42g.js} +6 -6
- package/dist/chunks/{studio-c5dj3dzk.js → studio-wxhy1yh7.js} +4 -4
- package/dist/chunks/studio-x08a3m0a.js +5 -0
- package/dist/chunks/studio-x3mjr5cc.js +5 -0
- package/dist/chunks/{studio-xddgbwhe.js → studio-x97ay0v4.js} +4 -4
- package/dist/chunks/{studio-mmqr9awv.js → studio-xdpz9rpe.js} +33 -33
- package/dist/chunks/studio-xh683qzy.js +5 -0
- package/dist/chunks/{studio-hxq1ebp0.js → studio-xkxetm65.js} +6 -6
- package/dist/chunks/{studio-73wz63nn.js → studio-y26hx9re.js} +4 -4
- package/dist/chunks/{studio-kdseew9m.js → studio-yg3djpnm.js} +4 -4
- package/dist/chunks/studio-yx8hzqja.js +5 -0
- package/dist/chunks/studio-zg16eeq1.js +5 -0
- package/dist/chunks/studio-zgx7svjd.js +9 -0
- package/dist/chunks/studio-zkq3r91p.js +5 -0
- package/dist/chunks/{toast-host-1yg5ar8d.js → toast-host-g3r2s0kn.js} +2 -2
- package/dist/chunks/toast-w11qpkyz.js +4 -0
- package/dist/chunks/{tsMode-vra2hh7y.js → tsMode-h6tkz0ea.js} +5 -5
- package/dist/chunks/workspace-home-zkkrxq3r.js +4 -0
- package/dist/chunks/workspace-tdqqjqaa.js +4 -0
- package/dist/chunks/{ws-client-72x5n99j.js → ws-client-e951my95.js} +3 -3
- package/dist/iframe-entry.js +13 -13
- package/dist/manifest.json +77 -77
- package/dist/studio.js +91 -92
- package/package.json +11 -12
- package/src/canvas/iframe-editable-root.ts +32 -2
- package/src/collab/collab-session.ts +6 -2
- package/src/commands/context.ts +4 -0
- package/src/commands/defaults.ts +2 -0
- package/src/commands/live-context.ts +2 -0
- package/src/files/files.ts +6 -3
- package/src/format/format-host.ts +1 -1
- package/src/github/github-auth-status.ts +2 -2
- package/src/media/media-library.ts +73 -3
- package/src/new-project/new-project-modal.ts +1 -4
- package/src/packages/ensure-deps.ts +5 -5
- package/src/packages/pull-package-sync.ts +4 -4
- package/src/panels/activity-panel.ts +1 -1
- package/src/panels/ai-panel.ts +27 -34
- package/src/platform.ts +20 -0
- package/src/platforms/cloud.ts +48 -34
- package/src/platforms/devserver.ts +2 -120
- package/src/services/ai-ask.ts +29 -171
- package/src/services/ai-command-tools.ts +40 -50
- package/src/services/ai-import-tools.ts +4 -5
- package/src/services/ai-project-tools.ts +13 -3
- package/src/services/ai-tools.ts +26 -564
- package/src/services/ai-writes.ts +84 -2
- package/src/services/document-assistant.ts +29 -58
- package/src/services/harness/assistant-tools.ts +68 -0
- package/src/services/harness/interaction-store.ts +118 -0
- package/src/services/harness/studio-documents.ts +105 -0
- package/src/services/harness/turn-hooks.ts +1 -6
- package/src/services/project-adoption.ts +8 -16
- package/src/services/tool-executor.ts +25 -22
- package/src/settings/extension-commands.ts +1 -1
- package/src/settings/extension-rows.ts +1 -6
- package/src/settings/extensions-section.ts +3 -6
- package/src/state.ts +3 -13
- package/src/surfaces/block-action-bar.json +1 -3
- package/src/surfaces/files-panel.json +12 -0
- package/src/surfaces/files-panel.ts +5 -2
- package/src/surfaces/media-library.json +35 -0
- package/src/surfaces/media-library.ts +2 -0
- package/src/surfaces/menu.ts +62 -1
- package/src/surfaces/settings-packages.ts +1 -1
- package/src/surfaces/welcome.ts +4 -1
- package/src/tabs/selection.ts +14 -109
- package/src/tabs/tab.ts +10 -1
- package/src/tabs/transact.ts +316 -124
- package/src/types.ts +23 -16
- package/src/ui/layers.ts +1 -1
- package/src/ui/progress-modal.ts +3 -3
- package/dist/chunks/adapter-tck81zft.js +0 -4
- package/dist/chunks/add-new-flow-a1w0bnm6.js +0 -4
- package/dist/chunks/cf-account-picker-ce3kp3x1.js +0 -4
- package/dist/chunks/cf-settings-7ye6x7g2.js +0 -4
- package/dist/chunks/color-4gj49xq6.js +0 -4
- package/dist/chunks/components-qce0v93f.js +0 -4
- package/dist/chunks/content-source-jjx8kgzr.js +0 -4
- package/dist/chunks/editor-bkja5dbv.js +0 -4
- package/dist/chunks/entry-commands-1wh0btvg.js +0 -4
- package/dist/chunks/file-ops-6pe4z7an.js +0 -4
- package/dist/chunks/files-ybpp3j38.js +0 -4
- package/dist/chunks/format-host-3mtbhxk1.js +0 -4
- package/dist/chunks/fs-events-xfanfxds.js +0 -4
- package/dist/chunks/grid-open-dg5qmnaj.js +0 -4
- package/dist/chunks/iframe-host-j2bk2xkc.js +0 -4
- package/dist/chunks/index-fkrm7pdk.js +0 -4
- package/dist/chunks/jx-validate-w7v32f6a.js +0 -4
- package/dist/chunks/layers-g7j732cm.js +0 -4
- package/dist/chunks/layers-panel-gjrsxcck.js +0 -4
- package/dist/chunks/lit-html-jdfz9rhc.js +0 -4
- package/dist/chunks/media-library-9j0v4wmk.js +0 -4
- package/dist/chunks/media-picker-cpyyt2m0.js +0 -4
- package/dist/chunks/monaco-buffer-9wcme176.js +0 -4
- package/dist/chunks/notify-rahnnnv4.js +0 -4
- package/dist/chunks/parse-v77xtwv7.js +0 -4
- package/dist/chunks/platform-b2aqv7v5.js +0 -4
- package/dist/chunks/publish-panel-4yk2461b.js +0 -4
- package/dist/chunks/publish-workspace-mxestzcf.js +0 -4
- package/dist/chunks/quick-search-9g6z65b0.js +0 -4
- package/dist/chunks/references-c26enpxs.js +0 -4
- package/dist/chunks/right-panel-9d0e0yg7.js +0 -4
- package/dist/chunks/source-control-xyn3a8ds.js +0 -4
- package/dist/chunks/studio-0khd0xdw.js +0 -5
- package/dist/chunks/studio-1fvzq9vs.js +0 -5
- package/dist/chunks/studio-1yhz0s8p.js +0 -5
- package/dist/chunks/studio-2c2mjyzj.js +0 -6
- package/dist/chunks/studio-3pndhat0.js +0 -9
- package/dist/chunks/studio-4fjqdzrb.js +0 -6
- package/dist/chunks/studio-5abge3wy.js +0 -5
- package/dist/chunks/studio-74nzv280.js +0 -5
- package/dist/chunks/studio-7cg75gtj.js +0 -14
- package/dist/chunks/studio-89cwmnae.js +0 -5
- package/dist/chunks/studio-8m1t83kn.js +0 -7
- package/dist/chunks/studio-a3evp7f6.js +0 -6
- package/dist/chunks/studio-ana67wax.js +0 -5
- package/dist/chunks/studio-b4xhpcwy.js +0 -4
- package/dist/chunks/studio-c4rcaftm.js +0 -5
- package/dist/chunks/studio-cgjt1zce.js +0 -5
- package/dist/chunks/studio-cq87hree.js +0 -5
- package/dist/chunks/studio-d140sy8e.js +0 -5
- package/dist/chunks/studio-e1g64q43.js +0 -5
- package/dist/chunks/studio-e8pzjjhf.js +0 -5
- package/dist/chunks/studio-f8kz3krj.js +0 -7
- package/dist/chunks/studio-fw3jp9m7.js +0 -5
- package/dist/chunks/studio-g6ysm05p.js +0 -7
- package/dist/chunks/studio-gmhd0j3f.js +0 -5
- package/dist/chunks/studio-gqpty626.js +0 -5
- package/dist/chunks/studio-grp684wx.js +0 -5
- package/dist/chunks/studio-h98520qr.js +0 -5
- package/dist/chunks/studio-hvxt8z79.js +0 -5
- package/dist/chunks/studio-j4e627w1.js +0 -5
- package/dist/chunks/studio-kt2j51sa.js +0 -5
- package/dist/chunks/studio-m4w6sygj.js +0 -16
- package/dist/chunks/studio-nqxqf8p2.js +0 -4
- package/dist/chunks/studio-p89w12s2.js +0 -7
- package/dist/chunks/studio-pbagym4j.js +0 -5
- package/dist/chunks/studio-pr23qhrw.js +0 -5
- package/dist/chunks/studio-r4ya3sb2.js +0 -5
- package/dist/chunks/studio-rm9m3z60.js +0 -5
- package/dist/chunks/studio-tpmnbgmt.js +0 -5
- package/dist/chunks/studio-ttxhq5yt.js +0 -5
- package/dist/chunks/studio-y1x65h5r.js +0 -5
- package/dist/chunks/studio-zf188hnd.js +0 -5
- package/dist/chunks/toast-1gv0h53h.js +0 -4
- package/dist/chunks/workspace-3nt93k48.js +0 -4
- package/dist/chunks/workspace-home-zbgyqqws.js +0 -4
- package/src/services/directory-picker.ts +0 -187
- package/src/services/gated-registry.ts +0 -77
package/src/tabs/transact.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
import { toRaw } from "../reactivity";
|
|
3
3
|
import { jsonClone } from "../utils/studio-utils";
|
|
4
|
-
import {
|
|
4
|
+
import { getNodeAtPath, pathsEqual } from "../state";
|
|
5
5
|
import {
|
|
6
6
|
cloneSelection,
|
|
7
7
|
primarySelection,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
structuralBatch,
|
|
11
11
|
} from "./selection";
|
|
12
12
|
import { applyDocOpToDoc, childArray, cloneValue, inverseOf } from "./doc-op-apply";
|
|
13
|
+
import { insertionSlot, planMove, shiftPrefixedIndex } from "@jxsuite/schema/doc-ops";
|
|
13
14
|
import {
|
|
14
15
|
beginRecording,
|
|
15
16
|
endRecording,
|
|
@@ -20,8 +21,9 @@ import {
|
|
|
20
21
|
} from "./patch-ops";
|
|
21
22
|
|
|
22
23
|
import type { JxDocOp, JxDocOpPair, TransactionRecord } from "./patch-ops";
|
|
24
|
+
import type { SelectionEffect } from "@jxsuite/ai/jx-tools";
|
|
23
25
|
|
|
24
|
-
import type { Tab } from "../tabs/tab";
|
|
26
|
+
import type { HistorySnapshot, Tab } from "../tabs/tab";
|
|
25
27
|
import type { JxPath } from "../state";
|
|
26
28
|
|
|
27
29
|
import type { JsonValue } from "../types";
|
|
@@ -38,7 +40,6 @@ import {
|
|
|
38
40
|
getNestedStyle,
|
|
39
41
|
isEventBinding,
|
|
40
42
|
isNonNullObject,
|
|
41
|
-
isNumber,
|
|
42
43
|
} from "@jxsuite/schema/guards";
|
|
43
44
|
|
|
44
45
|
const HISTORY_LIMIT = 100;
|
|
@@ -81,33 +82,6 @@ function setKeyPair(
|
|
|
81
82
|
};
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
/**
|
|
85
|
-
* Translate `path` into post-splice coordinates when it descends through `parentPath`'s children at
|
|
86
|
-
* or beyond the spliced index: a removal (delta -1) at fromIndex shifts deeper paths down; an
|
|
87
|
-
* insertion (delta +1, inclusive) shifts them up. Returns `path` unchanged when the splice doesn't
|
|
88
|
-
* affect it. Used to express a move's inverse in post-mutation coordinates.
|
|
89
|
-
*/
|
|
90
|
-
function shiftPrefixedIndex(
|
|
91
|
-
parentPath: JxPath,
|
|
92
|
-
path: JxPath,
|
|
93
|
-
spliceIndex: number,
|
|
94
|
-
delta: number,
|
|
95
|
-
inclusive: boolean,
|
|
96
|
-
): JxPath {
|
|
97
|
-
const d = parentPath.length;
|
|
98
|
-
const isProperPrefix = d < path.length && parentPath.every((seg, i) => seg === path[i]);
|
|
99
|
-
const idx = path[d + 1];
|
|
100
|
-
if (!isProperPrefix || path[d] !== "children" || !isNumber(idx)) {
|
|
101
|
-
return path;
|
|
102
|
-
}
|
|
103
|
-
if (inclusive ? idx < spliceIndex : idx <= spliceIndex) {
|
|
104
|
-
return path;
|
|
105
|
-
}
|
|
106
|
-
const shifted = [...path];
|
|
107
|
-
shifted[d + 1] = idx + delta;
|
|
108
|
-
return shifted;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
85
|
/** Any value that can sit at a document node key (undefined/null/"" deletes it). */
|
|
112
86
|
export type JxNodeValue =
|
|
113
87
|
| JsonValue
|
|
@@ -214,20 +188,21 @@ function rollbackFailedTransaction(
|
|
|
214
188
|
* with no prompt. A refusal is not an exception (it is an ordinary, expected state of the room), so
|
|
215
189
|
* it is a return value: `false` means the document is exactly as it was.
|
|
216
190
|
*
|
|
191
|
+
* **AND IT SAYS WHO WROTE.** `actor` (else the scope {@link runAsActor} opened, else the person at
|
|
192
|
+
* the keyboard) decides where the step lands: an actor that owns an open {@link BatchLane} folds it
|
|
193
|
+
* into that lane's segment on this tab, and anyone else first closes whatever segment is open here,
|
|
194
|
+
* so their edit is never folded into someone else's step (specs/ai.md §3.3).
|
|
195
|
+
*
|
|
217
196
|
* @param {Tab | null} tab
|
|
218
197
|
* @param {(tab: Tab) => void} mutationFn
|
|
219
|
-
* @param {
|
|
198
|
+
* @param {TransactOptions} [opts]
|
|
220
199
|
* @returns {boolean} True when the mutation was applied; false when there was no tab, or the gate
|
|
221
200
|
* refused it.
|
|
222
201
|
*/
|
|
223
202
|
export function transactDoc(
|
|
224
203
|
tab: Tab | null,
|
|
225
204
|
mutationFn: (tab: Tab) => void,
|
|
226
|
-
{
|
|
227
|
-
skipHistory = false,
|
|
228
|
-
origin = "user",
|
|
229
|
-
coalesceKey = null,
|
|
230
|
-
}: { skipHistory?: boolean; origin?: TransactOrigin; coalesceKey?: string | null } = {},
|
|
205
|
+
{ skipHistory = false, origin = "user", coalesceKey = null, actor = null }: TransactOptions = {},
|
|
231
206
|
): boolean {
|
|
232
207
|
if (!tab) {
|
|
233
208
|
return false;
|
|
@@ -235,6 +210,12 @@ export function transactDoc(
|
|
|
235
210
|
if (origin !== "remote" && _transactGate?.(tab, origin)) {
|
|
236
211
|
return false;
|
|
237
212
|
}
|
|
213
|
+
const writer = actor ?? _scopeActor;
|
|
214
|
+
/* A remote collaborator's ops are the room's, not a local writer's: they skip history and are
|
|
215
|
+
never published back, so they neither join a lane nor close one. */
|
|
216
|
+
if (origin !== "remote") {
|
|
217
|
+
closeForeignSegment(tab, writer);
|
|
218
|
+
}
|
|
238
219
|
const selectionBefore = cloneSelection(tab.session.selection);
|
|
239
220
|
const dirtyBefore = tab.doc.dirty;
|
|
240
221
|
beginRecording();
|
|
@@ -287,8 +268,13 @@ export function transactDoc(
|
|
|
287
268
|
}
|
|
288
269
|
}
|
|
289
270
|
|
|
290
|
-
if (!skipHistory
|
|
291
|
-
|
|
271
|
+
if (!skipHistory) {
|
|
272
|
+
const lane = writer ? laneOwnedBy(writer) : null;
|
|
273
|
+
if (lane) {
|
|
274
|
+
joinSegment(lane, tab, record, selectionBefore);
|
|
275
|
+
} else {
|
|
276
|
+
pushHistoryEntry(tab, raw, record, selectionBefore, coalesceKey);
|
|
277
|
+
}
|
|
292
278
|
}
|
|
293
279
|
|
|
294
280
|
tab.doc.dirty = true;
|
|
@@ -404,7 +390,7 @@ function materializeState(
|
|
|
404
390
|
export function transact(
|
|
405
391
|
tab: Tab | null,
|
|
406
392
|
fn: (doc: JxMutableNode) => void,
|
|
407
|
-
opts?:
|
|
393
|
+
opts?: TransactOptions,
|
|
408
394
|
): boolean {
|
|
409
395
|
return transactDoc(tab, (t) => fn(t.doc.document), opts);
|
|
410
396
|
}
|
|
@@ -431,6 +417,63 @@ export function applyExternalDocOps(tab: Tab, ops: JxDocOp[]) {
|
|
|
431
417
|
);
|
|
432
418
|
}
|
|
433
419
|
|
|
420
|
+
/** What {@link applyDocOpsAsUser} did: wrote, had nothing to write, or was refused by the gate. */
|
|
421
|
+
export type ApplyDocOpsResult = "applied" | "unchanged" | "refused";
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Apply ops a tool PRODUCED (rather than a mutator performing the edit itself) as one ordinary
|
|
425
|
+
* transaction: history, lanes, the gate, the canvas patch and the collab observer all see it as
|
|
426
|
+
* they see any edit. Each op's inverse is computed against the raw document just before that op
|
|
427
|
+
* lands, so a later op may address what an earlier one created, and the selection rule the producer
|
|
428
|
+
* named is applied inside the same transaction, so a rollback restores it too.
|
|
429
|
+
*
|
|
430
|
+
* Empty ops are no transaction at all: no new root, no history entry, nothing published
|
|
431
|
+
* (specs/ai.md §3.9). An op that cannot apply throws from `inverseOf` before it lands, and
|
|
432
|
+
* `transactDoc` rolls back the ones before it and rethrows.
|
|
433
|
+
*
|
|
434
|
+
* @param {Tab} tab
|
|
435
|
+
* @param {readonly JxDocOp[]} ops
|
|
436
|
+
* @param {{ actor?: ActorRef | null; selection?: SelectionEffect }} [options]
|
|
437
|
+
* @returns {ApplyDocOpsResult}
|
|
438
|
+
*/
|
|
439
|
+
export function applyDocOpsAsUser(
|
|
440
|
+
tab: Tab,
|
|
441
|
+
ops: readonly JxDocOp[],
|
|
442
|
+
{
|
|
443
|
+
actor = null,
|
|
444
|
+
selection = { kind: "keep" },
|
|
445
|
+
}: { actor?: ActorRef | null; selection?: SelectionEffect } = {},
|
|
446
|
+
): ApplyDocOpsResult {
|
|
447
|
+
if (ops.length === 0) {
|
|
448
|
+
return "unchanged";
|
|
449
|
+
}
|
|
450
|
+
const applied = transactDoc(
|
|
451
|
+
tab,
|
|
452
|
+
(t) => {
|
|
453
|
+
for (const op of ops) {
|
|
454
|
+
applyDocOp(t, op, inverseOf(toRaw(t.doc.document), op));
|
|
455
|
+
}
|
|
456
|
+
applySelectionEffect(t, selection);
|
|
457
|
+
},
|
|
458
|
+
{ actor },
|
|
459
|
+
);
|
|
460
|
+
return applied ? "applied" : "refused";
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Carry the tab's selection across an edit, as the producer's {@link SelectionEffect} says. */
|
|
464
|
+
function applySelectionEffect(tab: Tab, effect: SelectionEffect) {
|
|
465
|
+
if (effect.kind === "prune") {
|
|
466
|
+
for (const under of effect.under) {
|
|
467
|
+
tab.session.selection = pruneSelection(tab.session.selection, under);
|
|
468
|
+
}
|
|
469
|
+
} else if (
|
|
470
|
+
effect.kind === "follow" &&
|
|
471
|
+
pathsEqual(primarySelection(tab.session.selection), effect.from)
|
|
472
|
+
) {
|
|
473
|
+
tab.session.selection = [[...effect.to]];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
434
477
|
// ─── Document-op application ─────────────────────────────────────────────────
|
|
435
478
|
|
|
436
479
|
/** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
|
|
@@ -442,8 +485,10 @@ const DOC_META_KEYS = new Set(["state", "$media", "$head", "$elements", "imports
|
|
|
442
485
|
*
|
|
443
486
|
* @param {Tab} tab
|
|
444
487
|
* @param {JxDocOp} op
|
|
488
|
+
* @param {JxDocOp} inverse - The op that undoes this one: replay passes none (see below), a new
|
|
489
|
+
* edit its real inverse
|
|
445
490
|
*/
|
|
446
|
-
function applyDocOp(tab: Tab, op: JxDocOp) {
|
|
491
|
+
function applyDocOp(tab: Tab, op: JxDocOp, inverse: JxDocOp = op) {
|
|
447
492
|
let prev: JxDocumentValue | undefined | null = null;
|
|
448
493
|
if (op.op === "set-key") {
|
|
449
494
|
const node = getNodeAtPath(tab.doc.document, op.path);
|
|
@@ -456,10 +501,10 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
|
|
|
456
501
|
// Classified as applicable (suppressing the full render), and the canvas silently kept showing
|
|
457
502
|
// The pre-undo content. Replaying an op IS a forward op — record it as one.
|
|
458
503
|
//
|
|
459
|
-
//
|
|
504
|
+
// On replay history never reads the inverse: undo/redo run with `skipHistory`, and the entry they
|
|
460
505
|
// Replay already holds the pair. `markNonInvertible` would be wrong (it forces a whole-document
|
|
461
|
-
// Checkpoint), so
|
|
462
|
-
recordDocOp({ forward: op, inverse
|
|
506
|
+
// Checkpoint), so a replayed op is paired with itself.
|
|
507
|
+
recordDocOp({ forward: op, inverse });
|
|
463
508
|
switch (op.op) {
|
|
464
509
|
case "set-key": {
|
|
465
510
|
if (DOC_META_KEYS.has(op.key)) {
|
|
@@ -504,11 +549,78 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
|
|
|
504
549
|
}
|
|
505
550
|
}
|
|
506
551
|
|
|
507
|
-
// ─── Batch (
|
|
552
|
+
// ─── Batch lanes (one actor's run of writes, one undo step per document) ─────
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* An actor that is not the person at the keyboard writing to a document. The assistant's
|
|
556
|
+
* `@jxsuite/ai` `Actor` is one: its `id` is `assistant:<sessionId|local>:<turnId>`.
|
|
557
|
+
*/
|
|
558
|
+
export interface ActorRef {
|
|
559
|
+
readonly kind: "user" | "assistant" | "remote";
|
|
560
|
+
readonly id: string;
|
|
561
|
+
readonly turnId?: string;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The options {@link transactDoc} takes. */
|
|
565
|
+
export interface TransactOptions {
|
|
566
|
+
skipHistory?: boolean;
|
|
567
|
+
origin?: TransactOrigin;
|
|
568
|
+
coalesceKey?: string | null;
|
|
569
|
+
/** Who is writing; `null` (the default) is the person at the keyboard, or the open scope's. */
|
|
570
|
+
actor?: ActorRef | null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* One actor's run of writes that undoes as ONE step.
|
|
575
|
+
*
|
|
576
|
+
* A lane is open for a whole assistant turn. Each document it writes gets a **segment**, opened on
|
|
577
|
+
* the lane's first write there and closed when the lane flushes, or earlier when anyone else writes
|
|
578
|
+
* that document: the segment becomes one history entry and the other writer's edit becomes its own
|
|
579
|
+
* after it, so a person's edit is never folded into the assistant's step and the steps keep the
|
|
580
|
+
* order they happened in. A document the lane never wrote gets no entry at all.
|
|
581
|
+
*/
|
|
582
|
+
export interface BatchLane {
|
|
583
|
+
readonly owner: ActorRef;
|
|
584
|
+
/** Close every open segment now, one entry per document that recorded something. */
|
|
585
|
+
readonly flush: () => void;
|
|
586
|
+
/** Flush and retire the lane; returns where each of its entries landed, oldest first. */
|
|
587
|
+
readonly close: () => readonly BatchReceipt[];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Where one of a lane's segments landed in its tab's history. */
|
|
591
|
+
export interface BatchReceipt {
|
|
592
|
+
readonly tab: Tab;
|
|
593
|
+
/**
|
|
594
|
+
* The history entry the segment became, compared by identity: its index moves when the history
|
|
595
|
+
* limit drops the oldest entry, the entry does not. Null on a tab whose undo a history delegate
|
|
596
|
+
* owns (a shared live document), where the step is the delegate's and no entry names it.
|
|
597
|
+
*/
|
|
598
|
+
readonly entry: HistorySnapshot | null;
|
|
599
|
+
readonly turnId: string;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
interface LaneState {
|
|
603
|
+
readonly owner: ActorRef;
|
|
604
|
+
readonly receipts: BatchReceipt[];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
interface Segment {
|
|
608
|
+
readonly lane: LaneState;
|
|
609
|
+
readonly docOps: JxDocOpPair[];
|
|
610
|
+
readonly fmOps: TransactionRecord["fmOps"];
|
|
611
|
+
/** False once any write in it could not be inverted, or recorded no ops: the entry snapshots. */
|
|
612
|
+
invertible: boolean;
|
|
613
|
+
readonly selectionBefore: JxPath[];
|
|
614
|
+
}
|
|
508
615
|
|
|
509
|
-
|
|
616
|
+
/** The open lanes, by owner id. */
|
|
617
|
+
const _lanes = new Map<string, LaneState>();
|
|
618
|
+
/** The open segment on each tab: at most one, since a second writer closes the first's. */
|
|
619
|
+
const _segments = new Map<Tab, Segment>();
|
|
620
|
+
/** The actor {@link runAsActor} is running as, for a writer that cannot pass one. */
|
|
621
|
+
let _scopeActor: ActorRef | null = null;
|
|
510
622
|
|
|
511
|
-
/** Module-level hook invoked when a
|
|
623
|
+
/** Module-level hook invoked when a segment closes (collab flushes its buffered ops as one step). */
|
|
512
624
|
export type BatchEndNotifier = (tab: Tab) => void;
|
|
513
625
|
|
|
514
626
|
let _batchEndNotifier: BatchEndNotifier | null = null;
|
|
@@ -517,48 +629,132 @@ export function setBatchEndNotifier(fn: BatchEndNotifier | null) {
|
|
|
517
629
|
_batchEndNotifier = fn;
|
|
518
630
|
}
|
|
519
631
|
|
|
520
|
-
|
|
521
|
-
|
|
632
|
+
/**
|
|
633
|
+
* Open a lane for `owner`. Every transaction `owner` makes until {@link BatchLane.close} joins it;
|
|
634
|
+
* opening a second lane for the same owner replaces the first, after flushing it.
|
|
635
|
+
*/
|
|
636
|
+
export function openBatchLane(owner: ActorRef): BatchLane {
|
|
637
|
+
const previous = _lanes.get(owner.id);
|
|
638
|
+
if (previous) {
|
|
639
|
+
flushLane(previous);
|
|
640
|
+
}
|
|
641
|
+
const lane: LaneState = { owner, receipts: [] };
|
|
642
|
+
_lanes.set(owner.id, lane);
|
|
643
|
+
return {
|
|
644
|
+
owner,
|
|
645
|
+
flush: () => flushLane(lane),
|
|
646
|
+
close: () => {
|
|
647
|
+
flushLane(lane);
|
|
648
|
+
if (_lanes.get(owner.id) === lane) {
|
|
649
|
+
_lanes.delete(owner.id);
|
|
650
|
+
}
|
|
651
|
+
return [...lane.receipts];
|
|
652
|
+
},
|
|
653
|
+
};
|
|
522
654
|
}
|
|
523
655
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
656
|
+
/**
|
|
657
|
+
* Run `fn` with every transaction it makes attributed to `actor`.
|
|
658
|
+
*
|
|
659
|
+
* **Synchronous only.** The scope ends when `fn` returns, before any await inside it resumes, so a
|
|
660
|
+
* write made after an await is the person's again. That is deliberate: a scope that outlived its
|
|
661
|
+
* call would attribute whatever the person did in the meantime to the assistant. A writer that
|
|
662
|
+
* awaits before it writes passes `actor` to {@link transactDoc} instead.
|
|
663
|
+
*/
|
|
664
|
+
export function runAsActor<T>(actor: ActorRef, fn: () => T): T {
|
|
665
|
+
const previous = _scopeActor;
|
|
666
|
+
_scopeActor = actor;
|
|
667
|
+
try {
|
|
668
|
+
return fn();
|
|
669
|
+
} finally {
|
|
670
|
+
_scopeActor = previous;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Whether `tab` has an open segment: owned by `actor` when one is given, by anyone otherwise.
|
|
676
|
+
* Collab reads this to buffer the owner's ops until the segment closes.
|
|
677
|
+
*/
|
|
678
|
+
export function isBatched(tab: Tab, actor?: ActorRef | null): boolean {
|
|
679
|
+
const segment = _segments.get(tab);
|
|
680
|
+
return segment !== undefined && (!actor || segment.lane.owner.id === actor.id);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Close every open segment of every lane (a project is about to be replaced under them). */
|
|
684
|
+
export function flushOpenBatches(): void {
|
|
685
|
+
for (const lane of _lanes.values()) {
|
|
686
|
+
flushLane(lane);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function laneOwnedBy(actor: ActorRef): LaneState | null {
|
|
691
|
+
return _lanes.get(actor.id) ?? null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function flushLane(lane: LaneState): void {
|
|
695
|
+
// Deleting the entry being visited is safe: a Map iterator skips removed entries and goes on.
|
|
696
|
+
for (const [tab, segment] of _segments) {
|
|
697
|
+
if (segment.lane === lane) {
|
|
698
|
+
closeSegment(tab, segment);
|
|
537
699
|
}
|
|
538
|
-
tab.history.snapshots = truncated;
|
|
539
|
-
tab.history.index = truncated.length - 1;
|
|
540
700
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Fold a lane owner's transaction into its segment on `tab`, opening the segment if needed. */
|
|
704
|
+
function joinSegment(
|
|
705
|
+
lane: LaneState,
|
|
706
|
+
tab: Tab,
|
|
707
|
+
record: TransactionRecord,
|
|
708
|
+
selectionBefore: JxPath[],
|
|
709
|
+
): void {
|
|
710
|
+
let segment = _segments.get(tab);
|
|
711
|
+
if (!segment) {
|
|
712
|
+
segment = { docOps: [], fmOps: [], invertible: true, lane, selectionBefore };
|
|
713
|
+
_segments.set(tab, segment);
|
|
714
|
+
}
|
|
715
|
+
segment.docOps.push(...record.docOps);
|
|
716
|
+
segment.fmOps.push(...record.fmOps);
|
|
717
|
+
/* An unrecorded mutation changed the document without saying how, so only a snapshot of the
|
|
718
|
+
result can undo the segment it is in. */
|
|
719
|
+
if (!record.invertible || (record.docOps.length === 0 && record.fmOps.length === 0)) {
|
|
720
|
+
segment.invertible = false;
|
|
544
721
|
}
|
|
545
722
|
}
|
|
546
723
|
|
|
547
|
-
|
|
548
|
-
|
|
724
|
+
/** A writer other than the segment's owner is about to write `tab`: close the segment first. */
|
|
725
|
+
function closeForeignSegment(tab: Tab, writer: ActorRef | null): void {
|
|
726
|
+
const segment = _segments.get(tab);
|
|
727
|
+
if (segment && segment.lane.owner.id !== writer?.id) {
|
|
728
|
+
closeSegment(tab, segment);
|
|
729
|
+
}
|
|
549
730
|
}
|
|
550
731
|
|
|
551
|
-
/**
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
732
|
+
/** Push the segment as one history entry tagged with its owner, then let collab publish it. */
|
|
733
|
+
function closeSegment(tab: Tab, segment: Segment): void {
|
|
734
|
+
_segments.delete(tab);
|
|
735
|
+
const { owner } = segment.lane;
|
|
736
|
+
const turnId = owner.turnId ?? "";
|
|
737
|
+
/* A history delegate owns undo grouping while registered; an entry here would be dead weight.
|
|
738
|
+
The receipt is still issued, with no entry, so Restore can tell a step it cannot check from a
|
|
739
|
+
document the turn never wrote. */
|
|
740
|
+
if (_historyDelegates.has(tab)) {
|
|
741
|
+
segment.lane.receipts.push({ entry: null, tab, turnId });
|
|
742
|
+
} else {
|
|
743
|
+
const record: TransactionRecord = {
|
|
744
|
+
docOps: segment.docOps,
|
|
745
|
+
fmOps: segment.fmOps,
|
|
746
|
+
invertible: segment.invertible,
|
|
747
|
+
ops: [],
|
|
748
|
+
};
|
|
749
|
+
pushHistoryEntry(tab, toRaw(tab.doc.document), record, segment.selectionBefore);
|
|
750
|
+
const entry = tab.history.snapshots[tab.history.index]!;
|
|
751
|
+
entry.actor = owner.id;
|
|
752
|
+
if (owner.turnId !== undefined) {
|
|
753
|
+
entry.turnId = owner.turnId;
|
|
754
|
+
}
|
|
755
|
+
segment.lane.receipts.push({ entry, tab, turnId });
|
|
756
|
+
}
|
|
757
|
+
_batchEndNotifier?.(tab);
|
|
562
758
|
}
|
|
563
759
|
|
|
564
760
|
// ─── Undo / Redo ─────────────────────────────────────────────────────────────
|
|
@@ -591,12 +787,17 @@ export function getHistoryDelegate(tab: Tab): HistoryDelegate | null {
|
|
|
591
787
|
|
|
592
788
|
export function canUndo(tab: Tab): boolean {
|
|
593
789
|
const delegate = _historyDelegates.get(tab);
|
|
594
|
-
|
|
790
|
+
// An open segment is a step that has not been pushed yet; undo closes it and then takes it back.
|
|
791
|
+
return delegate ? delegate.canUndo(tab) : tab.history.index > 0 || _segments.has(tab);
|
|
595
792
|
}
|
|
596
793
|
|
|
597
794
|
export function canRedo(tab: Tab): boolean {
|
|
598
795
|
const delegate = _historyDelegates.get(tab);
|
|
599
|
-
|
|
796
|
+
/* An open segment has already changed the document past the redo stack, and closing it truncates
|
|
797
|
+
that stack, so there is nothing to redo while one is open. */
|
|
798
|
+
return delegate
|
|
799
|
+
? delegate.canRedo(tab)
|
|
800
|
+
: !_segments.has(tab) && tab.history.index < tab.history.snapshots.length - 1;
|
|
600
801
|
}
|
|
601
802
|
|
|
602
803
|
/** Restore a materialized snapshot state (full-render path). */
|
|
@@ -627,6 +828,10 @@ function assertHistoryConsistency(tab: Tab, index: number) {
|
|
|
627
828
|
|
|
628
829
|
/** @param {Tab} tab */
|
|
629
830
|
export function undo(tab: Tab) {
|
|
831
|
+
/* Undo is the person's, so a segment open on this tab closes first and becomes the step undo
|
|
832
|
+
takes back. Without this, ⌘Z mid-turn would skip the assistant's pending edits and undo the
|
|
833
|
+
entry before them, leaving the segment's inverses pointing at a document they no longer fit. */
|
|
834
|
+
closeForeignSegment(tab, null);
|
|
630
835
|
const delegate = _historyDelegates.get(tab);
|
|
631
836
|
if (delegate) {
|
|
632
837
|
delegate.undo(tab);
|
|
@@ -678,6 +883,8 @@ export function undo(tab: Tab) {
|
|
|
678
883
|
|
|
679
884
|
/** @param {Tab} tab */
|
|
680
885
|
export function redo(tab: Tab) {
|
|
886
|
+
// Same as {@link undo}: an open segment is history the redo stack is about to be measured against.
|
|
887
|
+
closeForeignSegment(tab, null);
|
|
681
888
|
const delegate = _historyDelegates.get(tab);
|
|
682
889
|
if (delegate) {
|
|
683
890
|
delegate.redo(tab);
|
|
@@ -740,11 +947,16 @@ export function mutateInsertNode(
|
|
|
740
947
|
if (!parent) {
|
|
741
948
|
return;
|
|
742
949
|
}
|
|
743
|
-
childArray(parent)
|
|
744
|
-
|
|
950
|
+
const children = childArray(parent);
|
|
951
|
+
/* Recorded at the slot the node lands in, not the one asked for: `splice` appends past the end,
|
|
952
|
+
and an op naming index 9 of a two-child list replays wrongly anywhere that does not clamp (the
|
|
953
|
+
collab mirror throws on it) and undoes by removing nothing. */
|
|
954
|
+
const slot = insertionSlot(index, children.length);
|
|
955
|
+
children.splice(slot, 0, structuredClone(nodeDef));
|
|
956
|
+
recordPatch({ index: slot, op: "insert", parentPath });
|
|
745
957
|
recordDocOp({
|
|
746
|
-
forward: { index, node: cloneValue(nodeDef), op: "insert-child", parentPath },
|
|
747
|
-
inverse: { index, op: "remove-child", parentPath },
|
|
958
|
+
forward: { index: slot, node: cloneValue(nodeDef), op: "insert-child", parentPath },
|
|
959
|
+
inverse: { index: slot, op: "remove-child", parentPath },
|
|
748
960
|
});
|
|
749
961
|
}
|
|
750
962
|
|
|
@@ -905,50 +1117,26 @@ export function mutateWrapNode(tab: Tab, path: JxPath, wrapperTag = "div") {
|
|
|
905
1117
|
*/
|
|
906
1118
|
export function mutateMoveNode(tab: Tab, fromPath: JxPath, toParentPath: JxPath, toIndex: number) {
|
|
907
1119
|
const doc = tab.doc.document;
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
if (!fromParentPath || !isNumber(fromIdx)) {
|
|
911
|
-
return;
|
|
912
|
-
}
|
|
913
|
-
const fromParent = getNodeAtPath(doc, fromParentPath);
|
|
914
|
-
const toParent = getNodeAtPath(doc, toParentPath);
|
|
915
|
-
if (!fromParent || !Array.isArray(fromParent.children) || !toParent) {
|
|
1120
|
+
const move = planMove(doc, fromPath, toParentPath, toIndex);
|
|
1121
|
+
if (!move) {
|
|
916
1122
|
return;
|
|
917
1123
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
fromParentPath,
|
|
928
|
-
op: "move-child",
|
|
929
|
-
toIndex: adjustedIndex,
|
|
930
|
-
toParentPath,
|
|
931
|
-
};
|
|
932
|
-
/* Computed BEFORE the splice, against the document the move applies to. The inverse runs against
|
|
933
|
-
the post-move document, so `inverseOf` writes both of its parent paths in post-move
|
|
934
|
-
coordinates: the removal shifted paths under the source parent, the insertion paths under the
|
|
935
|
-
target. One implementation of that rule, shared with every other host that moves a node. */
|
|
936
|
-
const inverse = inverseOf(doc, forward);
|
|
937
|
-
const [node] = fromParent.children.splice(fromIdx, 1);
|
|
938
|
-
childArray(toParent).splice(adjustedIndex, 0, node!);
|
|
939
|
-
recordPatch({ fromPath, op: "move", toIndex: adjustedIndex, toParentPath });
|
|
1124
|
+
const { forward, inverse } = move;
|
|
1125
|
+
/* SAFETY: `planMove` resolved both parents as nodes, and the source's children as a static
|
|
1126
|
+
array holding a child at `fromIndex`. */
|
|
1127
|
+
const fromParent = getNodeAtPath(doc, forward.fromParentPath)!;
|
|
1128
|
+
const toParent = getNodeAtPath(doc, toParentPath)!;
|
|
1129
|
+
// SAFETY: as above, the source parent's children are a static array (`planMove` checked).
|
|
1130
|
+
const [node] = (fromParent.children as (JxMutableNode | string)[]).splice(forward.fromIndex, 1);
|
|
1131
|
+
childArray(toParent).splice(forward.toIndex, 0, node!);
|
|
1132
|
+
recordPatch({ fromPath, op: "move", toIndex: forward.toIndex, toParentPath });
|
|
940
1133
|
recordDocOp({ forward, inverse });
|
|
941
1134
|
|
|
1135
|
+
/* The moved node's own post-move path is where its inverse picks it up. Writing the target path
|
|
1136
|
+
as given would be wrong whenever the removal renumbered it: moving `children/0` into
|
|
1137
|
+
`children/2` lands inside what is now `children/1`. */
|
|
942
1138
|
if (pathsEqual(primarySelection(tab.session.selection), fromPath)) {
|
|
943
|
-
|
|
944
|
-
if (
|
|
945
|
-
fromParentPath.length === toParentPath.length &&
|
|
946
|
-
fromParentPath.every((v, i) => v === toParentPath[i]) &&
|
|
947
|
-
fromIdx < toIndex
|
|
948
|
-
) {
|
|
949
|
-
idx = toIndex - 1;
|
|
950
|
-
}
|
|
951
|
-
tab.session.selection = [[...toParentPath, "children", idx]];
|
|
1139
|
+
tab.session.selection = [[...inverse.fromParentPath, "children", inverse.fromIndex]];
|
|
952
1140
|
}
|
|
953
1141
|
}
|
|
954
1142
|
|
|
@@ -984,6 +1172,10 @@ export function mutateUpdateProperty(tab: Tab, path: JxPath, key: string, value?
|
|
|
984
1172
|
node[key] = value;
|
|
985
1173
|
}
|
|
986
1174
|
recordDocOp(setKeyPair(path, key, before, cloneValue(node[key])));
|
|
1175
|
+
// Replacing the children replaces every node under them, so nothing selected there survives.
|
|
1176
|
+
if (key === "children") {
|
|
1177
|
+
tab.session.selection = pruneSelection(tab.session.selection, [...path, "children"]);
|
|
1178
|
+
}
|
|
987
1179
|
if (key === "textContent") {
|
|
988
1180
|
recordPatch({ op: "set-text", path });
|
|
989
1181
|
} else if (key === "style") {
|