Skip to content

Commit c42b637

Browse files
committed
Merge branch 'main' into nick/sd-2045-bug-tablessplitcell-removes-neighboring-cell-content-instead
2 parents 59e15ca + cd7a69b commit c42b637

12 files changed

Lines changed: 351 additions & 32 deletions

File tree

apps/docs/core/superdoc/configuration.mdx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -162,21 +162,20 @@ new SuperDoc({
162162
<ParamField path="modules.collaboration" type="Object">
163163
Real-time collaboration settings
164164
<Expandable title="properties" defaultOpen>
165-
<ParamField path="url" type="string" required>
166-
WebSocket server URL
165+
<ParamField path="modules.collaboration.ydoc" type="Y.Doc" required>
166+
Shared Yjs document instance for this collaborative session.
167167
</ParamField>
168-
<ParamField path="providerType" type="string" default="'hocuspocus'">
169-
Provider implementation
170-
</ParamField>
171-
<ParamField path="token" type="string">
172-
Authentication token
173-
</ParamField>
174-
<ParamField path="params" type="Object">
175-
Additional connection parameters
168+
<ParamField path="modules.collaboration.provider" type="Object" required>
169+
Yjs-compatible provider instance (for example LiveblocksYjsProvider or WebsocketProvider from y-websocket).
176170
</ParamField>
177171
</Expandable>
178172
</ParamField>
179173

174+
<Note>
175+
SuperDoc uses a provider-agnostic collaboration contract: `modules.collaboration = { ydoc, provider }`.
176+
Provider setup remains in your app code. See [Collaboration configuration](/modules/collaboration/configuration) and [Collaboration guides](/modules/collaboration/overview).
177+
</Note>
178+
180179
### Comments module
181180

182181
<ParamField path="modules.comments" type="Object">

apps/docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
]
111111
},
112112
"document-engine/sdks",
113+
"document-engine/sdk-collaboration-sessions",
113114
"document-engine/cli",
114115
"document-engine/mcp"
115116
]
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
title: SDK + Collaboration Sessions
3+
sidebarTitle: SDK + Collaboration
4+
description: How SDK sessions relate to Yjs collaboration sessions in SuperDoc
5+
keywords: "sdk collaboration, yjs session, superdoc session, liveblocks sdk, hocuspocus sdk"
6+
---
7+
8+
Use this guide when you are combining:
9+
10+
- The SuperDoc SDK (`@superdoc-dev/sdk` or `superdoc-sdk`) for headless/document automation
11+
- SuperDoc real-time collaboration (Yjs providers like Liveblocks, Hocuspocus, or SuperDoc Yjs)
12+
13+
## Two session types
14+
15+
These are different concepts:
16+
17+
- **Collaboration session**: a shared Yjs room/document (`ydoc` + provider) used by browser editors.
18+
- **SDK session**: a SuperDoc Document Engine editing session created by `doc.open`.
19+
20+
The SDK does not directly attach to a provider object. It operates through Document Engine sessions.
21+
22+
## SuperDoc JS collaboration contract
23+
24+
In the browser, SuperDoc uses a provider-agnostic contract:
25+
26+
```javascript
27+
import * as Y from "yjs";
28+
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
29+
import { SuperDoc } from "superdoc";
30+
31+
const ydoc = new Y.Doc();
32+
const provider = new LiveblocksYjsProvider(room, ydoc);
33+
34+
new SuperDoc({
35+
selector: "#editor",
36+
modules: {
37+
collaboration: { ydoc, provider },
38+
},
39+
});
40+
```
41+
42+
See [Collaboration Configuration](/modules/collaboration/configuration) for details.
43+
44+
## Using SDK sessions with collaboration-enabled documents
45+
46+
The SDK can work against a document that is also edited collaboratively, but interaction is through SDK/CLI session APIs.
47+
48+
```ts
49+
import { createSuperDocClient } from "@superdoc-dev/sdk";
50+
51+
const client = createSuperDocClient();
52+
await client.connect();
53+
54+
await client.doc.open({ doc: "./contract.docx" });
55+
56+
const sessions = await client.doc.session.list();
57+
console.log(sessions);
58+
```
59+
60+
You can target a specific active SDK session:
61+
62+
```ts
63+
await client.doc.session.setDefault({ id: "session-id" });
64+
await client.doc.info();
65+
```
66+
67+
## Provider choice is unchanged
68+
69+
Provider setup still happens in your app using SuperDoc JS:
70+
71+
- [Liveblocks](/guides/collaboration/liveblocks)
72+
- [Hocuspocus](/guides/collaboration/hocuspocus)
73+
- [SuperDoc Yjs](/guides/collaboration/superdoc-yjs)
74+
75+
The SDK integration pattern does not change by provider.
76+
77+
## Practical guidance
78+
79+
- Use collaboration providers for multi-user real-time editing UX.
80+
- Use SDK sessions for backend automation, workflows, and deterministic operations.
81+
- Keep the distinction explicit in your architecture: provider state vs SDK/CLI session state.

