@jxsuite/studio 0.36.0 → 0.37.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Reactive per-tab collab state, kept OUTSIDE the Tab type: UI reads it through collabState(tab),
3
+ * and non-collab builds never touch it. The path registry answers "is this file co-edited?" for the
4
+ * fs-sync/reload guards without importing the session machinery.
5
+ */
6
+
7
+ import { reactive } from "../reactivity";
8
+ import type { Tab } from "../tabs/tab";
9
+ import type { CollabAwarenessState } from "@jxsuite/collab/awareness-types";
10
+
11
+ export type CollabTabStatus = "detached" | "connecting" | "synced" | "offline";
12
+
13
+ export interface PeerPresence {
14
+ clientId: number;
15
+ state: CollabAwarenessState;
16
+ }
17
+
18
+ export interface TabCollabState {
19
+ status: CollabTabStatus;
20
+ /** True once a session is attached and past its initial sync. */
21
+ active: boolean;
22
+ readOnly: boolean;
23
+ /** Other clients' awareness states (this project connection, all docs). */
24
+ peers: PeerPresence[];
25
+ /**
26
+ * True while source holds the canonical lock (someone is co-editing the code view): structural
27
+ * surfaces soft-freeze and the canvas previews the source reconciler's parses.
28
+ */
29
+ sourceCanonical: boolean;
30
+ }
31
+
32
+ const states = new WeakMap<Tab, TabCollabState>();
33
+ const attachedPaths = new Map<string, number>();
34
+
35
+ export function collabState(tab: Tab): TabCollabState {
36
+ let state = states.get(tab);
37
+ if (!state) {
38
+ state = reactive({
39
+ active: false,
40
+ peers: [],
41
+ readOnly: false,
42
+ sourceCanonical: false,
43
+ status: "detached",
44
+ }) as TabCollabState;
45
+ states.set(tab, state);
46
+ }
47
+ return state;
48
+ }
49
+
50
+ export function isCollabActive(tab: Tab): boolean {
51
+ return states.get(tab)?.active === true;
52
+ }
53
+
54
+ /** Track which project-relative paths have live sessions (fs-event/reload suppression). */
55
+ export function registerCollabPath(path: string): void {
56
+ attachedPaths.set(path, (attachedPaths.get(path) ?? 0) + 1);
57
+ }
58
+
59
+ export function unregisterCollabPath(path: string): void {
60
+ const count = attachedPaths.get(path) ?? 0;
61
+ if (count <= 1) {
62
+ attachedPaths.delete(path);
63
+ } else {
64
+ attachedPaths.set(path, count - 1);
65
+ }
66
+ }
67
+
68
+ /** True while some tab co-edits this path — its Y.Doc is ahead of any provider write-back. */
69
+ export function isCollabPath(path: string): boolean {
70
+ return attachedPaths.has(path.replaceAll("\\", "/"));
71
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Remote cursor colors for y-monaco. MonacoBinding decorates other clients' text cursors with
3
+ * per-client class names (`yRemoteSelection-<id>`, `yRemoteSelectionHead-<id>`) but ships no
4
+ * styling — this module maintains one injected <style> element with a rule set per remote client,
5
+ * colored from the presence palette (`user.color`) and labeled with the author's name on hover of
6
+ * the cursor head.
7
+ */
8
+
9
+ /** The slice of y-protocols Awareness the style manager uses (duck-typed; stays yjs-free). */
10
+ export interface AwarenessLike {
11
+ clientID: number;
12
+ getStates: () => Map<number, unknown>;
13
+ on: (event: "change", cb: () => void) => void;
14
+ off: (event: "change", cb: () => void) => void;
15
+ }
16
+
17
+ interface PeerStateShape {
18
+ user?: { color?: string; name?: string; login?: string };
19
+ }
20
+
21
+ /** CSS-escape for the content string (names come from GitHub logins/display names). */
22
+ function cssString(value: string): string {
23
+ return `"${value.replaceAll("\\", String.raw`\\`).replaceAll('"', String.raw`\"`)}"`;
24
+ }
25
+
26
+ /** One client's rules: translucent selection band, solid caret, name flag above the caret. */
27
+ export function cursorRulesFor(clientId: number, color: string, label: string): string {
28
+ return [
29
+ `.yRemoteSelection-${clientId}{background-color:${color}44;}`,
30
+ `.yRemoteSelectionHead-${clientId}{position:absolute;border-left:2px solid ${color};height:100%;box-sizing:border-box;}`,
31
+ `.yRemoteSelectionHead-${clientId}::after{content:${cssString(label)};position:absolute;top:-1.2em;left:-2px;padding:0 4px;border-radius:3px 3px 3px 0;font:10px/1.4 sans-serif;color:#fff;white-space:nowrap;background-color:${color};opacity:0;transition:opacity .15s;pointer-events:none;}`,
32
+ `.yRemoteSelectionHead-${clientId}:hover::after{opacity:1;}`,
33
+ ].join("\n");
34
+ }
35
+
36
+ /** Full stylesheet text for every remote client with a presence identity. */
37
+ export function cursorStylesheet(awareness: AwarenessLike): string {
38
+ const rules: string[] = [];
39
+ for (const [clientId, raw] of awareness.getStates()) {
40
+ if (clientId === awareness.clientID) {
41
+ continue;
42
+ }
43
+ const { user } = raw as PeerStateShape;
44
+ if (!user?.color) {
45
+ continue;
46
+ }
47
+ rules.push(cursorRulesFor(clientId, user.color, user.name ?? user.login ?? String(clientId)));
48
+ }
49
+ return rules.join("\n");
50
+ }
51
+
52
+ /**
53
+ * Keep an injected <style> element in sync with the awareness roster while a code view is bound.
54
+ * Returns the disposer (removes the element and the listener).
55
+ */
56
+ export function attachCursorStyles(awareness: AwarenessLike, doc: Document): () => void {
57
+ const style = doc.createElement("style");
58
+ style.dataset["jxCollabCursors"] = "true";
59
+ doc.head.append(style);
60
+ const refresh = () => {
61
+ style.textContent = cursorStylesheet(awareness);
62
+ };
63
+ refresh();
64
+ awareness.on("change", refresh);
65
+ return () => {
66
+ awareness.off("change", refresh);
67
+ style.remove();
68
+ };
69
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Presence chips — who else is in this co-editing session, plus the sync-status pill that replaces
3
+ * the dirty dot for collab tabs. Pure lit templates over collabState(tab); the toolbar includes
4
+ * them and its render effect tracks the underlying reactive state.
5
+ */
6
+
7
+ import { html, nothing } from "lit-html";
8
+ import type { TemplateResult } from "lit-html";
9
+ import { collabState } from "./collab-state";
10
+ import type { PeerPresence } from "./collab-state";
11
+ import type { Tab } from "../tabs/tab";
12
+
13
+ function initialOf(peer: PeerPresence): string {
14
+ const name = peer.state.user.name ?? peer.state.user.login;
15
+ return (name[0] ?? "?").toUpperCase();
16
+ }
17
+
18
+ function titleOf(peer: PeerPresence, docPath: string | null): string {
19
+ const { user } = peer.state;
20
+ const who = user.name ? `${user.name} (${user.login})` : user.login;
21
+ const here = peer.state.focusedPath && peer.state.focusedPath === docPath;
22
+ return here ? who : `${who} — ${peer.state.focusedPath ?? "browsing"}`;
23
+ }
24
+
25
+ const STATUS_LABEL: Record<string, string> = {
26
+ connecting: "Connecting…",
27
+ offline: "Offline — changes sync on reconnect",
28
+ synced: "Live",
29
+ };
30
+
31
+ /** Chips + status pill for the toolbar; `nothing` while the tab isn't co-editing. */
32
+ export function presenceChipsTemplate(tab: Tab | null): TemplateResult | typeof nothing {
33
+ if (!tab) {
34
+ return nothing;
35
+ }
36
+ const state = collabState(tab);
37
+ if (state.status === "detached") {
38
+ return nothing;
39
+ }
40
+ const label = STATUS_LABEL[state.status] ?? state.status;
41
+ return html`
42
+ <div class="jx-presence" title=${label}>
43
+ <span class="jx-presence-status" data-status=${state.status}>${label}</span>
44
+ ${state.peers.map(
45
+ (peer) => html`
46
+ <span
47
+ class="jx-presence-chip"
48
+ style="background:${peer.state.user.color}"
49
+ title=${titleOf(peer, tab.documentPath)}
50
+ >${peer.state.user.avatarUrl
51
+ ? html`<img src=${peer.state.user.avatarUrl} alt=${initialOf(peer)} />`
52
+ : initialOf(peer)}</span
53
+ >
54
+ `,
55
+ )}
56
+ </div>
57
+ `;
58
+ }
@@ -13,6 +13,7 @@ import { statusMessage } from "../panels/statusbar";
13
13
  import { validateComponentSlots } from "../services/cem-export";
14
14
  import { getPlatform } from "../platform";
15
15
  import { activeTab, openTab } from "../workspace/workspace";
16
+ import { collabSave } from "../collab/collab-session";
16
17
  import { isEditing, stopEditing } from "../editor/inline-edit";
17
18
  import {
18
19
  defaultContentFormat,
@@ -162,6 +163,11 @@ export async function saveFile() {
162
163
  return;
163
164
  }
164
165
  try {
166
+ // A co-edited tab persists through its provider (a direct file write would reset the room).
167
+ if (await collabSave(tab)) {
168
+ savedMessage(tab, "Synced");
169
+ return;
170
+ }
165
171
  const output = await serializeDocument(tab);
166
172
 
167
173
  if (tab.documentPath) {
@@ -20,6 +20,7 @@ import { ensureDependenciesInstalled } from "../packages/ensure-deps";
20
20
  import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
21
21
  import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
22
22
  import { markLocalMutation } from "./fs-events";
23
+ import { isCollabPath } from "../collab/collab-state";
23
24
  import {
24
25
  draggable,
25
26
  dropTargetForElements,
@@ -1078,6 +1079,11 @@ export async function openFileInTab(path: string) {
1078
1079
  */
1079
1080
  /** Reload an open tab from disk when an external change arrives — but only if it is not dirty. */
1080
1081
  export function reloadCleanTab(path: string): void {
1082
+ // Co-edited docs never reload from disk: the shared Y.Doc is ahead of the provider's write-back
1083
+ // (Which is what produced this event), and genuine external changes arrive as a collab reset.
1084
+ if (isCollabPath(path)) {
1085
+ return;
1086
+ }
1081
1087
  for (const [, tab] of workspace.tabs.entries()) {
1082
1088
  if (tab.documentPath === path && !tab.doc.dirty) {
1083
1089
  void reloadFileInTab(path);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { html, nothing } from "lit-html";
8
8
  import { errorMessage } from "@jxsuite/schema/parse";
9
+ import { flushAllCollab } from "../collab/collab-session";
9
10
  import type {
10
11
  GitBranchesResult,
11
12
  GitDiffState,
@@ -263,6 +264,9 @@ export function renderGitPanel(
263
264
  return;
264
265
  }
265
266
  updateUi("gitCommitMessage", "");
267
+ // Fold co-editing sessions into the backend's tree first so the commit never misses
268
+ // Trailing keystrokes (the mirror is debounced).
269
+ await flushAllCollab();
266
270
  await gitAction("gitCommit", msg);
267
271
  };
268
272
 
@@ -275,6 +279,7 @@ export function renderGitPanel(
275
279
  updateUi("gitCommitMessage", "");
276
280
  updateUi("gitLoading", true);
277
281
  updateUi("gitError", null);
282
+ await flushAllCollab();
278
283
  const plat = getPlatform();
279
284
  try {
280
285
  await plat.gitCommit(msg);
@@ -7,7 +7,14 @@
7
7
  import { html, render as litRender, nothing } from "lit-html";
8
8
  import { openPublishPanel } from "../publish/publish-panel";
9
9
  import { updateSession } from "../store";
10
- import { redo as tabRedo, undo as tabUndo } from "../tabs/transact";
10
+ import {
11
+ canRedo as tabCanRedo,
12
+ canUndo as tabCanUndo,
13
+ redo as tabRedo,
14
+ undo as tabUndo,
15
+ } from "../tabs/transact";
16
+ import { collabState } from "../collab/collab-state";
17
+ import { presenceChipsTemplate } from "../collab/presence-chips";
11
18
  import { effect, effectScope } from "../reactivity";
12
19
  import { activeTab } from "../workspace/workspace";
13
20
  import { applyPanelCollapse, view } from "../view";
@@ -101,6 +108,8 @@ export function mount(rootEl: HTMLElement, ctx: ToolbarCtx) {
101
108
  void tab.session.ui.gitStatus;
102
109
  void tab.history.index;
103
110
  void tab.history.snapshots.length;
111
+ void collabState(tab).status;
112
+ void collabState(tab).peers.length;
104
113
  }
105
114
  render();
106
115
  });
@@ -322,8 +331,8 @@ function toolbarTemplate() {
322
331
  }
323
332
 
324
333
  const allowedModes = new Set(tab.capabilities.modes);
325
- const canUndo = tab.history.index > 0;
326
- const canRedo = tab.history.index < tab.history.snapshots.length - 1;
334
+ const canUndo = tabCanUndo(tab);
335
+ const canRedo = tabCanRedo(tab);
327
336
  const canSave = tab.doc.dirty;
328
337
 
329
338
  const S = {
@@ -495,6 +504,7 @@ function toolbarTemplate() {
495
504
  ${toolbarIconMap["sp-icon-redo"]}<span class="tb-label">Redo</span>
496
505
  </sp-action-button>
497
506
  </sp-action-group>
507
+ ${presenceChipsTemplate(tab)}
498
508
  <div class="tb-spacer"></div>
499
509
  <sp-action-button
500
510
  class="tb-search-trigger"
@@ -16,6 +16,7 @@
16
16
  import { transpileJxMarkdown } from "@jxsuite/parser/transpile";
17
17
  import { serializeJxMarkdown } from "@jxsuite/parser/serialize";
18
18
  import markdownClassDef from "@jxsuite/parser/Markdown.class.json";
19
+ import type { WsCollabConnection } from "@jxsuite/collab/client";
19
20
  import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
20
21
  import type {
21
22
  DirEntry,
@@ -197,6 +198,11 @@ export function parseRootKey(root: string): CloudProject | null {
197
198
  export function createCloudPlatform(project: CloudProject | null): StudioPlatform {
198
199
  const base = project ? sessionBase(project) : "";
199
200
  const root = project ? `${project.owner}/${project.repo}` : "";
201
+ /**
202
+ * One multiplexed collab socket per session; per-doc handles come from openDoc. Memoized as a
203
+ * promise so concurrent first opens share the connection instead of racing two sockets.
204
+ */
205
+ let collabConnection: Promise<WsCollabConnection> | null = null;
200
206
 
201
207
  function api(path: string, init?: RequestInit): Promise<Response> {
202
208
  if (!project) {
@@ -341,6 +347,31 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
341
347
  // Directories exist implicitly in the virtual tree (created on write).
342
348
  },
343
349
 
350
+ /**
351
+ * Realtime co-editing over the gateway's /collab WebSocket (rooms keyed by project-relative
352
+ * path, per the shared ProjectSession working tree). Backends without the endpoint (or with the
353
+ * flag off) refuse the upgrade and Studio degrades to solo editing. The wire client's
354
+ * evaluation defers behind the dynamic import until a doc opens.
355
+ */
356
+ async collab(docPath: string) {
357
+ if (!project || typeof WebSocket === "undefined" || typeof location === "undefined") {
358
+ return null;
359
+ }
360
+ collabConnection ??= (async () => {
361
+ const { createWsCollabConnection } = await import("@jxsuite/collab/client");
362
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
363
+ return createWsCollabConnection({
364
+ hydratePath: async (path) => {
365
+ // The DO has no GitHub token on a WS message; a plain read hydrates + caches the row.
366
+ await api(`/file?path=${encodeURIComponent(path)}`);
367
+ },
368
+ url: `${scheme}://${location.host}${base}/collab`,
369
+ });
370
+ })();
371
+ const connection = await collabConnection;
372
+ return connection.openDoc(docPath);
373
+ },
374
+
344
375
  /**
345
376
  * Live session events over the gateway WebSocket. Reconnects with a small backoff; the DO
346
377
  * pushes {kind:"fs"} batches for file mutations (including those from other tabs) and
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { streamImport } from "../services/import-client";
12
+ import type { WsCollabConnection } from "@jxsuite/collab/client";
12
13
  import type { ProjectConfig } from "@jxsuite/schema/types";
13
14
  import type {
14
15
  DirEntry,
@@ -46,6 +47,13 @@ interface SiteEntry {
46
47
  */
47
48
  export function createDevServerPlatform() {
48
49
  let _projectRoot = "";
50
+ /** Lazy /__studio/collab capability probe (null = not asked yet). */
51
+ let _collabProbe: Promise<boolean> | null = null;
52
+ /**
53
+ * One multiplexed collab socket per page; per-doc handles come from openDoc. Memoized as a
54
+ * promise so concurrent first opens share the connection instead of racing two sockets.
55
+ */
56
+ let _collabConnection: Promise<WsCollabConnection> | null = null;
49
57
 
50
58
  /**
51
59
  * Prefix a project-relative path with the active project root for server API calls.
@@ -390,6 +398,35 @@ export function createDevServerPlatform() {
390
398
  };
391
399
  },
392
400
 
401
+ /**
402
+ * Realtime co-editing over the dev server's /__studio/collab endpoint (rooms keyed by
403
+ * server-root-relative path). Probes capability once — older servers without the endpoint
404
+ * degrade to solo editing; the wire client's evaluation defers behind the dynamic import until
405
+ * a doc opens.
406
+ */
407
+ async collab(docPath: string) {
408
+ if (typeof WebSocket === "undefined" || typeof location === "undefined") {
409
+ return null;
410
+ }
411
+ if (_collabProbe === null) {
412
+ _collabProbe = fetch("/__studio/collab")
413
+ .then((res) => res.ok)
414
+ .catch(() => false);
415
+ }
416
+ if (!(await _collabProbe)) {
417
+ return null;
418
+ }
419
+ _collabConnection ??= (async () => {
420
+ const { createWsCollabConnection } = await import("@jxsuite/collab/client");
421
+ const scheme = location.protocol === "https:" ? "wss" : "ws";
422
+ return createWsCollabConnection({
423
+ url: `${scheme}://${location.host}/__studio/collab`,
424
+ });
425
+ })();
426
+ const connection = await _collabConnection;
427
+ return connection.openDoc(serverPath(docPath));
428
+ },
429
+
393
430
  /** @param {string} _path */
394
431
  async createDirectory(_path: string) {
395
432
  // The server creates directories implicitly when writing files.
package/src/studio.ts CHANGED
@@ -77,6 +77,11 @@ import {
77
77
  setupTreeKeyboard,
78
78
  } from "./files/files";
79
79
  import { startFsSync } from "./files/fs-events";
80
+ import {
81
+ configureCollabNotifier,
82
+ configureCollabParser,
83
+ configureCollabSerializer,
84
+ } from "./collab/collab-session";
80
85
  import { renderImportsTemplate } from "./panels/imports-panel";
81
86
  import { renderHeadTemplate } from "./panels/head-panel";
82
87
  import { exportCemManifest as _exportCemManifest } from "./services/cem-export";
@@ -571,6 +576,18 @@ function safeRenderRightPanel() {
571
576
  // Now that renderers are registered, bootstrap
572
577
  registerFunctionCompletions();
573
578
 
579
+ // Collab sessions serialize/parse through the format host when mirroring between the structure
580
+ // Tree and the shared source text, and surface freezes via the status bar.
581
+ configureCollabSerializer(serializeDocument);
582
+ configureCollabParser(async (tab, text) => {
583
+ if (tab.documentPath && formatForPath(tab.documentPath)) {
584
+ const parsed = await parseSourceForPath(tab.documentPath, text);
585
+ return { document: parsed.document as JxMutableNode, frontmatter: parsed.frontmatter };
586
+ }
587
+ return { document: JSON.parse(text) as JxMutableNode };
588
+ });
589
+ configureCollabNotifier(statusMessage);
590
+
574
591
  let fsUnsub: (() => void) | null = null;
575
592
  /** (Re)subscribe the sidebar to backend filesystem events for the active project. */
576
593
  function ensureFsSync() {
@@ -4,86 +4,10 @@
4
4
  * from live editing: history replay in the parent ({@link file://./transact.ts}) and the iframe
5
5
  * canvas's non-reactive shadow doc (the patch source-of-truth across the cross-origin bridge).
6
6
  *
7
- * Dependency-light on purpose: it pulls only the pure path helper from `../state` and clones values
8
- * with a local JSON round-trip, so the slim canvas-iframe bundle can import it without dragging in
9
- * the editor's reactive store / format host.
7
+ * The implementation is the canonical one in `@jxsuite/collab/ops` (yjs-free, so the slim
8
+ * canvas-iframe bundle imports it without dragging yjs in); this module re-exports it so both sides
9
+ * of the frame boundary and the collab bridge — replay ops through one code path.
10
10
  */
11
11
 
12
- import { getNodeAtPath } from "../state";
13
- import type { JxDocOp } from "./patch-ops";
14
- import type { JxMutableNode } from "@jxsuite/schema/types";
15
-
16
- /** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
17
- function jsonClone<T>(value: T): T {
18
- // oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
19
- return JSON.parse(JSON.stringify(value)) as T;
20
- }
21
-
22
- /** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
23
- export function cloneValue<T>(v: T): T {
24
- return v === undefined || v === null ? v : (jsonClone(v as object) as T);
25
- }
26
-
27
- /** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
28
- export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
29
- // Defense-in-depth: a path that resolves to a children array (rather than a node) would
30
- // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
31
- // Nothing renders. Callers must pass a node; fail loudly if they don't.
32
- if (Array.isArray(node)) {
33
- throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
34
- }
35
- if (!node.children) {
36
- node.children = [];
37
- }
38
- if (!Array.isArray(node.children)) {
39
- throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
40
- }
41
- return node.children;
42
- }
43
-
44
- /**
45
- * Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
46
- * aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
47
- */
48
- export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
49
- switch (op.op) {
50
- case "set-key": {
51
- const node = getNodeAtPath(doc, op.path);
52
- if (!node) {
53
- throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
54
- }
55
- const target = node as Record<string, unknown>;
56
- if (op.value === undefined) {
57
- delete target[op.key];
58
- } else {
59
- target[op.key] = cloneValue(op.value);
60
- }
61
- return;
62
- }
63
- case "insert-child": {
64
- const parent = getNodeAtPath(doc, op.parentPath);
65
- childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
66
- return;
67
- }
68
- case "remove-child": {
69
- const parent = getNodeAtPath(doc, op.parentPath);
70
- childArray(parent).splice(op.index, 1);
71
- return;
72
- }
73
- case "set-child": {
74
- const parent = getNodeAtPath(doc, op.parentPath);
75
- childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
76
- return;
77
- }
78
- case "move-child": {
79
- const fromParent = getNodeAtPath(doc, op.fromParentPath);
80
- const toParent = getNodeAtPath(doc, op.toParentPath);
81
- const [node] = childArray(fromParent).splice(op.fromIndex, 1);
82
- childArray(toParent).splice(op.toIndex, 0, node!);
83
- return;
84
- }
85
- default: {
86
- throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
87
- }
88
- }
89
- }
12
+ export { applyDocOpToDoc, childArray, cloneValue } from "@jxsuite/collab/ops";
13
+ export type { JxDocOp } from "@jxsuite/collab/ops";
@@ -5,6 +5,8 @@
5
5
  * document as the single source of truth.
6
6
  */
7
7
 
8
+ // oxlint-disable-next-line unicorn/prefer-export-from -- JxDocOpPair is also used locally (TransactionRecord)
9
+ import type { JxDocOpPair } from "@jxsuite/collab/ops";
8
10
  import type { JxPath } from "../state";
9
11
  import type { Tab } from "./tab.js";
10
12
 
@@ -31,30 +33,12 @@ export type JxPatchOp =
31
33
  /**
32
34
  * Value-carrying document mutation, replayable in either direction. Mutators record a
33
35
  * forward/inverse pair per change; history applies them for surgical undo/redo and for
34
- * materializing states from checkpoints — without whole-document snapshots per edit.
36
+ * materializing states from checkpoints — without whole-document snapshots per edit. The definition
37
+ * is canonical in `@jxsuite/collab/ops` (the collab bridge mirrors the same ops into a shared
38
+ * Y.Doc); re-exported here so studio call sites keep their import path.
35
39
  */
36
- export type JxDocOp =
37
- /** Set (or delete, when value is undefined) a key on the node at path. */
38
- | { op: "set-key"; path: JxPath; key: string; value?: unknown }
39
- /** Insert a node at parentPath.children[index]. */
40
- | { op: "insert-child"; parentPath: JxPath; index: number; node: unknown }
41
- /** Remove parentPath.children[index]. */
42
- | { op: "remove-child"; parentPath: JxPath; index: number }
43
- /** Replace parentPath.children[index] with node. */
44
- | { op: "set-child"; parentPath: JxPath; index: number; node: unknown }
45
- /** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
46
- | {
47
- op: "move-child";
48
- fromParentPath: JxPath;
49
- fromIndex: number;
50
- toParentPath: JxPath;
51
- toIndex: number;
52
- };
53
-
54
- export interface JxDocOpPair {
55
- forward: JxDocOp;
56
- inverse: JxDocOp;
57
- }
40
+ export type { JxDocOp } from "@jxsuite/collab/ops";
41
+ export type { JxDocOpPair };
58
42
 
59
43
  /** Everything recorded during one transaction. */
60
44
  export interface TransactionRecord {