@matrajs/mcp 1.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/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/cli.js +362 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +72 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +236 -0
- package/dist/index.js.map +1 -0
- package/docs/benchmarks-full.md +207 -0
- package/docs/changelog.md +222 -0
- package/docs/contributing.md +87 -0
- package/docs/design.md +107 -0
- package/docs/docs-ai.md +90 -0
- package/docs/docs-api.md +149 -0
- package/docs/docs-benchmarks.md +104 -0
- package/docs/docs-collab.md +128 -0
- package/docs/docs-commands.md +162 -0
- package/docs/docs-document-model.md +103 -0
- package/docs/docs-extensions.md +168 -0
- package/docs/docs-first-editor.md +136 -0
- package/docs/docs-frameworks.md +138 -0
- package/docs/docs-index.md +66 -0
- package/docs/docs-installation.md +50 -0
- package/docs/docs-mcp.md +66 -0
- package/docs/docs-position-mapping.md +112 -0
- package/docs/docs-react.md +91 -0
- package/docs/docs-recipes.md +504 -0
- package/docs/docs-shortcuts.md +94 -0
- package/docs/docs-solid.md +82 -0
- package/docs/docs-styling.md +61 -0
- package/docs/docs-svelte.md +86 -0
- package/docs/docs-versions.md +103 -0
- package/docs/docs-vue.md +126 -0
- package/docs/engine.md +315 -0
- package/docs/index.json +205 -0
- package/docs/readme.md +604 -0
- package/docs/releasing.md +65 -0
- package/docs/security.md +72 -0
- package/package.json +54 -0
package/docs/design.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Matra — core API design
|
|
2
|
+
|
|
3
|
+
Status: draft. Nothing published beyond reserved package names.
|
|
4
|
+
|
|
5
|
+
## Principles
|
|
6
|
+
|
|
7
|
+
1. **The engine never leaks.** No ProseMirror type appears in a public signature.
|
|
8
|
+
Raw access exists at `editor.unsafe`, is excluded from semver, and every use of
|
|
9
|
+
it is a bug report against this API.
|
|
10
|
+
2. **Plain data, plain functions.** Definitions are object literals. Commands are
|
|
11
|
+
ordinary functions. No `this`, no classes, no `.extend()` inheritance chains.
|
|
12
|
+
3. **Types are inferred, never declared twice.** Adding an extension to the array
|
|
13
|
+
adds its commands to `editor.commands` with full argument types. There is no
|
|
14
|
+
module augmentation step and no interface to keep in sync.
|
|
15
|
+
4. **Async is a first-class problem.** Positions drift while an AI call is in
|
|
16
|
+
flight. The API makes that safe by default rather than leaving it to callers.
|
|
17
|
+
|
|
18
|
+
## Three primitives
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const heading = defineNode({
|
|
22
|
+
name: 'heading',
|
|
23
|
+
content: 'inline*',
|
|
24
|
+
group: 'block',
|
|
25
|
+
attrs: { level: { default: 1 } },
|
|
26
|
+
parseHTML: [{ tag: 'h1' }, { tag: 'h2' }, { tag: 'h3' }],
|
|
27
|
+
toDOM: (node) => [`h${node.attrs?.level}`, 0],
|
|
28
|
+
commands: {
|
|
29
|
+
setHeading: (ctx, level: 1 | 2 | 3) => ctx.setBlockType('heading', { level }),
|
|
30
|
+
},
|
|
31
|
+
keys: { 'Mod-Alt-1': 'setHeading' },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const bold = defineMark({
|
|
35
|
+
name: 'bold',
|
|
36
|
+
parseHTML: [{ tag: 'strong' }, { style: 'font-weight=bold' }],
|
|
37
|
+
toDOM: () => ['strong', 0],
|
|
38
|
+
commands: { toggleBold: (ctx) => ctx.toggleMark('bold') },
|
|
39
|
+
keys: { 'Mod-b': 'toggleBold' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const editor = createEditor({ extensions: [heading, bold] })
|
|
43
|
+
editor.commands.setHeading(2) // typed
|
|
44
|
+
editor.commands.setHeading(9) // compile error
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`defineExtension` is the third: no schema contribution, just commands, keys,
|
|
48
|
+
state and lifecycle. Everything that isn't a node or a mark.
|
|
49
|
+
|
|
50
|
+
## What differs from TipTap, and why
|
|
51
|
+
|
|
52
|
+
| TipTap | Matra | Reason |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `addCommands() { return { cmd: () => ({ commands }) => ... } }` | `commands: { cmd: (ctx, ...args) => boolean }` | Three levels of currying collapse to one function. Arguments get real types instead of being erased. |
|
|
55
|
+
| `this.editor`, `this.options`, `this.storage` | everything passed as arguments | `this` is bound differently per hook and resists typing. Explicit arguments always work. |
|
|
56
|
+
| `Extension.create().extend()` | plain objects, composed | Inheritance chains make it impossible to know what a definition finally contains. |
|
|
57
|
+
| Commands merged into one global namespace | same, but collisions are a **compile error** | Two extensions declaring `toggleBold` should not silently shadow. |
|
|
58
|
+
| `declare module` augmentation for types | inferred from the extensions array | The augmentation step is the most common source of broken types in TipTap projects. |
|
|
59
|
+
| PM types in public API (`Node`, `Mark`, `EditorState`) | plain JSON `DocNode` | Swapping or upgrading the engine becomes possible without a breaking release. |
|
|
60
|
+
|
|
61
|
+
## Async and position drift
|
|
62
|
+
|
|
63
|
+
The hard problem in an AI editor: you send a paragraph to a model, the user keeps
|
|
64
|
+
typing, the response arrives three seconds later, and every position you captured
|
|
65
|
+
is now wrong. Written naively this corrupts documents.
|
|
66
|
+
|
|
67
|
+
`ctx.mark()` takes a marker that maps positions through every intervening change:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const rewrite = defineExtension({
|
|
71
|
+
name: 'ai-rewrite',
|
|
72
|
+
commands: {
|
|
73
|
+
rewrite: (ctx) => {
|
|
74
|
+
const marker = ctx.mark()
|
|
75
|
+
const range = ctx.selection
|
|
76
|
+
const text = ctx.doc // read what we need now
|
|
77
|
+
|
|
78
|
+
void ai.rewrite(text).then((result) => {
|
|
79
|
+
// the user may have typed anywhere in the meantime
|
|
80
|
+
editor.commands.replaceRange(marker.mapRange(range), result)
|
|
81
|
+
})
|
|
82
|
+
return true
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This is why the AI layer belongs in core's design even though it ships as a
|
|
89
|
+
separate package. Retrofitting position mapping later is not possible.
|
|
90
|
+
|
|
91
|
+
## Open questions
|
|
92
|
+
|
|
93
|
+
- **Collaboration.** Y.js maps positions through its own type. `PosMarker` and
|
|
94
|
+
Y.js relative positions need to be one concept, not two. Decide before 0.1.
|
|
95
|
+
- **`batch()` rollback.** Rolling back when any command returns false is stated
|
|
96
|
+
in the types but interacts with input rules in ways not yet worked out.
|
|
97
|
+
- **Node views.** Framework-specific by nature. Core should expose a renderer
|
|
98
|
+
interface that `@matrajs/vue` and `@matrajs/react` implement, but the shape of
|
|
99
|
+
that interface is undecided.
|
|
100
|
+
- **Schema ordering.** ProseMirror's first node becomes the doc's default content.
|
|
101
|
+
Currently implicit via `priority`; may need to be explicit.
|
|
102
|
+
|
|
103
|
+
## Verified
|
|
104
|
+
|
|
105
|
+
`packages/core/src/types.test-d.ts` is a compile-time test. It asserts that valid
|
|
106
|
+
calls typecheck and that wrong arity, wrong argument types, and unknown commands
|
|
107
|
+
all fail. Run with `tsc --noEmit --strict`.
|
package/docs/docs-ai.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# AI
|
|
2
|
+
|
|
3
|
+
Streaming edits that survive concurrent typing: the stream you supply, askAi, and what cancel, accept and reject each do.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm i @matrajs/ai
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`@matrajs/ai` streams a rewrite into the selected range and keeps that range correct while it arrives. Someone can carry on typing above, below or inside the selection and the streamed text still lands on the words they picked, because every chunk is applied to a **re-resolved** range rather than to the two numbers captured when the request started. That is [position mapping](https://matrajs.com/docs/position-mapping) doing the work, and it is the whole reason this package exists.
|
|
10
|
+
|
|
11
|
+
> Note: This package is [commercial](https://matrajs.com/pricing). Free to evaluate, develop against, test with, teach with and use in personal projects · a subscription buys running it in production. Nothing phones home and there is no runtime licence check.
|
|
12
|
+
|
|
13
|
+
## You supply the stream
|
|
14
|
+
|
|
15
|
+
The extension never talks to a model. It takes an `AiStream` — any function returning an async iterable of strings — so the key stays on your server and the provider stays yours to change.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
import { createEditor, starterKit } from '@matrajs/core'
|
|
19
|
+
import { ai } from '@matrajs/ai'
|
|
20
|
+
|
|
21
|
+
const editor = createEditor({
|
|
22
|
+
extensions: [
|
|
23
|
+
...starterKit,
|
|
24
|
+
ai({
|
|
25
|
+
async *stream({ text, instruction, signal }) {
|
|
26
|
+
const response = await fetch('/api/rewrite', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ text, instruction }),
|
|
30
|
+
signal,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const stream = response.body!.pipeThrough(new TextDecoderStream())
|
|
34
|
+
for await (const chunk of stream) yield chunk
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
] as const,
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Each chunk you yield replaces the range with *everything received so far*, so yield deltas rather than the whole answer again. `signal` aborts when the session is cancelled or the editor is destroyed · pass it to `fetch` and a cancelled rewrite stops costing tokens straight away.
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
| Command | What it does |
|
|
46
|
+
|
|
47
|
+
| `askAi(instruction)` | Starts a session over the current selection. Returns `false` on an empty selection, and `false` if a session is already streaming — one at a time. |
|
|
48
|
+
|
|
49
|
+
| `cancelAi()` | Aborts the stream. **What already arrived stays in the document** · nothing further is applied. |
|
|
50
|
+
|
|
51
|
+
| `acceptAi()` | Ends the session and keeps the result. The document is not touched. |
|
|
52
|
+
|
|
53
|
+
| `rejectAi()` | Aborts and puts the selection back over the rewritten range. The original text comes back by undoing. |
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
editor.commands.select({ from, to }) // a rewrite needs a selection
|
|
57
|
+
editor.commands.askAi('make this shorter')
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
> Note: Chunks are applied as they arrive, so the stream is several history steps rather than one. A reject followed by undo restores the paragraph · that is the path to wire to a *discard* button.
|
|
61
|
+
|
|
62
|
+
## Status, for spinners and toasts
|
|
63
|
+
|
|
64
|
+
`onStatus` is called on every transition, with the session as it stands.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
ai({
|
|
68
|
+
stream,
|
|
69
|
+
onStatus: (session) => {
|
|
70
|
+
setBusy(session.status === 'streaming')
|
|
71
|
+
if (session.status === 'error') toast(session.error!.message)
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Field | Type | |
|
|
77
|
+
|
|
78
|
+
| `id` | `number` | One per session. A late chunk from a superseded session is discarded. |
|
|
79
|
+
|
|
80
|
+
| `status` | `'idle' | 'streaming' | 'done' | 'error' | 'cancelled'` | |
|
|
81
|
+
|
|
82
|
+
| `range` | `Range` | Where the rewrite is going, re-resolved as of now. |
|
|
83
|
+
|
|
84
|
+
| `received` | `string` | Everything that has arrived so far. |
|
|
85
|
+
|
|
86
|
+
| `error` | `Error?` | Set when `status` is `'error'`. |
|
|
87
|
+
|
|
88
|
+
## What a failure does
|
|
89
|
+
|
|
90
|
+
A stream that throws reports `'error'` with the thrown error, and stops. It does not throw out of the command and it does not tear the document up: whatever had already streamed stays, exactly as if the model had stopped early. A provider having a bad afternoon must not take an editor down with it.
|
package/docs/docs-api.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# API
|
|
2
|
+
|
|
3
|
+
The editor object, the core commands, and the command context, in one page.
|
|
4
|
+
|
|
5
|
+
Everything public, on one page. No engine type appears in any signature here · that is the contract, not a coincidence.
|
|
6
|
+
|
|
7
|
+
## createEditor(options)
|
|
8
|
+
|
|
9
|
+
| Option | Type | |
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
options.map(([name, type, note]) => (
|
|
13
|
+
|
|
14
|
+
| | | |
|
|
15
|
+
))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
## Editor
|
|
19
|
+
|
|
20
|
+
| Member | Returns | |
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
editorApi.map(([name, type, note]) => (
|
|
24
|
+
|
|
25
|
+
| | | |
|
|
26
|
+
))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
## Core commands
|
|
30
|
+
|
|
31
|
+
Always present, whatever extensions you loaded. Every one returns a boolean.
|
|
32
|
+
|
|
33
|
+
| Command | Arguments | |
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
coreCommands.map(([name, args, note]) => (
|
|
37
|
+
|
|
38
|
+
| | | |
|
|
39
|
+
))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
> Note: A command never throws. Positions that are not finite integers inside the document return false · `NaN` included, which slips past naive range checks because both `NaN size` are false.
|
|
43
|
+
|
|
44
|
+
## Ctx · what a command receives
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
ctxApi.map(([name, note]) => (
|
|
48
|
+
|
|
49
|
+
| | |
|
|
50
|
+
))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
## What an extension may declare
|
|
54
|
+
|
|
55
|
+
commandsPlain functions of (ctx, …args) => boolean. Their types land on editor.commands and editor.can.
|
|
56
|
+
keysA binding to a command name or a function · . A binding that returns false lets the next one try.
|
|
57
|
+
inputRulesA RegExp on the text before the caret and a handler. One undo step each.
|
|
58
|
+
attributesAttributes added to nodes and marks defined elsewhere, rendered and parsed.
|
|
59
|
+
handlePaste · handleDropClaim a paste or a drop before the editor parses it. Return true to keep it.
|
|
60
|
+
filterChangeVeto a change before it lands. Nothing is applied, recorded or redrawn when it
|
|
61
|
+
returns false.
|
|
62
|
+
decorationsDraw over the document: inline attributes, node attributes, widgets. Never in the
|
|
63
|
+
document.
|
|
64
|
+
stateinit and apply, reduced on every transaction · read with editor.extensionState(name).
|
|
65
|
+
nodeViewsRender nodes defined elsewhere with your own DOM, keyed by node name.
|
|
66
|
+
onCreate · onChange · onDestroyLifecycle, each handed the editor. onCreate runs when the editor mounts.
|
|
67
|
+
priorityLoad order. Higher loads first; a later binding for the same key wins.
|
|
68
|
+
nodes · marksA node declares content, group, attrs, parseDOM, toDOM, nodeView, listItem, marks, code; a mark declares inclusive, excludes, parseDOM, toDOM.
|
|
69
|
+
|
|
70
|
+
## Types worth knowing
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
type Pos = number & { readonly __pos: unique symbol }
|
|
74
|
+
type Range = { from: Pos; to: Pos }
|
|
75
|
+
|
|
76
|
+
interface DocNode {
|
|
77
|
+
type: string
|
|
78
|
+
attrs?: Record<string, unknown>
|
|
79
|
+
content?: DocNode[]
|
|
80
|
+
text?: string
|
|
81
|
+
marks?: DocMark[]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`Pos` is branded so a raw number cannot be passed by accident. Cast at the boundary where you genuinely know the number is a position.
|
|
86
|
+
|
|
87
|
+
## Writing a position down
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
import { pos, range } from '@matrajs/core'
|
|
91
|
+
|
|
92
|
+
pos(0) // number -> Pos
|
|
93
|
+
range(1, 6) // (number, number) -> Range
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`Pos` is branded so that arithmetic on one does not typecheck · these are how you write a literal without a cast. Not for carrying a position across an `await`: that is ctx.mark().
|
|
97
|
+
|
|
98
|
+
## Functions that need no editor
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
import { toMarkdown, fromMarkdown, tableOfContents, assignIds } from '@matrajs/core'
|
|
102
|
+
|
|
103
|
+
toMarkdown(doc) // DocNode -> string
|
|
104
|
+
fromMarkdown(source) // string -> DocNode
|
|
105
|
+
tableOfContents(doc) // DocNode -> TocEntry[]
|
|
106
|
+
assignIds(doc, options) // DocNode -> DocNode with stable block ids
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
None of these touches the DOM, so all four run on a server.
|
|
110
|
+
|
|
111
|
+
.api {
|
|
112
|
+
overflow-x: auto;
|
|
113
|
+
margin: 0 0 18px;
|
|
114
|
+
}
|
|
115
|
+
.api table {
|
|
116
|
+
border-collapse: collapse;
|
|
117
|
+
width: 100%;
|
|
118
|
+
font-size: 13.5px;
|
|
119
|
+
}
|
|
120
|
+
.api td,
|
|
121
|
+
.api th {
|
|
122
|
+
text-align: left;
|
|
123
|
+
padding: 9px 12px;
|
|
124
|
+
background-image: var(--dash-h);
|
|
125
|
+
background-size: 100% 1px;
|
|
126
|
+
background-position: 0 100%;
|
|
127
|
+
background-repeat: no-repeat;
|
|
128
|
+
vertical-align: top;
|
|
129
|
+
}
|
|
130
|
+
.api th {
|
|
131
|
+
font-family: var(--font-mono);
|
|
132
|
+
font-size: 10px;
|
|
133
|
+
letter-spacing: 0.08em;
|
|
134
|
+
text-transform: uppercase;
|
|
135
|
+
color: var(--ink-faint);
|
|
136
|
+
font-weight: 400;
|
|
137
|
+
}
|
|
138
|
+
.api td.n {
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
color: var(--ink);
|
|
141
|
+
}
|
|
142
|
+
.api td.t {
|
|
143
|
+
white-space: nowrap;
|
|
144
|
+
color: var(--indigo);
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
}
|
|
147
|
+
.api td.d {
|
|
148
|
+
color: var(--ink-soft);
|
|
149
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Benchmarks
|
|
2
|
+
|
|
3
|
+
How the numbers on the landing page were measured, what they leave out, and how to run the harness yourself.
|
|
4
|
+
|
|
5
|
+
Every figure on the landing page comes from one run of one harness with all four editors mounted in the same page. Numbers gathered from different browsers do not belong in the same table, so they are not gathered that way.
|
|
6
|
+
|
|
7
|
+
## Method
|
|
8
|
+
|
|
9
|
+
- WebKit, one page, one run · all four editors mounted side by side.
|
|
10
|
+
|
|
11
|
+
- Median of seven samples, after five warm-up rounds.
|
|
12
|
+
|
|
13
|
+
- A forced layout read every round, so the DOM work is really done.
|
|
14
|
+
|
|
15
|
+
- Each published figure is the median of three such runs.
|
|
16
|
+
|
|
17
|
+
That is not ceremony. A single sample moved one metric by 6× between runs on a change that could not affect it, and the keystroke row swings by about 30% between runs for every editor here. One sample is a story, not a measurement.
|
|
18
|
+
|
|
19
|
+
## Milliseconds, lower is better
|
|
20
|
+
|
|
21
|
+
| Operation | No editor | Matra | Tiptap | Lexical | Slate |
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
rows.map(([op, floor, a, b, c, d]) => (
|
|
25
|
+
|
|
26
|
+
| | | | | | |
|
|
27
|
+
))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
## These numbers are slower than the ones that were here before
|
|
31
|
+
|
|
32
|
+
The machine is why. Same harness, same browser, a different day: Lexical parsed the same document in 34.7 ms one week and 76.9 ms the next, without a line of it changing. Only the comparison inside a single run means anything, which is exactly why all four editors are mounted in one page and measured in one pass. Milliseconds from two different runs are two different measurements wearing the same unit.
|
|
33
|
+
|
|
34
|
+
## The keystroke row changed hands
|
|
35
|
+
|
|
36
|
+
It used to be the row Matra lost, and losing it was fair: Lexical was ahead in every run. Measured back to back against the same rivals in the same session, before and after:
|
|
37
|
+
|
|
38
|
+
| One keystroke | Before | After |
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
keystroke.map(([size, before, after]) => (
|
|
42
|
+
|
|
43
|
+
| | | **** |
|
|
44
|
+
))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Three things were costing it, all three found by profiling rather than by reading:
|
|
48
|
+
|
|
49
|
+
- **Every ancestor of an edit was rebuilt by cutting.** A paragraph changes, and each ancestor up to the document was rebuilt around it — cut the run of children in two, append the replacement, append the rest, then add up every child's size to arrive at a total that differed from the old one by exactly one child. On two thousand blocks that is four walks of two thousand children per character. Swapping the one child that moved makes it one array copy and one subtraction.
|
|
50
|
+
|
|
51
|
+
- **The diff reached into the DOM for blocks it had already decided to skip.** It read `childNodes[i]` before asking whether block `i` was inside the edit at all, and for 1999 of 2000 blocks threw the answer away. Ask first, index second.
|
|
52
|
+
|
|
53
|
+
- **Every sixty-fourth keystroke threw the rendered document away.** The position map absorbs each edit rather than rewriting itself, and capped its backlog at sixty-four — past which the whole document was rebuilt from scratch. It was the backlog that had gone stale, not the DOM. That one was a correctness bug in a performance bug's clothing: a rebuild also dropped the state of every mounted node view.
|
|
54
|
+
|
|
55
|
+
Away from the browser's layout cost, in Node against happy-dom, the same three changes take a keystroke on two thousand paragraphs from 0.464 ms to 0.077 ms — and the cost stops tracking the length of the document: 0.052 ms at twenty blocks against 0.077 ms at two thousand.
|
|
56
|
+
|
|
57
|
+
## The "No editor" column
|
|
58
|
+
|
|
59
|
+
The same paragraphs, built by hand into a `contenteditable` div, with no editor in the page at all. Putting a document on screen is mostly the browser making elements and laying them out, and nothing can go faster than that. Two thirds of the 200-paragraph mount row and half of the 2,000-paragraph one is that floor, which is worth knowing before reading a 0.3 ms difference as a result.
|
|
60
|
+
|
|
61
|
+
Every mount is also checked rather than trusted: the harness mounts once more outside the timing and looks for the last paragraph's text on screen. A reconciler that returns before its DOM exists is the cheapest possible way to win this row, and an earlier version of the harness reported Slate's mount as a number when nothing had been drawn at all.
|
|
62
|
+
|
|
63
|
+
## The mount row was wrong, and the harness was why
|
|
64
|
+
|
|
65
|
+
For a while this page said Lexical put an editor on screen faster than Matra. It did not. The harness ran each editor's teardown *inside* the timed function, and the layout read that makes a render measurement real came after it — so an editor whose teardown detaches its DOM had already taken its document off screen before the browser was asked to lay anything out. Lexical's teardown does that. Matra's does not: it drops listeners and leaves the document where it is.
|
|
66
|
+
|
|
67
|
+
So Matra paid for laying out two thousand paragraphs and Lexical did not, on a row where the layout is most of the number. Teardown now runs after the clock stops, every editor pays for its own layout, and both mount rows changed hands. It is the same class of mistake the harness already refused to make for Slate, made one level up — which is the argument for a floor row and a verification pass rather than for trusting a number because it came out of a timer.
|
|
68
|
+
|
|
69
|
+
## What is missing, and why
|
|
70
|
+
|
|
71
|
+
Slate is measured on mount only. Its keystroke goes through a React render that has not happened by the time the timer stops, and the harness checks whether the text on screen changed during the measurement — when it did not, it reports `NOT MEASURED` instead of a number. An earlier version of the harness cheerfully reported Slate at 0.02 ms per keystroke, which was the model update with nothing drawn behind it.
|
|
72
|
+
|
|
73
|
+
Each editor is driven through its own idiomatic API. That is the only fair way to do it and also the caveat worth stating: Lexical and Slate carry the rich-text behaviour their own quick-starts prescribe, which is not the same feature set as either starter kit.
|
|
74
|
+
|
|
75
|
+
## Bundle size
|
|
76
|
+
|
|
77
|
+
One app importing that editor and its rich-text kit, bundled and minified with esbuild, then gzipped. Same method for all four.
|
|
78
|
+
|
|
79
|
+
| | Gzipped | What is in it |
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
sizes.map(([name, size, note]) => (
|
|
83
|
+
|
|
84
|
+
| | | |
|
|
85
|
+
))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
## Run it yourself
|
|
89
|
+
|
|
90
|
+
The harness is in the repository, under `bench/browser`.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
# from a scratch directory
|
|
94
|
+
npm i @tiptap/core @tiptap/starter-kit @tiptap/pm \
|
|
95
|
+
lexical @lexical/rich-text @lexical/html \
|
|
96
|
+
slate slate-react slate-dom slate-history react react-dom esbuild
|
|
97
|
+
cp -r matra/packages/core/dist matra-dist
|
|
98
|
+
cp matra/bench/browser/{bench.src.js,index.html} .
|
|
99
|
+
npx esbuild bench.src.js --bundle --format=esm --outfile=bench.js \
|
|
100
|
+
--define:process.env.NODE_ENV='"production"'
|
|
101
|
+
python3 -m http.server 8899
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Reload two or three times and take the median of the runs as well. If you get different numbers, they are your numbers · that is rather the point of shipping the harness.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Collaboration
|
|
2
|
+
|
|
3
|
+
Step exchange, rebasing and remote cursors over a central authority — with no CRDT and no dependency.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm i @matrajs/collab
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The protocol is the well-trodden one. A client sends the steps it has made together with the version they applied to; the authority accepts them only if that version is still current. A client whose version is stale pulls what it missed, rebases its own unsent work over it, and tries again. Neither side loses an edit.
|
|
10
|
+
|
|
11
|
+
There is no CRDT here and no dependency. Rebasing already lives in the engine — mapping a step over another is what lets a local edit survive a remote one — so collaboration is a version counter and a transport on top of that.
|
|
12
|
+
|
|
13
|
+
> Note: This package is [commercial](https://matrajs.com/pricing). Free to evaluate, develop against, test with, teach with and use in personal projects · a subscription buys running it in production. Nothing phones home and there is no runtime licence check.
|
|
14
|
+
|
|
15
|
+
## The client
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
import { createEditor, starterKit } from '@matrajs/core'
|
|
19
|
+
import { collab } from '@matrajs/collab'
|
|
20
|
+
|
|
21
|
+
const editor = createEditor({
|
|
22
|
+
extensions: [...starterKit, collab({ clientId: 'nahim-1a2b' })] as const,
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Option | Type | |
|
|
27
|
+
|
|
28
|
+
| `clientId` | `string` |
|
|
29
|
+
|
|
30
|
+
| `version` | `number?` | The version this client starts from. Defaults to `0`. |
|
|
31
|
+
|
|
32
|
+
## The authority
|
|
33
|
+
|
|
34
|
+
`Authority` is a plain class with no server attached, so the same object works over a WebSocket, over HTTP polling, in a Durable Object, or in an in-memory channel in a test.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
import { Authority } from '@matrajs/collab'
|
|
38
|
+
|
|
39
|
+
const authority = new Authority((version) => broadcast(version))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Member | |
|
|
43
|
+
|
|
44
|
+
| `version` | How many steps it holds. |
|
|
45
|
+
|
|
46
|
+
| `receive(version, steps)` | Appends if the client is current, and returns whether it was. `false` means *pull, rebase, try again*. Rejecting rather than merging is what keeps the history linear and every client's version meaningful. |
|
|
47
|
+
|
|
48
|
+
| `since(version)` | Everything that happened after that version. ## The loop Two functions, and they are the whole integration.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
import { getVersion, sendableSteps } from '@matrajs/collab'
|
|
52
|
+
|
|
53
|
+
/** Send what the authority has not seen. */
|
|
54
|
+
function push() {
|
|
55
|
+
const sendable = sendableSteps(editor)
|
|
56
|
+
if (!sendable) return true
|
|
57
|
+
|
|
58
|
+
const accepted = authority.receive(sendable.version, sendable.steps)
|
|
59
|
+
if (accepted) editor.commands.confirmCollabSteps(sendable.steps.length)
|
|
60
|
+
return accepted
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Take what we missed and rebase onto it. */
|
|
64
|
+
function pull() {
|
|
65
|
+
const missing = authority.since(getVersion(editor))
|
|
66
|
+
if (missing.length) editor.commands.receiveCollabSteps(missing)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
editor.on('change', () => {
|
|
70
|
+
if (!push()) {
|
|
71
|
+
pull()
|
|
72
|
+
push()
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Export | |
|
|
78
|
+
|
|
79
|
+
| `sendableSteps(editor)` | ``, or `null` when there is nothing outstanding. |
|
|
80
|
+
|
|
81
|
+
| `getVersion(editor)` | The version this client believes it is on. `receiveCollabSteps(steps)` | Applies other clients' steps. Your own are skipped — they are already in the document, and applying them twice would duplicate the edit. |
|
|
82
|
+
|
|
83
|
+
| `confirmCollabSteps(count)` | The authority took this many · stop tracking them as unconfirmed. |
|
|
84
|
+
|
|
85
|
+
> Note: A step that no longer applies is **dropped rather than thrown**. One malformed message from a peer must not take an editor down, and a hostile one must not be able to try.
|
|
86
|
+
|
|
87
|
+
## Remote cursors
|
|
88
|
+
|
|
89
|
+
A second extension, because presence is optional and costs a little to keep.
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
import { colorFor, remoteCursorCSS, remoteCursors } from '@matrajs/collab'
|
|
93
|
+
|
|
94
|
+
const editor = createEditor({
|
|
95
|
+
extensions: [
|
|
96
|
+
...starterKit,
|
|
97
|
+
collab({ clientId }),
|
|
98
|
+
remoteCursors(),
|
|
99
|
+
] as const,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
editor.on('selectionChange', () => {
|
|
103
|
+
const { from, to } = editor.selection
|
|
104
|
+
socket.send(JSON.stringify({
|
|
105
|
+
clientId,
|
|
106
|
+
anchor: from,
|
|
107
|
+
head: to,
|
|
108
|
+
meta: { name: 'Nahim' },
|
|
109
|
+
}))
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
socket.onmessage = (event) => editor.commands.setPresence(JSON.parse(event.data))
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Command or export | |
|
|
116
|
+
|
|
117
|
+
| `setPresence(presence)` | `` `removePresence(clientId)` | Someone left. |
|
|
118
|
+
|
|
119
|
+
| `clearPresence()` | Everyone left · a reconnect, usually. | `colorFor(clientId)` | A stable colour derived from the id, at a fixed saturation and lightness so it stays legible on light and dark. Derived beats assigned: two clients that never speak still agree on what colour a third person is. |
|
|
120
|
+
remoteCursorCSSThe stylesheet the decorations expect. See Styling.
|
|
121
|
+
|
|
122
|
+
Read the cursors for an avatar row:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
import type { RemoteCursors } from '@matrajs/collab'
|
|
126
|
+
|
|
127
|
+
const people = editor.extensionState<RemoteCursors>('remoteCursors')
|
|
128
|
+
```
|