apps/docs/document-engine/sdks.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,4 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p
323323
324324
- [Document API](/document-api/overview) — the in-browser API that defines the operation set
325325
- [CLI](/document-engine/cli) — use the same operations from the terminal
326+
- [SDK + Collaboration Sessions](/document-engine/sdk-collaboration-sessions) — how SDK sessions relate to Yjs collaboration sessions

apps/docs/guides/collaboration/hocuspocus.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ provider.on("synced", () => {
8585
});
8686
```
8787

88+
SuperDoc JS always uses the same collaboration contract, regardless of provider: `modules.collaboration = { ydoc, provider }`.
89+
8890
## React example
8991

9092
```tsx

apps/docs/guides/collaboration/liveblocks.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ provider.on("sync", (synced) => {
5959
});
6060
```
6161

62+
SuperDoc JS always uses the same collaboration contract, regardless of provider: `modules.collaboration = { ydoc, provider }`.
63+
6264
## Room management
6365

6466
Each **room** represents a collaborative session. Use unique room IDs for different documents:

apps/docs/guides/collaboration/superdoc-yjs.mdx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,28 +50,37 @@ fastify.listen({ port: 3050 });
5050

5151
### Client
5252

53+
```bash
54+
npm install yjs y-websocket
55+
```
56+
5357
```javascript
58+
import * as Y from 'yjs';
59+
import { WebsocketProvider } from 'y-websocket';
5460
import { SuperDoc } from 'superdoc';
5561

62+
const documentId = 'document-123';
63+
const ydoc = new Y.Doc();
64+
const provider = new WebsocketProvider(
65+
`ws://localhost:3050/doc/${documentId}`,
66+
documentId,
67+
ydoc
68+
);
69+
5670
new SuperDoc({
5771
selector: '#editor',
58-
document: {
59-
id: 'document-123',
60-
type: 'docx'
61-
},
6272
user: {
6373
name: 'John Smith',
6474
email: 'john@example.com'
6575
},
6676
modules: {
67-
collaboration: {
68-
url: 'ws://localhost:3050/doc',
69-
token: 'auth-token'
70-
}
77+
collaboration: { ydoc, provider }
7178
}
7279
});
7380
```
7481

82+
The SuperDoc JS collaboration contract is provider-agnostic: always pass `{ ydoc, provider }` in `modules.collaboration`.
83+
7584
## Builder API
7685

7786
The `CollaborationBuilder` provides a fluent interface:

apps/docs/modules/collaboration/configuration.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ keywords: "collaboration configuration, superdoc events, awareness, user presenc
66

77
All configuration options and events for real-time collaboration.
88

9+
If you need to combine collaboration with headless SDK automation, see [SDK + Collaboration Sessions](/document-engine/sdk-collaboration-sessions).
10+
911
## Configuration
1012

1113
**You manage the Yjs provider** — works with SuperDoc Yjs, Liveblocks, Hocuspocus, etc.

apps/docs/modules/collaboration/overview.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,5 @@ provider.on("sync", (synced) => {
6969
```
7070

7171
See the [full quickstart guide](/modules/collaboration/quickstart) for framework-specific examples (React, Vue, Vanilla JS).
72+
73+
If you are also using the SDK for backend automation, see [SDK + Collaboration Sessions](/document-engine/sdk-collaboration-sessions).

packages/super-editor/src/document-api-adapters/tables-adapter.regressions.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,137 @@ describe('tables-adapter regressions', () => {
348348
expect(tr.insert).toHaveBeenCalledWith(expectedInsertPos, expect.anything());
349349
});
350350

