@ai-matrx/office 0.7.0 → 0.8.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/docs.d.ts CHANGED
@@ -178,6 +178,60 @@ type CommandServiceLike = {
178
178
  syncExecuteCommand(id: string, params: MutationParams, options?: ExecuteOptions): unknown;
179
179
  };
180
180
 
181
+ /**
182
+ * The Docs co-editing room on the ordered log (COLLAB-DESIGN §6, unit C3): the `OpDocumentAdapter` for
183
+ * `openOpSession` over a document's editors, plus saves, restore and the saver choice.
184
+ *
185
+ * - CAPTURE (§6.3). The document model hands over every local `doc.mutation.rich-text-editing` with the applied
186
+ * params (the handler's own rewrite, C0 surprise 6), the pre-state cloned in `beforeCommandExecuted`, and the
187
+ * outermost command id. `onlyLocal` / `fromCollab` never arrive here (the model filters them). Only `segmentId`
188
+ * and `actions` travel; the inverse is computed from the APPLIED actions on a COPY (`invertWithDoc` mutates its
189
+ * op). Each mutation goes to the session as it happens, so a remote apply can never land between a local
190
+ * mutation and its hand-over; the command id rides along as the change's label.
191
+ * - APPLY (§6.7). `syncExecuteCommand(id, { unitId: own, segmentId, actions, textRanges: null,
192
+ * noNeedSetTextRange: true }, { onlyLocal: true, fromCollab: true })` through the model (every view of the
193
+ * document in this tab). A throw, `false`, or actions the handler emptied fail the apply: the session rebuilds,
194
+ * never skips. While an IME composition is open, remote applies QUEUE (acks continue); a local op captured
195
+ * meanwhile is rebased over the queue (`L' = T(L, Q, 'left')`, `Q' = T(Q, L, 'right')`), and the queue drains
196
+ * at `compositionend`.
197
+ * - UNDO (§6.9). Each applied remote op rebases every stored undo and redo item of this seat
198
+ * (`T(item, R1, 'left')`); `textRanges` and `layoutState` are stripped from rebased items; an item whose rebase
199
+ * is a barrier, throws or comes back empty is dropped. Undo therefore only ever reverts this person's changes.
200
+ * - SAVES (§7.3). Only a quiet seat whose hash matches every peer at its rev saves, and only the chosen saver
201
+ * (the lowest client id among seats publishing a quiet hash) saves on its own; a manual save always tries.
202
+ * - RESTORE (§20). A whole-document replace goes through `collab_replace`: the snapshot is loaded into this seat's
203
+ * editor first, so the stored snapshot and its hash are the Univer-normalised form every seat will load.
204
+ */
205
+
206
+ /** One local mutation as the document model hands it over. */
207
+ interface DocsLocalMutation {
208
+ id: string;
209
+ params: unknown;
210
+ /** The document before this mutation (cloned in `beforeCommandExecuted`), when the view could supply it. */
211
+ pre: unknown | null;
212
+ /** The outermost command this mutation belongs to (the depth tracker with its try/finally guard). */
213
+ cmd: string | null;
214
+ /** Whether the mutation changes what a snapshot stores. */
215
+ content: boolean;
216
+ }
217
+ /** What the document model lends the room (implemented by `DocumentModel`). */
218
+ interface DocsOtPort {
219
+ onLocal(listener: (m: DocsLocalMutation) => void): () => void;
220
+ /** Apply one committed mutation to every view of the document; throws when any view could not. */
221
+ applyRemote(id: string, params: Record<string, unknown>): void;
222
+ /** The document as the editor holds it now (`doc.save()`), or null when no view is mounted. */
223
+ snapshot(): unknown | null;
224
+ /** Replace the document in every view (and the kept editor). */
225
+ load(snapshot: unknown): void;
226
+ /** Univer's undo/redo service of each mounted view and the unit it holds. */
227
+ undoTargets(): Array<{
228
+ service: unknown;
229
+ unitId: string;
230
+ }>;
231
+ /** Ask for a save (the record's one coalesced save). */
232
+ markDirty(): void;
233
+ }
234
+
181
235
  /**
182
236
  * THE DOCUMENT MODEL — the engine of one cloud document per tab.
183
237
  *
@@ -226,6 +280,8 @@ interface DocumentViewPort {
226
280
  remount: (snapshot: DocumentSnapshotData) => void;
227
281
  /** Whether this view may change the document (read at edit time). */
228
282
  editable: () => boolean;
