@jxsuite/studio 0.36.0 → 0.37.1

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.
@@ -79,22 +79,64 @@ export type JxNodeValue =
79
79
 
80
80
  // ─── Transactional layer ─────────────────────────────────────────────────────
81
81
 
82
+ /**
83
+ * Where a transaction came from: a direct user/tool edit, the undo/redo machinery replaying
84
+ * recorded ops, or a remote collaborator's ops applied locally. Observers use this to avoid
85
+ * republishing what they themselves applied.
86
+ */
87
+ export type TransactOrigin = "user" | "history" | "remote";
88
+
89
+ /**
90
+ * Module-level hook invoked at the end of every transactDoc (after the canvas patch apply and the
91
+ * dirty mark). Registered by the collab layer to mirror local edits into a shared document; at most
92
+ * one observer exists. `record.docOps` may be empty for un-instrumented mutations — observers must
93
+ * handle that (e.g. by diffing).
94
+ */
95
+ export type TransactObserver = (
96
+ tab: Tab,
97
+ record: TransactionRecord,
98
+ origin: TransactOrigin,
99
+ ) => void;
100
+
101
+ let _transactObserver: TransactObserver | null = null;
102
+
103
+ export function setTransactObserver(fn: TransactObserver | null) {
104
+ _transactObserver = fn;
105
+ }
106
+
107
+ /**
108
+ * Module-level gate consulted at transactDoc entry: return a refusal reason to block the mutation
109
+ * (the collab layer soft-freezes structural editing while a peer holds source-canonical), or null
110
+ * to proceed. Remote-origin transactions always pass — they ARE the frozen representation's
111
+ * mirror.
112
+ */
113
+ export type TransactGate = (tab: Tab, origin: TransactOrigin) => string | null;
114
+
115
+ let _transactGate: TransactGate | null = null;
116
+
117
+ export function setTransactGate(fn: TransactGate | null) {
118
+ _transactGate = fn;
119
+ }
120
+
82
121
  /**
83
122
  * Apply a document mutation transactionally: push to history and mark dirty. The mutationFn
84
123
  * receives the tab and should mutate tab.doc.document in place.
85
124
  *
86
125
  * @param {Tab | null} tab
87
126
  * @param {(tab: Tab) => void} mutationFn
88
- * @param {{ skipHistory?: boolean }} [opts]
127
+ * @param {{ skipHistory?: boolean; origin?: TransactOrigin }} [opts]
89
128
  */
90
129
  export function transactDoc(
91
130
  tab: Tab | null,
92
131
  mutationFn: (tab: Tab) => void,
93
- { skipHistory = false }: { skipHistory?: boolean } = {},
132
+ { skipHistory = false, origin = "user" }: { skipHistory?: boolean; origin?: TransactOrigin } = {},
94
133
  ) {
95
134
  if (!tab) {
96
135
  return;
97
136
  }
137
+ if (origin !== "remote" && _transactGate?.(tab, origin)) {
138
+ return;
139
+ }
98
140
  const selectionBefore = tab.session.selection ? [...tab.session.selection] : null;
99
141
  beginRecording();
100
142
  let record: TransactionRecord;
@@ -141,6 +183,8 @@ export function transactDoc(
141
183
  }
142
184
 
143
185
  tab.doc.dirty = true;
186
+
187
+ _transactObserver?.(tab, record, origin);
144
188
  }
145
189
 
146
190
  /**
@@ -215,16 +259,38 @@ function materializeState(
215
259
  *
216
260
  * @param {Tab | null} tab
217
261
  * @param {(doc: JxMutableNode) => void} fn
218
- * @param {{ skipHistory?: boolean }} [opts]
262
+ * @param {{ skipHistory?: boolean; origin?: TransactOrigin }} [opts]
219
263
  */
220
264
  export function transact(
221
265
  tab: Tab | null,
222
266
  fn: (doc: JxMutableNode) => void,
223
- opts?: { skipHistory?: boolean },
267
+ opts?: { skipHistory?: boolean; origin?: TransactOrigin },
224
268
  ) {
225
269
  transactDoc(tab, (t) => fn(t.doc.document), opts);
226
270
  }
227
271
 