351+
it('deletes shiftLeft cells without appending a trailing replacement cell', () => {
352+
const editor = makeTableEditor();
353+
const tr = editor.state.tr as unknown as {
354+
delete: ReturnType<typeof vi.fn>;
355+
insert: ReturnType<typeof vi.fn>;
356+
setNodeMarkup: ReturnType<typeof vi.fn>;
357+
};
358+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
359+
const targetCellOffset = TableMap.get(tableNode).map[0]!;
360+
const targetCellNode = tableNode.nodeAt(targetCellOffset) as ProseMirrorNode;
361+
const expectedStart = 1 + targetCellOffset;
362+
const expectedEnd = expectedStart + targetCellNode.nodeSize;
363+
364+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-1', mode: 'shiftLeft' });
365+
expect(result.success).toBe(true);
366+
expect(tr.delete).toHaveBeenCalledWith(expectedStart, expectedEnd);
367+
expect(tr.insert).not.toHaveBeenCalled();
368+
expect(tr.setNodeMarkup).toHaveBeenCalledWith(
369+
expect.any(Number),
370+
null,
371+
expect.objectContaining({
372+
colspan: 2,
373+
}),
374+
);
375+
});
376+
377+
it('deletes the row trailing cell for shiftLeft without appending a replacement cell', () => {
378+
const editor = makeTableEditor();
379+
const tr = editor.state.tr as unknown as {
380+
delete: ReturnType<typeof vi.fn>;
381+
insert: ReturnType<typeof vi.fn>;
382+
setNodeMarkup: ReturnType<typeof vi.fn>;
383+
};
384+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
385+
const targetCellOffset = TableMap.get(tableNode).map[1]!;
386+
const targetCellNode = tableNode.nodeAt(targetCellOffset) as ProseMirrorNode;
387+
const expectedStart = 1 + targetCellOffset;
388+
const expectedEnd = expectedStart + targetCellNode.nodeSize;
389+
390+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-2', mode: 'shiftLeft' });
391+
expect(result.success).toBe(true);
392+
expect(tr.delete).toHaveBeenCalledWith(expectedStart, expectedEnd);
393+
expect(tr.insert).not.toHaveBeenCalled();
394+
expect(tr.setNodeMarkup).toHaveBeenCalledWith(
395+
expect.any(Number),
396+
null,
397+
expect.objectContaining({
398+
colspan: 2,
399+
}),
400+
);
401+
});
402+
403+
it('falls back to trailing replacement cell when shiftLeft would widen a vertically merged trailing cell', () => {
404+
const editor = makeTableEditor();
405+
const tr = editor.state.tr as unknown as {
406+
delete: ReturnType<typeof vi.fn>;
407+
insert: ReturnType<typeof vi.fn>;
408+
setNodeMarkup: ReturnType<typeof vi.fn>;
409+
};
410+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
411+
const firstRow = tableNode.child(0) as ProseMirrorNode;
412+
const trailingCell = firstRow.child(1) as unknown as { attrs: Record<string, unknown> };
413+
trailingCell.attrs.rowspan = 2;
414+
trailingCell.attrs.tableCellProperties = { vMerge: 'restart' };
415+
416+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-1', mode: 'shiftLeft' });
417+
expect(result.success).toBe(true);
418+
expect(tr.insert).toHaveBeenCalledWith(expect.any(Number), expect.anything());
419+
expect(tr.setNodeMarkup).not.toHaveBeenCalled();
420+
});
421+
422+
it('shiftLeft vMerge fallback inserts at the post-delete row end without double-mapping', () => {
423+
// Regression: rowEndPos was computed from the post-delete doc (tr.doc) but then
424+
// passed through tr.mapping.map() which maps old→new, double-shifting the position.
425+
const editor = makeTableEditor();
426+
427+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
428+
const firstRow = tableNode.child(0) as ProseMirrorNode;
429+
const trailingCell = firstRow.child(1) as unknown as { attrs: Record<string, unknown> };
430+
trailingCell.attrs.rowspan = 2;
431+
trailingCell.attrs.tableCellProperties = { vMerge: 'restart' };
432+
433+
const cell1 = firstRow.child(0);
434+
const deletionStart = 2; // absolute position of cell-1
435+
const deletionSize = cell1.nodeSize; // 9
436+
437+
// Build post-delete table: row 0 only contains the vMerge cell.
438+
const postDeleteRow0 = createNode('tableRow', [firstRow.child(1)], {
439+
attrs: { ...(firstRow.attrs as Record<string, unknown>) },
440+
isBlock: true,
441+
inlineContent: false,
442+
});
443+
const postDeleteTable = createNode('table', [postDeleteRow0, tableNode.child(1)], {
444+
attrs: { ...(tableNode.attrs as Record<string, unknown>) },
445+
isBlock: true,
446+
inlineContent: false,
447+
});
448+
const postDeleteDoc = createNode('doc', [postDeleteTable], { isBlock: false });
449+
450+
const trObj = editor.state.tr as unknown as {
451+
delete: ReturnType<typeof vi.fn>;
452+
insert: ReturnType<typeof vi.fn>;
453+
mapping: { map: (p: number) => number; maps: unknown[]; slice: () => { map: (p: number) => number } };
454+
doc: ProseMirrorNode;
455+
};
456+
457+
// Swap tr.doc to the post-delete document when delete is called.
458+
trObj.delete = vi.fn(() => {
459+
trObj.doc = postDeleteDoc;
460+
return trObj;
461+
});
462+
463+
// Simulate real deletion mapping: positions at or after the deleted range shift left.
464+
trObj.mapping.map = (pos: number) => {
465+
if (pos < deletionStart) return pos;
466+
if (pos < deletionStart + deletionSize) return deletionStart;
467+
return pos - deletionSize;
468+
};
469+
470+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-1', mode: 'shiftLeft' });
471+
expect(result.success).toBe(true);
472+
expect(trObj.insert).toHaveBeenCalled();
473+
474+
// Post-delete row 0 nodeSize = 2 + cell-2 size (9) = 11.
475+
// rowEndPos = tablePos(0) + 1 + 11 = 12.
476+
// Correct insert = 12 - 1 = 11 (just inside the row).
477+
// Old buggy code: tr.mapping.map(11) = 11 - 9 = 2 — wrong position!
478+
const insertPos = trObj.insert.mock.calls[0]![0];
479+
expect(insertPos).toBe(11);
480+
});
481+
351482
it('keeps table grid widths in sync when distributing columns', () => {
352483
const editor = makeTableEditor();
353484
const tr = editor.state.tr as unknown as { setNodeMarkup: ReturnType<typeof vi.fn> };

0 commit comments

Comments
 (0)