283
+ /** Univer's undo/redo service of this view's instance (the co-editing room rebases its items, §6.9). */
284
+ undoRedo?: () => unknown;
229
285
  /**
230
286
  * The Univer unit id of this view's mounted document. Every seat mounts its own unit, so a
231
287
  * mutation carries the SENDER's id and must be re-addressed to this view's unit before it is
@@ -257,6 +313,14 @@ interface DocumentModelDeps {
257
313
  /** A mutation could not be applied to a view: the app says so (a notice); the model re-syncs from the saved version. */
258
314
  onApplyFailed?: (documentId: string, mutationId: string, error: unknown) => void;
259
315
  }
316
+ /** The document's room on the ordered log (COLLAB-DESIGN §6, C3), bound through `startOtRoom`. */
317
+ interface DocumentOtHandle {
318
+ stop: () => void;
319
+ write: (reason: CommitReason) => Promise<boolean>;
320
+ replace: (snapshot: DocumentSnapshotData) => Promise<void>;
321
+ isSaver: () => boolean;
322
+ }
323
+ type DocumentOtFactory = (port: DocsOtPort, onAwareness: (awareness: DocumentAwareness) => void) => Promise<DocumentOtHandle | null>;
260
324
  /** A collab room (WorkbookCollabSession) bound to the model's command stream. */