272
+ /**
273
+ * Apply externally-produced doc ops (a remote collaborator's edits) through the normal transaction
274
+ * pipeline: the canvas patches surgically exactly as it does for undo/redo replay, panels' effects
275
+ * fire, and — because the origin is "remote" — the transact observer will not republish them.
276
+ * History is skipped; while a collab session is attached, undo is delegated (see
277
+ * setHistoryDelegate) and local-only.
278
+ *
279
+ * @param {Tab} tab
280
+ * @param {JxDocOp[]} ops
281
+ */
282
+ export function applyExternalDocOps(tab: Tab, ops: JxDocOp[]) {
283
+ transactDoc(
284
+ tab,
285
+ (t) => {
286
+ for (const op of ops) {
287
+ applyDocOp(t, op);
288
+ }
289
+ },
290
+ { origin: "remote", skipHistory: true },
291
+ );
292
+ }
293
+
228
294
  // ─── Document-op application ─────────────────────────────────────────────────
229
295
 
230
296
  /** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
@@ -289,26 +355,40 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
289
355
 
290
356
  let _batchTab: Tab | null = null;
291
357
 
358
+ /** Module-level hook invoked when a batch closes (collab flushes its buffered ops as one step). */
359
+ export type BatchEndNotifier = (tab: Tab) => void;
360
+
361
+ let _batchEndNotifier: BatchEndNotifier | null = null;
362
+
363
+ export function setBatchEndNotifier(fn: BatchEndNotifier | null) {
364
+ _batchEndNotifier = fn;
365
+ }
366
+
292
367
  export function beginBatch(tab: Tab | null) {
293
368
  _batchTab = tab;
294
369
  }
295
370
 