261
325
  interface DocumentCollabHandle {
262
326
  stop: () => void;
@@ -314,6 +378,10 @@ declare class DocumentModel {
314
378
  private readonly knownSnapshots;
315
379
  private readonly collabListeners;
316
380
  private collab;
381
+ /** The ordered-log room (C3); exclusive with `collab` (the 0.6 relay room). */
382
+ private ot;
383
+ private otStarting;
384
+ private readonly otListeners;
317
385
  /** The saved snapshot the body started from, and the room entries it already holds. */
318
386
  private baseId;
319
387
  private baseCovered;
@@ -347,6 +415,13 @@ declare class DocumentModel {
347
415
  /** The kept editor, handed to the view that mounts next (null when none is kept). */
348
416
  takeParkedEditor(): ParkedDocumentEditor | null;
349
417
  private onViewMutation;
418
+ /**
419
+ * Per view: the outermost command running (a mutation's group label) and, while a room listens, the document
420
+ * before each rich-text mutation (its inverse is computed from the APPLIED actions against this copy). Univer's
421
+ * `executeCommand` has no `finally`: a command that mutates then throws never fires `onCommandExecuted`, so
422
+ * the depth is restored in a try/finally around the instance's `executeCommand` (C0 surprise 7).
423
+ */
424
+ private trackCommands;
350
425
  /** The unit id a view's document has (what a replayed mutation must be addressed to). */
351
426
  private unitIdOf;
352
427
  /** Replay one mutation into every view except `exceptViewId`, addressed to each view's own unit. */
@@ -390,7 +465,7 @@ declare class DocumentModel {
390
465
  replaceContent(row: {
391
466
  id: string;
392
467
  snapshot: DocumentSnapshotData;
393
- }): void;
468
+ }): void | Promise<void>;
394
469
  /** A peer replaced the document: show the newest save (once). */
395
470
  private onPeerReplaced;
396
471
  /** A loaded row becomes the base: its content (what views mount) and the room entries it holds. */
@@ -404,6 +479,12 @@ declare class DocumentModel {
404
479
  private readonly virtualCommandService;
405
480
  /** Join the document's collab room once (the first view that asks). */
406
481
  startCollab(factory: DocumentCollabFactory): Promise<void>;
482
+ /** The model as the ordered-log room sees it (COLLAB-DESIGN §6.7: the model is the client, not the view). */
483
+ private readonly otPort;
484
+ /** Join the document's ordered-log room once (C3). Exclusive with `startCollab`. */
485
+ startOtRoom(factory: DocumentOtFactory): Promise<void>;
486
+ /** Which co-editing room this document is in right now. */
487
+ collabMode(): "ordered" | "relay" | null;
407
488
  isCollabHost(): boolean;
408
489
  getAwareness(): DocumentAwareness | null;
409
490
  subscribeAwareness(listener: () => void): () => void;
@@ -602,4 +683,4 @@ declare function documentBodyWriteHandler(deps: {
602
683
  assertWritable: (whatItBlocks: string) => void;
603
684
  }): DocumentBodyWriteHandler;
604
685
 
605
- export { type BodyEdit, type BodyText, type CreateDocumentArgs, DOCUMENT_BODY_MAX_LENGTH, DOCUMENT_DESCRIPTION_MAX_LENGTH, DOCUMENT_NAME_MAX_LENGTH, DOCUMENT_SAVE_DELAY_MS, type DocsHostPorts, type DocsSupabase, type DocumentAwareness, type DocumentBodyPort, type DocumentBodyWriteHandler, type DocumentCollabBase, type DocumentCollabFactory, type DocumentCollabHandle, DocumentModel, type DocumentModelDeps, type DocumentSnapshotData, type DocumentViewPort, EMOJI_GROUPS, type Glyph, MIN_FIT_ZOOM, type ParkedDocumentEditor, SPECIAL_GROUPS, type SaveDocumentSnapshotArgs, bodyTextOf, configureDocsHost, copyDocument, createDocument, deleteDocument, discardFailedDocument, documentBodyWriteHandler, documentSourceLabel, fitZoomRatio, getDocsHost, getDocument, getLatestDocumentSnapshot, listAccessibleDocuments, listDocumentSnapshots, listTrashedDocuments, moveDocument, planBodyEdit, renameDocument, restoreDocument, sanitizeUniverDocSnapshot, saveDocumentSnapshot, searchSymbols, updateDocumentDescription, validateDocumentBody, validateDocumentDescription, validateDocumentName, writeBodyText };
686
+ export { type BodyEdit, type BodyText, type CreateDocumentArgs, DOCUMENT_BODY_MAX_LENGTH, DOCUMENT_DESCRIPTION_MAX_LENGTH, DOCUMENT_NAME_MAX_LENGTH, DOCUMENT_SAVE_DELAY_MS, type DocsHostPorts, type DocsSupabase, type DocumentAwareness, type DocumentBodyPort, type DocumentBodyWriteHandler, type DocumentCollabBase, type DocumentCollabFactory, type DocumentCollabHandle, DocumentModel, type DocumentModelDeps, type DocumentOtFactory, type DocumentOtHandle, type DocumentSnapshotData, type DocumentViewPort, EMOJI_GROUPS, type Glyph, MIN_FIT_ZOOM, type ParkedDocumentEditor, SPECIAL_GROUPS, type SaveDocumentSnapshotArgs, bodyTextOf, configureDocsHost, copyDocument, createDocument, deleteDocument, discardFailedDocument, documentBodyWriteHandler, documentSourceLabel, fitZoomRatio, getDocsHost, getDocument, getLatestDocumentSnapshot, listAccessibleDocuments, listDocumentSnapshots, listTrashedDocuments, moveDocument, planBodyEdit, renameDocument, restoreDocument, sanitizeUniverDocSnapshot, saveDocumentSnapshot, searchSymbols, updateDocumentDescription, validateDocumentBody, validateDocumentDescription, validateDocumentName, writeBodyText };
package/dist/docs.js CHANGED
@@ -45,6 +45,9 @@ function takeCovered(snapshot) {
45
45
  }
46
46
 
47
47
  // src/docs/document-model.ts
48
+ var RICH_TEXT_EDITING_ID = "doc.mutation.rich-text-editing";
49
+ var COMMAND_TYPE = 0;
50
+ var DEPTH_GUARDED = /* @__PURE__ */ Symbol.for("ai-matrx.office.docs.command-depth-guard");
48
51
  var DOCUMENT_SAVE_DELAY_MS = 2500;
49
52
  var viewIds = 0;
50
53
  var DocumentModel = class {
@@ -61,6 +64,10 @@ var DocumentModel = class {
61
64
  knownSnapshots = /* @__PURE__ */ new Set();
62
65
  collabListeners = /* @__PURE__ */ new Set();
63
66
  collab = null;
67
+ /** The ordered-log room (C3); exclusive with `collab` (the 0.6 relay room). */
68
+ ot = null;
69
+ otStarting = null;
70
+ otListeners = /* @__PURE__ */ new Set();
64
71
  /** The saved snapshot the body started from, and the room entries it already holds. */
65
72
  baseId = null;
66
73
  baseCovered = /* @__PURE__ */ new Set();
@@ -124,7 +131,14 @@ var DocumentModel = class {
124
131
  const subscription = port.commandService.onMutationExecutedForCollab(
125
132
  (info, options) => this.onViewMutation(id, info, options)
126
133
  );
127
- this.views.set(id, { id, port, dispose: () => subscription.dispose() });
134
+ const view = { id, port, dispose: () => {
135
+ }, commands: [], pre: null };
136
+ const hooks = this.trackCommands(view);
137
+ view.dispose = () => {
138
+ subscription.dispose();
139
+ hooks();
140
+ };
141
+ this.views.set(id, view);
128
142
  if (!this.handle.hasPending()) {
129
143
  try {
130
144
  const mounted = port.snapshot();
@@ -137,8 +151,8 @@ var DocumentModel = class {
137
151
  return () => {
138
152
  if (detached) return;
139
153
  detached = true;
140
- const view = this.views.get(id);
141
- if (!view) return;
154
+ const view2 = this.views.get(id);
155
+ if (!view2) return;
142
156
  if (this.views.size === 1) {
143
157
  try {
144
158
  const snapshot = port.snapshot();
@@ -147,7 +161,7 @@ var DocumentModel = class {
147
161
  console.warn(`[document-model] could not keep ${this.documentId}'s last view's document`, error);
148
162
  }
149
163
  }
150
- view.dispose();
164
+ view2.dispose();
151
165
  this.views.delete(id);
152
166
  if (this.lastEditedView === id) this.lastEditedView = null;
153
167
  };
@@ -177,8 +191,63 @@ var DocumentModel = class {
177
191
  this.lastEditedView = viewId;
178
192
  this.relay(info, viewId);
179
193
  for (const listener of this.collabListeners) listener(info, options);
194
+ if (this.otListeners.size && !options?.onlyLocal) {
195
+ const m = {
196
+ id: info.id,
197
+ params: info.params,
198
+ pre: info.id === RICH_TEXT_EDITING_ID ? view.pre : null,
199
+ cmd: view.commands[0] ?? null,
200
+ content: isContentMutation(info)
201
+ };
202
+ view.pre = null;
203
+ for (const listener of Array.from(this.otListeners)) listener(m);
204
+ }
180
205
  if (isContentMutation(info)) this.markDirty();
181
206
  }
207
+ /**
208
+ * Per view: the outermost command running (a mutation's group label) and, while a room listens, the document
209
+ * before each rich-text mutation (its inverse is computed from the APPLIED actions against this copy). Univer's
210
+ * `executeCommand` has no `finally`: a command that mutates then throws never fires `onCommandExecuted`, so
211
+ * the depth is restored in a try/finally around the instance's `executeCommand` (C0 surprise 7).
212
+ */
213
+ trackCommands(view) {
214
+ const cs = view.port.commandService;
215
+ if (typeof cs.beforeCommandExecuted !== "function" || typeof cs.onCommandExecuted !== "function") return () => {
216
+ };
217
+ const before = cs.beforeCommandExecuted((info) => {
218
+ if (info.type === COMMAND_TYPE) view.commands.push(info.id);
219
+ else if (info.id === RICH_TEXT_EDITING_ID && this.otListeners.size && !this.relaying) {
220
+ try {
221
+ const snap = view.port.snapshot();
222
+ view.pre = snap ? structuredClone(snap) : null;
223
+ } catch {
224
+ view.pre = null;
225
+ }
226
+ }
227
+ });
228
+ const after = cs.onCommandExecuted((info) => {
229
+ if (info.type !== COMMAND_TYPE) return;
230
+ const at = view.commands.lastIndexOf(info.id);
231
+ if (at >= 0) view.commands.splice(at, 1);
232
+ });
233
+ const guarded = cs;
234
+ if (typeof cs.executeCommand === "function" && !guarded[DEPTH_GUARDED]) {
235
+ const original = cs.executeCommand.bind(cs);
236
+ guarded[DEPTH_GUARDED] = true;
237
+ cs.executeCommand = async (id, params, options) => {
238
+ const depth = view.commands.length;
239
+ try {
240
+ return await original(id, params, options);
241
+ } finally {
242
+ if (view.commands.length > depth) view.commands.length = depth;
243
+ }
244
+ };
245
+ }
246
+ return () => {
247
+ before.dispose();
248
+ after.dispose();
249
+ };
250
+ }
182
251
  /** The unit id a view's document has (what a replayed mutation must be addressed to). */
183
252
  unitIdOf(view) {
184
253
  try {
@@ -242,6 +311,11 @@ var DocumentModel = class {
242
311
  * peer's edits reach it through the room). An explicit Save always writes.
243
312
  */
244
313
  async write(reason) {
314
+ if (this.ot) {
315
+ const wrote = await this.ot.write(reason);
316
+ if (wrote) this.deps.onSaved?.(this.documentId, reason === "manual" ? "manual" : "autosave");
317
+ return wrote;
318
+ }
245
319
  const snapshot = this.latest();
246
320
  if (!snapshot) throw new Error("There is no open document to save.");
247
321
  if (this.collab && !this.collab.isHost() && reason !== "manual") return false;
@@ -286,7 +360,7 @@ var DocumentModel = class {
286
360
  * person chooses; `overUnsaved` is "Take theirs" (`./documentModels.ts`).
287
361
  */
288
362
  async onRemoteSnapshot(snapshotId, { overUnsaved = false } = {}) {
289
- if (this.collab) return;
363
+ if (this.collab || this.ot) return;
290
364
  if (this.knownSnapshots.has(snapshotId)) return;
291
365
  if (this.saving) return;
292
366
  if (this.hasUnsaved() && !overUnsaved) {
@@ -305,6 +379,7 @@ var DocumentModel = class {
305
379
  * room the peers reload it and the shared log is emptied.
306
380
  */
307
381
  replaceContent(row) {
382
+ if (this.ot) return this.ot.replace(row.snapshot);
308
383
  this.show(row);
309
384
  this.collab?.announceReplaced(row.id);
310
385
  }
@@ -388,7 +463,79 @@ var DocumentModel = class {
388
463
  }
389
464
  return this.collabStarting;
390
465
  }
466
+ /** The model as the ordered-log room sees it (COLLAB-DESIGN §6.7: the model is the client, not the view). */
467
+ otPort = {
468
+ onLocal: (listener) => {
469
+ this.otListeners.add(listener);
470
+ return () => void this.otListeners.delete(listener);
471
+ },
472
+ applyRemote: (id, params) => {
473
+ if (this.views.size === 0) throw new Error("no view of the document is mounted");
474
+ this.relaying = true;
475
+ try {
476
+ for (const view of this.views.values()) {
477
+ const unitId = this.unitIdOf(view);
478
+ const input = Array.isArray(params.actions) ? params.actions.length : 0;
479
+ const p = { ...params, ...unitId ? { unitId } : {}, textRanges: null, noNeedSetTextRange: true };
480
+ const ok = view.port.commandService.syncExecuteCommand(id, p, { onlyLocal: true, fromCollab: true });
481
+ if (ok === false) throw new Error(`"${id}" was refused by the editor`);
482
+ if (input > 0 && Array.isArray(p.actions) && p.actions.length === 0) throw new Error(`"${id}" was emptied by the editor`);
483
+ }
484
+ } finally {
485
+ this.relaying = false;
486
+ }
487
+ if (isContentMutation({ id, type: 2, params })) this.markDirty();
488
+ },
489
+ snapshot: () => this.latest(),
490
+ load: (snapshot) => {
491
+ const s2 = snapshot;
492
+ this.stored = s2;
493
+ this.savedFingerprint = contentFingerprint(s2);
494
+ this.relaying = true;
495
+ try {
496
+ for (const view of this.views.values()) view.port.remount(s2);
497
+ this.parked?.remount(s2);
498
+ } finally {
499
+ this.relaying = false;
500
+ }
501
+ },
502
+ undoTargets: () => {
503
+ const out = [];
504
+ for (const view of this.views.values()) {
505
+ const unitId = this.unitIdOf(view);
506
+ const service = view.port.undoRedo?.();
507
+ if (unitId && service) out.push({ service, unitId });
508
+ }
509
+ return out;
510
+ },
511
+ markDirty: () => this.markDirty()
512
+ };
513
+ /** Join the document's ordered-log room once (C3). Exclusive with `startCollab`. */
514
+ startOtRoom(factory) {
515
+ if (this.ot || this.collab) return Promise.resolve();
516
+ if (!this.otStarting) {
517
+ this.otStarting = factory(this.otPort, (awareness) => {
518
+ this.awareness = awareness;
519
+ for (const listener of Array.from(this.awarenessListeners)) listener();
520
+ }).then((handle) => {
521
+ if (!handle) return;
522
+ if (this.views.size === 0) {
523
+ handle.stop();
524
+ return;
525
+ }
526
+ this.ot = handle;
527
+ }).finally(() => {
528
+ this.otStarting = null;
529
+ });
530
+ }
531
+ return this.otStarting;
532
+ }
533
+ /** Which co-editing room this document is in right now. */
534
+ collabMode() {
535
+ return this.ot ? "ordered" : this.collab ? "relay" : null;
536
+ }
391
537
  isCollabHost() {
538
+ if (this.ot) return this.ot.isSaver();
392
539
  return this.collab ? this.collab.isHost() : true;
393
540
  }
394
541
  getAwareness() {
@@ -416,6 +563,8 @@ var DocumentModel = class {
416
563
  rest() {
417
564
  this.collab?.stop();
418
565
  this.collab = null;
566
+ this.ot?.stop();
567
+ this.ot = null;
419
568
  this.awareness = null;
420
569
  }
421
570
  closed = false;