296
371
  export function endBatch() {
297
- if (_batchTab) {
298
- const raw = toRaw(_batchTab.doc.document);
372
+ const tab = _batchTab;
373
+ // A history delegate owns undo grouping while registered; the snapshot push would be dead weight.
374
+ if (tab && !_historyDelegates.has(tab)) {
375
+ const raw = toRaw(tab.doc.document);
299
376
  const snapshot = {
300
377
  document: jsonClone(raw),
301
- selection: _batchTab.session.selection ? [..._batchTab.session.selection] : null,
378
+ selection: tab.session.selection ? [...tab.session.selection] : null,
302
379
  };
303
- const truncated = _batchTab.history.snapshots.slice(0, _batchTab.history.index + 1);
380
+ const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
304
381
  truncated.push(snapshot);
305
382
  if (truncated.length > HISTORY_LIMIT) {
306
383
  truncated.shift();
307
384
  }
308
- _batchTab.history.snapshots = truncated;
309
- _batchTab.history.index = truncated.length - 1;
385
+ tab.history.snapshots = truncated;
386
+ tab.history.index = truncated.length - 1;
310
387
  }
311
388
  _batchTab = null;
389
+ if (tab) {
390
+ _batchEndNotifier?.(tab);
391
+ }
312
392
  }
313
393
 
314
394
  export function isBatching(): boolean {
@@ -317,6 +397,42 @@ export function isBatching(): boolean {
317
397
 
318
398
  // ─── Undo / Redo ─────────────────────────────────────────────────────────────
319
399
 
400
+ /**
401
+ * Per-tab replacement for the built-in op-log history. While a delegate is registered (a collab
402
+ * session's Y.UndoManager), undo/redo route to it and the snapshot history is bypassed — keeping
403
+ * this module free of any yjs knowledge.
404
+ */
405
+ export interface HistoryDelegate {
406
+ undo: (tab: Tab) => void;
407
+ redo: (tab: Tab) => void;
408
+ canUndo: (tab: Tab) => boolean;
409
+ canRedo: (tab: Tab) => boolean;
410
+ }
411
+
412
+ const _historyDelegates = new WeakMap<Tab, HistoryDelegate>();
413
+
414
+ export function setHistoryDelegate(tab: Tab, delegate: HistoryDelegate | null) {
415
+ if (delegate) {
416
+ _historyDelegates.set(tab, delegate);
417
+ } else {
418
+ _historyDelegates.delete(tab);
419
+ }
420
+ }
421
+
422
+ export function getHistoryDelegate(tab: Tab): HistoryDelegate | null {
423
+ return _historyDelegates.get(tab) ?? null;
424
+ }
425
+
426
+ export function canUndo(tab: Tab): boolean {
427
+ const delegate = _historyDelegates.get(tab);
428
+ return delegate ? delegate.canUndo(tab) : tab.history.index > 0;
429
+ }
430
+
431
+ export function canRedo(tab: Tab): boolean {
432
+ const delegate = _historyDelegates.get(tab);
433
+ return delegate ? delegate.canRedo(tab) : tab.history.index < tab.history.snapshots.length - 1;
434
+ }
435
+
320
436
  /** Restore a materialized snapshot state (full-render path). */
321
437
  function restoreState(tab: Tab, index: number) {
322
438
  const { snapshots } = tab.history;
@@ -345,6 +461,11 @@ function assertHistoryConsistency(tab: Tab, index: number) {
345
461
 
346
462
  /** @param {Tab} tab */
347
463
  export function undo(tab: Tab) {
464
+ const delegate = _historyDelegates.get(tab);
465
+ if (delegate) {
466
+ delegate.undo(tab);
467
+ return;
468
+ }
348
469
  if (tab.history.index <= 0) {
349
470
  return;
350
471
  }
@@ -360,7 +481,7 @@ export function undo(tab: Tab) {
360
481
  applyDocOp(t, toRaw(inverseOps[i]) as JxDocOp);
361
482
  }
362
483
  },
363
- { skipHistory: true },
484
+ { origin: "history", skipHistory: true },
364
485
  );
365
486
  tab.session.selection = entry.selectionBefore ? [...toRaw(entry.selectionBefore)] : null;
366
487
  tab.history.index -= 1;
@@ -374,6 +495,11 @@ export function undo(tab: Tab) {
374
495
 
375
496
  /** @param {Tab} tab */
376
497
  export function redo(tab: Tab) {
498
+ const delegate = _historyDelegates.get(tab);
499
+ if (delegate) {
500
+ delegate.redo(tab);
501
+ return;
502
+ }
377
503
  if (tab.history.index >= tab.history.snapshots.length - 1) {
378
504
  return;
379
505
  }
@@ -387,7 +513,7 @@ export function redo(tab: Tab) {
387
513
  applyDocOp(t, toRaw(op) as JxDocOp);
388
514
  }
389
515
  },
390
- { skipHistory: true },
516
+ { origin: "history", skipHistory: true },
391
517
  );
392
518
  tab.session.selection = entry.selection ? [...toRaw(entry.selection)] : null;
393
519
  tab.history.index += 1;
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /// <reference lib="dom" />
2
+ import type { CollabHandle } from "@jxsuite/collab/provider";
2
3
  import type { JxMutableNode, JxPath, ProjectConfig } from "@jxsuite/schema/types";
3
4
 
4
5
  // ─── Wire types (the Studio Backend Protocol) ───────────────────────────────
@@ -224,6 +225,13 @@ export interface StudioPlatform {
224
225
  * projects are found via the native picker instead.
225
226
  */
226
227
  listProjects?: () => Promise<ProjectListEntry[]>;
228
+ /**
229
+ * Open a realtime co-editing session for a project-relative document path. Optional: platforms
230
+ * without a collab backend omit it and Studio edits solo with file-level saves. Resolves null
231
+ * when the backend refuses a room for this doc (binary, oversized). The returned handle's Y.Doc
232
+ * starts empty and fills from the provider — see `@jxsuite/collab/provider` for the contract.
233
+ */
234
+ collab?: (docPath: string) => Promise<CollabHandle | null>;
227
235
  // ─── Identity & hosting connections (publish surface) ───────────────────────
228
236
  /** The signed-in user's identity, when the platform has one (cloud). */
229
237
  getUser?: () => Promise<{ login: string; name?: string; avatarUrl?: string } | null>;
@@ -1,4 +1,5 @@
1
1
  import { computed, reactive, toRaw } from "../reactivity";
2
+ import { ensureCollab, rekeyCollab } from "../collab/collab-session";
2
3
  import { createTab, disposeTab } from "../tabs/tab";
3
4
  import type { Tab } from "../tabs/tab";
4
5
 
@@ -96,6 +97,7 @@ export function openTab(opts: {
96
97
  workspace.tabs.set(tab.id, tab);
97
98
  workspace.tabOrder.push(tab.id);
98
99
  workspace.activeTabId = tab.id;
100
+ ensureCollab(tab);
99
101
  return tab;
100
102
  }
101
103
 
@@ -155,6 +157,7 @@ export function replaceAllTabs(newTabOpts: {
155
157
  workspace.tabs.set(newTab.id, newTab);
156
158
  workspace.activeTabId = newTab.id;
157
159
  workspace.tabOrder = [newTab.id];
160
+ ensureCollab(newTab);
158
161
 
159
162
  for (const id of oldIds) {
160
163
  if (id === newTab.id) {
@@ -201,4 +204,5 @@ export function renameTab(oldId: string, newId: string, newDocumentPath: string)
201
204
  if (workspace.activeTabId === oldId) {
202
205
  workspace.activeTabId = newId;
203
206
  }
207
+ rekeyCollab(tab);
204
208
  }