@matrajs/collab 0.4.1 → 0.6.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 CHANGED
@@ -1,21 +1,57 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Nahim Hossain Shohan
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ Matra Commercial License
2
+ Version 1.0, 24 August 2026
3
+ Copyright (c) 2026 Amrela
4
+
5
+ This package is source-available, not open source. The Matra core
6
+ (@matrajs/core, @matrajs/react, @matrajs/vue) is MIT and always will be; this
7
+ package is not.
8
+
9
+ 1. FREE USE
10
+
11
+ You may use, copy and modify this software at no cost for:
12
+
13
+ a. evaluation, development and testing;
14
+ b. personal projects that earn no revenue;
15
+ c. education, research and teaching;
16
+ d. internal tools at an organisation with fewer than three developers
17
+ working on software that includes this package.
18
+
19
+ No key, no signup, no phone-home. We do not check. This clause is the
20
+ licence, not a trial period.
21
+
22
+ 2. PAID USE
23
+
24
+ Any other use in production requires a current subscription, one seat per
25
+ developer working on software that includes this package. If the software
26
+ you ship earns money and more than two developers touch it, that is paid
27
+ use.
28
+
29
+ 3. WHAT YOU MAY ALWAYS DO
30
+
31
+ Read the source. Modify it for your own licensed use. Fork it privately.
32
+ Keep running the version you licensed, forever, even if your subscription
33
+ lapses — a lapsed subscription ends updates and support, not the software
34
+ you already shipped.
35
+
36
+ 4. WHAT YOU MAY NOT DO
37
+
38
+ Redistribute this package, modified or not, as part of a product that
39
+ competes with Matra. Remove or obscure this licence. Sublicense it.
40
+
41
+ 5. NO WARRANTY
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
45
+ OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
46
+
47
+ 6. TERMINATION
48
+
49
+ This licence ends if you breach it and do not fix the breach within 30 days
50
+ of being told. Clause 3's "keep running what you licensed" survives
51
+ termination for versions you had already licensed and shipped.
52
+
53
+ 7. GOVERNING LAW
54
+
55
+ The laws of Bangladesh.
56
+
57
+ Questions, or a licence that does not fit your situation: licensing@amrela.co
package/README.md CHANGED
@@ -31,10 +31,49 @@ editor.commands.receiveCollabSteps(await since(getVersion(editor)))
31
31
  `Authority` is the server half, deliberately transport-free so the same object
32
32
  works over WebSocket, HTTP polling, or an in-memory channel in a test.
33
33
 
34
- `PresenceTracker` keeps other people's cursors inside the document as it
35
- changes. Rendering them is the host's job the engine has no decoration layer,
36
- and inventing one here would be the wrong place for it.
34
+ `remoteCursors()` draws everyone else's caret and selection, as decorations
35
+ rather than as document content so nothing about presence travels with a
36
+ copy, an export, or an undo.
37
+
38
+ ```ts
39
+ import { collab, remoteCursors, remoteCursorCSS } from '@matrajs/collab'
40
+
41
+ const editor = createEditor({
42
+ extensions: [...starterKit, collab({ clientId }), remoteCursors()],
43
+ })
44
+
45
+ // Whatever your transport delivers.
46
+ socket.on('presence', (p) => editor.commands.setPresence(p))
47
+ socket.on('left', (id) => editor.commands.removePresence(id))
48
+ ```
49
+
50
+ A caret arrives as a position in the *sender's* document, and is wrong the
51
+ moment anything is typed locally. Each one is mapped through the same steps the
52
+ local document went through, which is the only thing that keeps a caret on the
53
+ word it was actually next to — clamping it to the document size, the obvious
54
+ shortcut, silently points every cursor at the wrong place.
55
+
56
+ Colours come from `colorFor(clientId)`, derived rather than assigned, so two
57
+ clients that never speak still agree on what colour a third person is. Drop in
58
+ `remoteCursorCSS` for enough styling to see a caret, or write your own against
59
+ `.matra-remote-cursor`, `.matra-remote-cursor-label` and
60
+ `.matra-remote-selection`.
37
61
 
38
62
  - Source: https://github.com/amrelaco/matra
39
63
 
40
- MIT.
64
+
65
+ ## Licence
66
+
67
+ Source-available under the [Matra Commercial License](./LICENSE), **not MIT** —
68
+ unlike `@matrajs/core`, which is MIT and stays that way.
69
+
70
+ Free for evaluation, development, testing, personal projects, education, and
71
+ internal tools at organisations with fewer than three developers on the
72
+ codebase. Paid, one seat per developer, for production use beyond that. No
73
+ licence key and no phone-home: the licence is the agreement, not a mechanism.
74
+
75
+ Versions up to and including **0.5.0 were published under MIT**, and that grant
76
+ cannot be withdrawn — if you are already on 0.5.0 you may keep using it under
77
+ MIT indefinitely. The commercial licence applies from 0.6.0 onward.
78
+
79
+ licensing@amrela.co
package/dist/index.cjs CHANGED
@@ -142,57 +142,163 @@ function readEngine(ctx) {
142
142
  return access;
143
143
  }
144
144
 
145
- // src/presence.ts
146
- var PresenceTracker = class {
147
- constructor(editor, onUpdate) {
148
- this.editor = editor;
149
- this.onUpdate = onUpdate;
150
- editor.getJSON();
151
- this.off = editor.on("change", () => {
152
- const size = measure(editor.getJSON());
153
- for (const [id, person] of this.people) {
154
- this.people.set(id, {
155
- ...person,
156
- anchor: Math.min(person.anchor, size),
157
- head: Math.min(person.head, size)
145
+ // src/remote-cursors.ts
146
+ function readEngine2(ctx) {
147
+ const access = ctx[/* @__PURE__ */ Symbol.for("matra.engine")];
148
+ if (!access) throw new Error("Matra: remoteCursors ctx was created outside the engine");
149
+ return access;
150
+ }
151
+ function colorFor(clientId) {
152
+ let hash = 0;
153
+ for (let i = 0; i < clientId.length; i++) hash = hash * 31 + clientId.charCodeAt(i) | 0;
154
+ return `hsl(${Math.abs(hash) % 360} 70% 45%)`;
155
+ }
156
+ var META = "matra-presence";
157
+ function remoteCursors() {
158
+ return {
159
+ kind: "extension",
160
+ name: "remoteCursors",
161
+ state: {
162
+ init: () => /* @__PURE__ */ new Map(),
163
+ apply(ctx, previous) {
164
+ const { tr } = readEngine2(ctx);
165
+ const action = tr.getMeta(META);
166
+ let next = previous;
167
+ if (tr.docChanged) {
168
+ const mapped = /* @__PURE__ */ new Map();
169
+ for (const [id, person] of previous) {
170
+ mapped.set(id, {
171
+ ...person,
172
+ anchor: tr.mapping.map(person.anchor),
173
+ head: tr.mapping.map(person.head)
174
+ });
175
+ }
176
+ next = mapped;
177
+ }
178
+ if (!action) return next;
179
+ const copy = new Map(next);
180
+ if ("clear" in action) copy.clear();
181
+ else if ("remove" in action) copy.delete(action.remove);
182
+ else if (isPlacedPresence(action.set)) copy.set(action.set.clientId, action.set);
183
+ return copy;
184
+ }
185
+ },
186
+ decorations(ctx) {
187
+ const { tr, pluginState } = readEngine2(ctx);
188
+ const people = pluginState("remoteCursors");
189
+ if (!people?.size) return [];
190
+ const size = tr.doc.content.size;
191
+ const out = [];
192
+ for (const person of people.values()) {
193
+ const anchor = clamp(person.anchor, size);
194
+ const head = clamp(person.head, size);
195
+ if (anchor === null || head === null) continue;
196
+ const color = colorFor(person.clientId);
197
+ const name = readName(person);
198
+ if (anchor !== head) {
199
+ out.push({
200
+ type: "inline",
201
+ from: Math.min(anchor, head),
202
+ to: Math.max(anchor, head),
203
+ attrs: {
204
+ class: "matra-remote-selection",
205
+ style: `background-color: ${color}; opacity: 0.25`
206
+ }
207
+ });
208
+ }
209
+ out.push({
210
+ type: "widget",
211
+ pos: head,
212
+ // Keyed by client so the renderer reuses the caret element instead of
213
+ // tearing it down and rebuilding it on every keystroke.
214
+ key: `cursor-${person.clientId}`,
215
+ side: 1,
216
+ render: () => renderCaret(color, name)
158
217
  });
159
218
  }
160
- editor.getJSON();
161
- this.onUpdate?.(this.list());
162
- });
163
- }
164
- editor;
165
- onUpdate;
166
- people = /* @__PURE__ */ new Map();
167
- off;
168
- set(person) {
169
- this.people.set(person.clientId, person);
170
- this.onUpdate?.(this.list());
171
- }
172
- remove(clientId) {
173
- if (this.people.delete(clientId)) this.onUpdate?.(this.list());
174
- }
175
- list() {
176
- return [...this.people.values()];
177
- }
178
- destroy() {
179
- this.off();
180
- this.people.clear();
181
- }
182
- };
183
- function measure(doc) {
184
- if (typeof doc.text === "string") return doc.text.length;
185
- let size = 0;
186
- for (const child of doc.content ?? []) {
187
- size += typeof child.text === "string" ? child.text.length : measure(child) + 2;
219
+ return out;
220
+ },
221
+ commands: {
222
+ setPresence: (ctx, presence) => {
223
+ if (!isPlacedPresence(presence)) return false;
224
+ readEngine2(ctx).tr.setMeta(META, { set: presence });
225
+ return true;
226
+ },
227
+ removePresence: (ctx, clientId) => {
228
+ if (typeof clientId !== "string" || !clientId) return false;
229
+ readEngine2(ctx).tr.setMeta(META, { remove: clientId });
230
+ return true;
231
+ },
232
+ clearPresence: (ctx) => {
233
+ readEngine2(ctx).tr.setMeta(META, { clear: true });
234
+ return true;
235
+ }
236
+ }
237
+ };
238
+ }
239
+ function isPlacedPresence(value) {
240
+ if (!value || typeof value !== "object") return false;
241
+ const person = value;
242
+ return typeof person.clientId === "string" && person.clientId.length > 0 && isPlace(person.anchor) && isPlace(person.head);
243
+ }
244
+ function isPlace(value) {
245
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
246
+ }
247
+ function clamp(value, size) {
248
+ if (!isPlace(value)) return null;
249
+ return Math.min(value, size);
250
+ }
251
+ function renderCaret(color, name) {
252
+ const caret = document.createElement("span");
253
+ caret.className = "matra-remote-cursor";
254
+ caret.style.borderLeft = `2px solid ${color}`;
255
+ if (name !== null) {
256
+ const label = document.createElement("span");
257
+ label.className = "matra-remote-cursor-label";
258
+ label.style.backgroundColor = color;
259
+ label.textContent = name;
260
+ caret.appendChild(label);
188
261
  }
189
- return size;
262
+ return caret;
263
+ }
264
+ function readName(person) {
265
+ const name = person.meta?.name;
266
+ if (typeof name !== "string") return null;
267
+ const trimmed = name.trim();
268
+ if (!trimmed) return null;
269
+ return trimmed.slice(0, 40);
270
+ }
271
+ var remoteCursorCSS = `
272
+ .matra-remote-cursor {
273
+ position: relative;
274
+ margin-left: -1px;
275
+ margin-right: -1px;
276
+ pointer-events: none;
277
+ word-break: normal;
278
+ }
279
+ .matra-remote-cursor-label {
280
+ position: absolute;
281
+ top: -1.2em;
282
+ left: -2px;
283
+ padding: 0 4px;
284
+ border-radius: 3px;
285
+ color: #fff;
286
+ font-size: 11px;
287
+ line-height: 1.4;
288
+ white-space: nowrap;
289
+ user-select: none;
290
+ }
291
+ .matra-remote-selection {
292
+ border-radius: 2px;
190
293
  }
294
+ `;
191
295
 
192
296
  exports.Authority = Authority;
193
- exports.PresenceTracker = PresenceTracker;
194
297
  exports.collab = collab;
298
+ exports.colorFor = colorFor;
195
299
  exports.getVersion = getVersion;
300
+ exports.remoteCursorCSS = remoteCursorCSS;
301
+ exports.remoteCursors = remoteCursors;
196
302
  exports.sendableSteps = sendableSteps;
197
303
  //# sourceMappingURL=index.cjs.map
198
304
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/authority.ts","../src/collab.ts","../src/presence.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { CollabStep } from './types'\n\n/**\n * The server side of the protocol, as a plain object.\n *\n * An authority is barely anything: a list of steps and the rule that a client\n * may only append if it is up to date. Keeping it transport-free means the same\n * object works over WebSocket, HTTP polling or an in-memory channel in tests.\n */\nexport class Authority {\n private readonly history: CollabStep[] = []\n\n constructor(private readonly onChange?: (version: number) => void) {}\n\n get version(): number {\n return this.history.length\n }\n\n /**\n * Append steps if the client is current.\n *\n * Returns false when it is not — the client should pull what it missed,\n * rebase, and try again. Rejecting rather than merging is what keeps the\n * history linear and every client's version meaningful.\n */\n receive(version: number, steps: CollabStep[]): boolean {\n if (version !== this.version) return false\n this.history.push(...steps)\n this.onChange?.(this.version)\n return true\n }\n\n /** Everything that happened after `version`. */\n since(version: number): CollabStep[] {\n return this.history.slice(version)\n }\n}\n","import type { AnyDef, Command, Ctx, Editor, ExtensionDef } from '@matrajs/core'\nimport type { CollabStep, Sendable } from './types'\n\nexport interface CollabOptions {\n /** Identifies this client. Two clients must never share one. */\n clientId: string\n /** The version this client starts from. */\n version?: number\n}\n\n/** A local step, kept with what it takes to rewind and rebase it. */\nexport interface PendingStep {\n step: EngineStep\n /** The step that undoes it, computed against the document it applied to. */\n inverted: EngineStep\n json: Record<string, unknown>\n clientId: string\n}\n\nexport interface CollabState {\n version: number\n /** Local steps the authority has not confirmed yet. */\n unconfirmed: PendingStep[]\n}\n\n/** The slice of a step this package needs; the engine owns the real type. */\ninterface EngineStep {\n toJSON(): Record<string, unknown>\n invert(doc: unknown): EngineStep\n map(mapping: unknown): EngineStep | null\n}\n\nconst REMOTE = 'collab:remote'\nconst CONFIRM = 'collab:confirm'\nconst REBASED = 'collab:rebased'\n\n/**\n * Collaborative editing over a central authority.\n *\n * The protocol is the well-trodden one: a client sends the steps it has made\n * together with the version they applied to, and the authority accepts them\n * only if that version is still current. A client whose version is stale pulls\n * the steps it missed, rebases its own unconfirmed work over them, and tries\n * again.\n *\n * There is no CRDT here and no dependency. Rebasing already lives in the engine\n * — `Step.map` is what lets a local edit survive a remote one — so\n * collaboration is a version counter and a transport on top of it.\n */\nexport function collab(options: CollabOptions): ExtensionDef<\n {\n receiveCollabSteps: Command<[steps: CollabStep[]]>\n confirmCollabSteps: Command<[count: number]>\n },\n CollabState\n> {\n const clientId = options.clientId\n if (!clientId) throw new Error('Matra: collab needs a clientId')\n\n return {\n kind: 'extension',\n name: 'collab',\n state: {\n init: () => ({ version: options.version ?? 0, unconfirmed: [] }),\n apply: (ctx, previous) => {\n const engine = readEngine(ctx)\n const tr = engine.tr\n\n const confirmed = tr.getMeta(CONFIRM)\n if (typeof confirmed === 'number') {\n return {\n version: previous.version + confirmed,\n unconfirmed: previous.unconfirmed.slice(confirmed),\n }\n }\n\n const rebased = tr.getMeta(REBASED) as\n | { remote: number; unconfirmed: PendingStep[] }\n | undefined\n if (rebased) {\n return {\n version: previous.version + rebased.remote,\n unconfirmed: rebased.unconfirmed,\n }\n }\n\n if (!tr.steps.length) return previous\n if (tr.getMeta(REMOTE)) return previous\n\n // Local work: keep each step with its inverse, which is what lets it be\n // rewound and replayed when someone else's edit arrives first.\n const pending: PendingStep[] = tr.steps.map((step, index) => ({\n step,\n inverted: step.invert(tr.docs[index]),\n json: step.toJSON(),\n clientId,\n }))\n return {\n version: previous.version,\n unconfirmed: [...previous.unconfirmed, ...pending],\n }\n },\n },\n commands: {\n /**\n * Apply steps from other clients.\n *\n * Steps this client sent are skipped — they are already in the document,\n * and applying them twice would duplicate the edit. A step that no longer\n * applies is dropped rather than throwing: one bad message from a peer\n * must not take the editor down.\n */\n receiveCollabSteps: (ctx, incoming) => {\n if (!incoming?.length) return false\n const engine = readEngine(ctx)\n const foreign = incoming.filter((entry) => entry.clientId !== clientId)\n if (!foreign.length) return false\n\n const pending =\n (engine.pluginState('collab') as CollabState | undefined)?.unconfirmed ?? []\n const tr = engine.tr\n\n // Rewind local work so the remote steps land on the document the\n // authority actually has. Applying them on top of unsent local edits\n // would leave both the document and the outgoing positions wrong.\n for (let i = pending.length - 1; i >= 0; i--) {\n tr.maybeStep(pending[i]?.inverted as never)\n }\n\n const beforeRemote = tr.steps.length\n let applied = 0\n for (const entry of foreign) {\n const step = engine.stepFromJSON(entry.step)\n if (step && tr.maybeStep(step)) applied++\n }\n if (!applied) return false\n\n // Replay local work over the remote changes.\n const remoteMapping = tr.mapping.slice(beforeRemote)\n const rebased: PendingStep[] = []\n for (const entry of pending) {\n const mapped = entry.step.map(remoteMapping)\n if (!mapped) continue\n const docBefore = tr.doc\n if (!tr.maybeStep(mapped as never)) continue\n rebased.push({\n step: mapped,\n inverted: mapped.invert(docBefore),\n json: mapped.toJSON(),\n clientId,\n })\n }\n\n tr.setMeta(REBASED, { remote: applied, unconfirmed: rebased })\n return true\n },\n\n /** The authority accepted this many of our steps; stop tracking them. */\n confirmCollabSteps: (ctx, count) => {\n if (!Number.isInteger(count) || count <= 0) return false\n readEngine(ctx).tr.setMeta(CONFIRM, count)\n return true\n },\n },\n }\n}\n\n/** Steps this client has made that the authority has not seen. */\nexport function sendableSteps(editor: Editor<readonly AnyDef[]>): Sendable | null {\n const state = editor.extensionState<CollabState>('collab')\n if (!state?.unconfirmed.length) return null\n return {\n version: state.version,\n steps: state.unconfirmed.map((entry) => ({ step: entry.json, clientId: entry.clientId })),\n clientId: state.unconfirmed[0]?.clientId ?? '',\n }\n}\n\n/** The version of the document this client believes it is on. */\nexport function getVersion(editor: Editor<readonly AnyDef[]>): number {\n return editor.extensionState<CollabState>('collab')?.version ?? 0\n}\n\ninterface CollabEngine {\n tr: {\n steps: EngineStep[]\n docs: unknown[]\n doc: unknown\n mapping: { slice(from: number): unknown }\n getMeta(key: string): unknown\n setMeta(key: string, value: unknown): unknown\n maybeStep(step: unknown): boolean\n }\n stepFromJSON(json: Record<string, unknown>): EngineStep | null\n pluginState(key: string): unknown\n}\n\n/** Reach the engine the way bundled extensions do. */\nfunction readEngine(ctx: Ctx): CollabEngine {\n const access = (ctx as unknown as Record<symbol, CollabEngine>)[Symbol.for('matra.engine')]\n if (!access) throw new Error('Matra: collab ctx was created outside the engine')\n return access\n}\n","import type { AnyDef, Editor } from '@matrajs/core'\nimport type { Presence } from './types'\n\n/**\n * Other people's cursors, kept honest as the document changes.\n *\n * A remote caret arrives as a position in the sender's document. The moment\n * anything is typed locally that number is wrong, so every position is mapped\n * forward on each change. Rendering is the host application's job — the engine\n * has no decoration layer, and inventing one here would be the wrong place.\n */\nexport class PresenceTracker {\n private readonly people = new Map<string, Presence>()\n private readonly off: () => void\n\n constructor(\n private readonly editor: Editor<readonly AnyDef[]>,\n private readonly onUpdate?: (people: Presence[]) => void,\n ) {\n let previous = editor.getJSON()\n this.off = editor.on('change', () => {\n // Positions past the end of the document are clamped rather than dropped:\n // a cursor at the end of a paragraph someone just shortened should sit at\n // the new end, not disappear.\n const size = measure(editor.getJSON())\n for (const [id, person] of this.people) {\n this.people.set(id, {\n ...person,\n anchor: Math.min(person.anchor, size),\n head: Math.min(person.head, size),\n })\n }\n previous = editor.getJSON()\n void previous\n this.onUpdate?.(this.list())\n })\n }\n\n set(person: Presence): void {\n this.people.set(person.clientId, person)\n this.onUpdate?.(this.list())\n }\n\n remove(clientId: string): void {\n if (this.people.delete(clientId)) this.onUpdate?.(this.list())\n }\n\n list(): Presence[] {\n return [...this.people.values()]\n }\n\n destroy(): void {\n this.off()\n this.people.clear()\n }\n}\n\nfunction measure(doc: { content?: unknown[]; text?: string }): number {\n if (typeof doc.text === 'string') return doc.text.length\n let size = 0\n for (const child of (doc.content ?? []) as Array<{ content?: unknown[]; text?: string }>) {\n size += typeof child.text === 'string' ? child.text.length : measure(child) + 2\n }\n return size\n}\n"]}
1
+ {"version":3,"sources":["../src/authority.ts","../src/collab.ts","../src/remote-cursors.ts"],"names":["readEngine"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { CollabStep } from './types'\n\n/**\n * The server side of the protocol, as a plain object.\n *\n * An authority is barely anything: a list of steps and the rule that a client\n * may only append if it is up to date. Keeping it transport-free means the same\n * object works over WebSocket, HTTP polling or an in-memory channel in tests.\n */\nexport class Authority {\n private readonly history: CollabStep[] = []\n\n constructor(private readonly onChange?: (version: number) => void) {}\n\n get version(): number {\n return this.history.length\n }\n\n /**\n * Append steps if the client is current.\n *\n * Returns false when it is not — the client should pull what it missed,\n * rebase, and try again. Rejecting rather than merging is what keeps the\n * history linear and every client's version meaningful.\n */\n receive(version: number, steps: CollabStep[]): boolean {\n if (version !== this.version) return false\n this.history.push(...steps)\n this.onChange?.(this.version)\n return true\n }\n\n /** Everything that happened after `version`. */\n since(version: number): CollabStep[] {\n return this.history.slice(version)\n }\n}\n","import type { AnyDef, Command, Ctx, Editor, ExtensionDef } from '@matrajs/core'\nimport type { CollabStep, Sendable } from './types'\n\nexport interface CollabOptions {\n /** Identifies this client. Two clients must never share one. */\n clientId: string\n /** The version this client starts from. */\n version?: number\n}\n\n/** A local step, kept with what it takes to rewind and rebase it. */\nexport interface PendingStep {\n step: EngineStep\n /** The step that undoes it, computed against the document it applied to. */\n inverted: EngineStep\n json: Record<string, unknown>\n clientId: string\n}\n\nexport interface CollabState {\n version: number\n /** Local steps the authority has not confirmed yet. */\n unconfirmed: PendingStep[]\n}\n\n/** The slice of a step this package needs; the engine owns the real type. */\ninterface EngineStep {\n toJSON(): Record<string, unknown>\n invert(doc: unknown): EngineStep\n map(mapping: unknown): EngineStep | null\n}\n\nconst REMOTE = 'collab:remote'\nconst CONFIRM = 'collab:confirm'\nconst REBASED = 'collab:rebased'\n\n/**\n * Collaborative editing over a central authority.\n *\n * The protocol is the well-trodden one: a client sends the steps it has made\n * together with the version they applied to, and the authority accepts them\n * only if that version is still current. A client whose version is stale pulls\n * the steps it missed, rebases its own unconfirmed work over them, and tries\n * again.\n *\n * There is no CRDT here and no dependency. Rebasing already lives in the engine\n * — `Step.map` is what lets a local edit survive a remote one — so\n * collaboration is a version counter and a transport on top of it.\n */\nexport function collab(options: CollabOptions): ExtensionDef<\n {\n receiveCollabSteps: Command<[steps: CollabStep[]]>\n confirmCollabSteps: Command<[count: number]>\n },\n CollabState\n> {\n const clientId = options.clientId\n if (!clientId) throw new Error('Matra: collab needs a clientId')\n\n return {\n kind: 'extension',\n name: 'collab',\n state: {\n init: () => ({ version: options.version ?? 0, unconfirmed: [] }),\n apply: (ctx, previous) => {\n const engine = readEngine(ctx)\n const tr = engine.tr\n\n const confirmed = tr.getMeta(CONFIRM)\n if (typeof confirmed === 'number') {\n return {\n version: previous.version + confirmed,\n unconfirmed: previous.unconfirmed.slice(confirmed),\n }\n }\n\n const rebased = tr.getMeta(REBASED) as\n | { remote: number; unconfirmed: PendingStep[] }\n | undefined\n if (rebased) {\n return {\n version: previous.version + rebased.remote,\n unconfirmed: rebased.unconfirmed,\n }\n }\n\n if (!tr.steps.length) return previous\n if (tr.getMeta(REMOTE)) return previous\n\n // Local work: keep each step with its inverse, which is what lets it be\n // rewound and replayed when someone else's edit arrives first.\n const pending: PendingStep[] = tr.steps.map((step, index) => ({\n step,\n inverted: step.invert(tr.docs[index]),\n json: step.toJSON(),\n clientId,\n }))\n return {\n version: previous.version,\n unconfirmed: [...previous.unconfirmed, ...pending],\n }\n },\n },\n commands: {\n /**\n * Apply steps from other clients.\n *\n * Steps this client sent are skipped — they are already in the document,\n * and applying them twice would duplicate the edit. A step that no longer\n * applies is dropped rather than throwing: one bad message from a peer\n * must not take the editor down.\n */\n receiveCollabSteps: (ctx, incoming) => {\n if (!incoming?.length) return false\n const engine = readEngine(ctx)\n const foreign = incoming.filter((entry) => entry.clientId !== clientId)\n if (!foreign.length) return false\n\n const pending =\n (engine.pluginState('collab') as CollabState | undefined)?.unconfirmed ?? []\n const tr = engine.tr\n\n // Rewind local work so the remote steps land on the document the\n // authority actually has. Applying them on top of unsent local edits\n // would leave both the document and the outgoing positions wrong.\n for (let i = pending.length - 1; i >= 0; i--) {\n tr.maybeStep(pending[i]?.inverted as never)\n }\n\n const beforeRemote = tr.steps.length\n let applied = 0\n for (const entry of foreign) {\n const step = engine.stepFromJSON(entry.step)\n if (step && tr.maybeStep(step)) applied++\n }\n if (!applied) return false\n\n // Replay local work over the remote changes.\n const remoteMapping = tr.mapping.slice(beforeRemote)\n const rebased: PendingStep[] = []\n for (const entry of pending) {\n const mapped = entry.step.map(remoteMapping)\n if (!mapped) continue\n const docBefore = tr.doc\n if (!tr.maybeStep(mapped as never)) continue\n rebased.push({\n step: mapped,\n inverted: mapped.invert(docBefore),\n json: mapped.toJSON(),\n clientId,\n })\n }\n\n tr.setMeta(REBASED, { remote: applied, unconfirmed: rebased })\n return true\n },\n\n /** The authority accepted this many of our steps; stop tracking them. */\n confirmCollabSteps: (ctx, count) => {\n if (!Number.isInteger(count) || count <= 0) return false\n readEngine(ctx).tr.setMeta(CONFIRM, count)\n return true\n },\n },\n }\n}\n\n/** Steps this client has made that the authority has not seen. */\nexport function sendableSteps(editor: Editor<readonly AnyDef[]>): Sendable | null {\n const state = editor.extensionState<CollabState>('collab')\n if (!state?.unconfirmed.length) return null\n return {\n version: state.version,\n steps: state.unconfirmed.map((entry) => ({ step: entry.json, clientId: entry.clientId })),\n clientId: state.unconfirmed[0]?.clientId ?? '',\n }\n}\n\n/** The version of the document this client believes it is on. */\nexport function getVersion(editor: Editor<readonly AnyDef[]>): number {\n return editor.extensionState<CollabState>('collab')?.version ?? 0\n}\n\ninterface CollabEngine {\n tr: {\n steps: EngineStep[]\n docs: unknown[]\n doc: unknown\n mapping: { slice(from: number): unknown }\n getMeta(key: string): unknown\n setMeta(key: string, value: unknown): unknown\n maybeStep(step: unknown): boolean\n }\n stepFromJSON(json: Record<string, unknown>): EngineStep | null\n pluginState(key: string): unknown\n}\n\n/** Reach the engine the way bundled extensions do. */\nfunction readEngine(ctx: Ctx): CollabEngine {\n const access = (ctx as unknown as Record<symbol, CollabEngine>)[Symbol.for('matra.engine')]\n if (!access) throw new Error('Matra: collab ctx was created outside the engine')\n return access\n}\n","import type { Command, Ctx, DecorationSpec, ExtensionDef, Pos } from '@matrajs/core'\nimport type { Presence } from './types'\n\n/** The slice of the engine this extension needs, reached the way collab does. */\ninterface CursorEngine {\n tr: {\n docChanged: boolean\n doc: { content: { size: number } }\n mapping: { map(pos: number, assoc?: -1 | 1): number }\n getMeta(key: string): unknown\n setMeta(key: string, value: unknown): unknown\n }\n pluginState(key: string): unknown\n}\n\nfunction readEngine(ctx: Ctx): CursorEngine {\n const access = (ctx as unknown as Record<symbol, CursorEngine>)[Symbol.for('matra.engine')]\n if (!access) throw new Error('Matra: remoteCursors ctx was created outside the engine')\n return access\n}\n\n/** Everyone else's caret, in this client's coordinates. */\nexport type RemoteCursors = ReadonlyMap<string, Presence>\n\n/**\n * A colour per person, chosen from the id so it is stable everywhere.\n *\n * Deriving it beats assigning one, because two clients that never speak still\n * agree on what colour a third person is.\n */\nexport function colorFor(clientId: string): string {\n let hash = 0\n for (let i = 0; i < clientId.length; i++) hash = (hash * 31 + clientId.charCodeAt(i)) | 0\n // Fixed saturation and lightness keep every colour legible on white and dark.\n return `hsl(${Math.abs(hash) % 360} 70% 45%)`\n}\n\nconst META = 'matra-presence'\n\n/** What a presence message asks the extension to do. */\ntype PresenceAction = { set: Presence } | { remove: string } | { clear: true }\n\n/**\n * Other people's carets and selections, drawn as decorations.\n *\n * A remote caret arrives as a position in the *sender's* document. The instant\n * anything is typed locally that number is wrong. The only correct fix is to\n * map it through the same steps the local document went through, which is why\n * this is an extension rather than a helper: extension state is reduced inside\n * the transaction pipeline, where the mapping actually lives.\n *\n * Clamping to the document size — the obvious shortcut — is wrong. Insert ten\n * characters at the start and every later caret should move ten forward;\n * clamping leaves them all pointing at the wrong word.\n */\nexport function remoteCursors(): ExtensionDef<\n {\n setPresence: Command<[presence: Presence]>\n removePresence: Command<[clientId: string]>\n clearPresence: Command\n },\n RemoteCursors\n> {\n return {\n kind: 'extension',\n name: 'remoteCursors',\n\n state: {\n init: () => new Map<string, Presence>(),\n\n apply(ctx, previous) {\n const { tr } = readEngine(ctx)\n const action = tr.getMeta(META) as PresenceAction | undefined\n let next = previous\n\n // Map first, then apply the message. A message arriving in the same\n // transaction as an edit already describes the document after it.\n if (tr.docChanged) {\n // This transaction's own mapping, not ctx.mark() — that one maps\n // changes made *after* it is taken, and by the time a reducer runs\n // the change it must account for is the one already in `tr`.\n const mapped = new Map<string, Presence>()\n for (const [id, person] of previous) {\n mapped.set(id, {\n ...person,\n anchor: tr.mapping.map(person.anchor),\n head: tr.mapping.map(person.head),\n })\n }\n next = mapped\n }\n\n if (!action) return next\n\n const copy = new Map(next)\n if ('clear' in action) copy.clear()\n else if ('remove' in action) copy.delete(action.remove)\n else if (isPlacedPresence(action.set)) copy.set(action.set.clientId, action.set)\n return copy\n },\n },\n\n decorations(ctx) {\n const { tr, pluginState } = readEngine(ctx)\n const people = pluginState('remoteCursors') as RemoteCursors | undefined\n if (!people?.size) return []\n\n const size = tr.doc.content.size\n const out: DecorationSpec[] = []\n\n for (const person of people.values()) {\n // A peer can send anything. A caret outside the document would throw\n // deep in the decoration mapper, so it is dropped here instead.\n const anchor = clamp(person.anchor, size)\n const head = clamp(person.head, size)\n if (anchor === null || head === null) continue\n\n const color = colorFor(person.clientId)\n const name = readName(person)\n\n if (anchor !== head) {\n out.push({\n type: 'inline',\n from: Math.min(anchor, head) as Pos,\n to: Math.max(anchor, head) as Pos,\n attrs: {\n class: 'matra-remote-selection',\n style: `background-color: ${color}; opacity: 0.25`,\n },\n })\n }\n\n out.push({\n type: 'widget',\n pos: head as Pos,\n // Keyed by client so the renderer reuses the caret element instead of\n // tearing it down and rebuilding it on every keystroke.\n key: `cursor-${person.clientId}`,\n side: 1,\n render: () => renderCaret(color, name),\n })\n }\n\n return out\n },\n\n commands: {\n setPresence: (ctx, presence) => {\n if (!isPlacedPresence(presence)) return false\n readEngine(ctx).tr.setMeta(META, { set: presence })\n return true\n },\n removePresence: (ctx, clientId) => {\n if (typeof clientId !== 'string' || !clientId) return false\n readEngine(ctx).tr.setMeta(META, { remove: clientId })\n return true\n },\n clearPresence: (ctx) => {\n readEngine(ctx).tr.setMeta(META, { clear: true })\n return true\n },\n },\n }\n}\n\n/** Is this a presence message we can place, or noise from the wire? */\nfunction isPlacedPresence(value: unknown): value is Presence {\n if (!value || typeof value !== 'object') return false\n const person = value as Presence\n return (\n typeof person.clientId === 'string' &&\n person.clientId.length > 0 &&\n isPlace(person.anchor) &&\n isPlace(person.head)\n )\n}\n\nfunction isPlace(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value >= 0\n}\n\nfunction clamp(value: number, size: number): number | null {\n if (!isPlace(value)) return null\n return Math.min(value, size)\n}\n\n/**\n * The caret element.\n *\n * Built with `document.createElement` and `textContent` rather than a template\n * string: the label is a display name chosen by another user, and it must not\n * be able to become markup.\n */\nfunction renderCaret(color: string, name: string | null): HTMLElement {\n const caret = document.createElement('span')\n caret.className = 'matra-remote-cursor'\n caret.style.borderLeft = `2px solid ${color}`\n\n if (name !== null) {\n const label = document.createElement('span')\n label.className = 'matra-remote-cursor-label'\n label.style.backgroundColor = color\n label.textContent = name\n caret.appendChild(label)\n }\n return caret\n}\n\n/** A display name, if the sender supplied a usable one. */\nfunction readName(person: Presence): string | null {\n const name = person.meta?.name\n if (typeof name !== 'string') return null\n const trimmed = name.trim()\n if (!trimmed) return null\n // A name is a label, not an essay; a peer sending 10kB of it is an attack.\n return trimmed.slice(0, 40)\n}\n\n/** Enough styling to see a caret. Drop it in a stylesheet, or write your own. */\nexport const remoteCursorCSS = `\n.matra-remote-cursor {\n position: relative;\n margin-left: -1px;\n margin-right: -1px;\n pointer-events: none;\n word-break: normal;\n}\n.matra-remote-cursor-label {\n position: absolute;\n top: -1.2em;\n left: -2px;\n padding: 0 4px;\n border-radius: 3px;\n color: #fff;\n font-size: 11px;\n line-height: 1.4;\n white-space: nowrap;\n user-select: none;\n}\n.matra-remote-selection {\n border-radius: 2px;\n}\n`\n"]}
package/dist/index.d.cts CHANGED
@@ -93,24 +93,34 @@ declare function sendableSteps(editor: Editor<readonly AnyDef[]>): Sendable | nu
93
93
  /** The version of the document this client believes it is on. */
94
94
  declare function getVersion(editor: Editor<readonly AnyDef[]>): number;
95
95
 
96
+ /** Everyone else's caret, in this client's coordinates. */
97
+ type RemoteCursors = ReadonlyMap<string, Presence>;
96
98
  /**
97
- * Other people's cursors, kept honest as the document changes.
99
+ * A colour per person, chosen from the id so it is stable everywhere.
98
100
  *
99
- * A remote caret arrives as a position in the sender's document. The moment
100
- * anything is typed locally that number is wrong, so every position is mapped
101
- * forward on each change. Rendering is the host application's job — the engine
102
- * has no decoration layer, and inventing one here would be the wrong place.
101
+ * Deriving it beats assigning one, because two clients that never speak still
102
+ * agree on what colour a third person is.
103
103
  */
104
- declare class PresenceTracker {
105
- private readonly editor;
106
- private readonly onUpdate?;
107
- private readonly people;
108
- private readonly off;
109
- constructor(editor: Editor<readonly AnyDef[]>, onUpdate?: ((people: Presence[]) => void) | undefined);
110
- set(person: Presence): void;
111
- remove(clientId: string): void;
112
- list(): Presence[];
113
- destroy(): void;
114
- }
104
+ declare function colorFor(clientId: string): string;
105
+ /**
106
+ * Other people's carets and selections, drawn as decorations.
107
+ *
108
+ * A remote caret arrives as a position in the *sender's* document. The instant
109
+ * anything is typed locally that number is wrong. The only correct fix is to
110
+ * map it through the same steps the local document went through, which is why
111
+ * this is an extension rather than a helper: extension state is reduced inside
112
+ * the transaction pipeline, where the mapping actually lives.
113
+ *
114
+ * Clamping to the document size — the obvious shortcut — is wrong. Insert ten
115
+ * characters at the start and every later caret should move ten forward;
116
+ * clamping leaves them all pointing at the wrong word.
117
+ */
118
+ declare function remoteCursors(): ExtensionDef<{
119
+ setPresence: Command<[presence: Presence]>;
120
+ removePresence: Command<[clientId: string]>;
121
+ clearPresence: Command;
122
+ }, RemoteCursors>;
123
+ /** Enough styling to see a caret. Drop it in a stylesheet, or write your own. */
124
+ declare const remoteCursorCSS = "\n.matra-remote-cursor {\n position: relative;\n margin-left: -1px;\n margin-right: -1px;\n pointer-events: none;\n word-break: normal;\n}\n.matra-remote-cursor-label {\n position: absolute;\n top: -1.2em;\n left: -2px;\n padding: 0 4px;\n border-radius: 3px;\n color: #fff;\n font-size: 11px;\n line-height: 1.4;\n white-space: nowrap;\n user-select: none;\n}\n.matra-remote-selection {\n border-radius: 2px;\n}\n";
115
125
 
116
- export { Authority, type CollabOptions, type CollabState, type CollabStep, type Presence, PresenceTracker, type Sendable, collab, getVersion, sendableSteps };
126
+ export { Authority, type CollabOptions, type CollabState, type CollabStep, type Presence, type RemoteCursors, type Sendable, collab, colorFor, getVersion, remoteCursorCSS, remoteCursors, sendableSteps };
package/dist/index.d.ts CHANGED
@@ -93,24 +93,34 @@ declare function sendableSteps(editor: Editor<readonly AnyDef[]>): Sendable | nu
93
93
  /** The version of the document this client believes it is on. */
94
94
  declare function getVersion(editor: Editor<readonly AnyDef[]>): number;
95
95
 
96
+ /** Everyone else's caret, in this client's coordinates. */
97
+ type RemoteCursors = ReadonlyMap<string, Presence>;
96
98
  /**
97
- * Other people's cursors, kept honest as the document changes.
99
+ * A colour per person, chosen from the id so it is stable everywhere.
98
100
  *
99
- * A remote caret arrives as a position in the sender's document. The moment
100
- * anything is typed locally that number is wrong, so every position is mapped
101
- * forward on each change. Rendering is the host application's job — the engine
102
- * has no decoration layer, and inventing one here would be the wrong place.
101
+ * Deriving it beats assigning one, because two clients that never speak still
102
+ * agree on what colour a third person is.
103
103
  */
104
- declare class PresenceTracker {
105
- private readonly editor;
106
- private readonly onUpdate?;
107
- private readonly people;
108
- private readonly off;
109
- constructor(editor: Editor<readonly AnyDef[]>, onUpdate?: ((people: Presence[]) => void) | undefined);
110
- set(person: Presence): void;
111
- remove(clientId: string): void;
112
- list(): Presence[];
113
- destroy(): void;
114
- }
104
+ declare function colorFor(clientId: string): string;
105
+ /**
106
+ * Other people's carets and selections, drawn as decorations.
107
+ *
108
+ * A remote caret arrives as a position in the *sender's* document. The instant
109
+ * anything is typed locally that number is wrong. The only correct fix is to
110
+ * map it through the same steps the local document went through, which is why
111
+ * this is an extension rather than a helper: extension state is reduced inside
112
+ * the transaction pipeline, where the mapping actually lives.
113
+ *
114
+ * Clamping to the document size — the obvious shortcut — is wrong. Insert ten
115
+ * characters at the start and every later caret should move ten forward;
116
+ * clamping leaves them all pointing at the wrong word.
117
+ */
118
+ declare function remoteCursors(): ExtensionDef<{
119
+ setPresence: Command<[presence: Presence]>;
120
+ removePresence: Command<[clientId: string]>;
121
+ clearPresence: Command;
122
+ }, RemoteCursors>;
123
+ /** Enough styling to see a caret. Drop it in a stylesheet, or write your own. */
124
+ declare const remoteCursorCSS = "\n.matra-remote-cursor {\n position: relative;\n margin-left: -1px;\n margin-right: -1px;\n pointer-events: none;\n word-break: normal;\n}\n.matra-remote-cursor-label {\n position: absolute;\n top: -1.2em;\n left: -2px;\n padding: 0 4px;\n border-radius: 3px;\n color: #fff;\n font-size: 11px;\n line-height: 1.4;\n white-space: nowrap;\n user-select: none;\n}\n.matra-remote-selection {\n border-radius: 2px;\n}\n";
115
125
 
116
- export { Authority, type CollabOptions, type CollabState, type CollabStep, type Presence, PresenceTracker, type Sendable, collab, getVersion, sendableSteps };
126
+ export { Authority, type CollabOptions, type CollabState, type CollabStep, type Presence, type RemoteCursors, type Sendable, collab, colorFor, getVersion, remoteCursorCSS, remoteCursors, sendableSteps };
package/dist/index.js CHANGED
@@ -140,53 +140,157 @@ function readEngine(ctx) {
140
140
  return access;
141
141
  }
142
142
 
143
- // src/presence.ts
144
- var PresenceTracker = class {
145
- constructor(editor, onUpdate) {
146
- this.editor = editor;
147
- this.onUpdate = onUpdate;
148
- editor.getJSON();
149
- this.off = editor.on("change", () => {
150
- const size = measure(editor.getJSON());
151
- for (const [id, person] of this.people) {
152
- this.people.set(id, {
153
- ...person,
154
- anchor: Math.min(person.anchor, size),
155
- head: Math.min(person.head, size)
143
+ // src/remote-cursors.ts
144
+ function readEngine2(ctx) {
145
+ const access = ctx[/* @__PURE__ */ Symbol.for("matra.engine")];
146
+ if (!access) throw new Error("Matra: remoteCursors ctx was created outside the engine");
147
+ return access;
148
+ }
149
+ function colorFor(clientId) {
150
+ let hash = 0;
151
+ for (let i = 0; i < clientId.length; i++) hash = hash * 31 + clientId.charCodeAt(i) | 0;
152
+ return `hsl(${Math.abs(hash) % 360} 70% 45%)`;
153
+ }
154
+ var META = "matra-presence";
155
+ function remoteCursors() {
156
+ return {
157
+ kind: "extension",
158
+ name: "remoteCursors",
159
+ state: {
160
+ init: () => /* @__PURE__ */ new Map(),
161
+ apply(ctx, previous) {
162
+ const { tr } = readEngine2(ctx);
163
+ const action = tr.getMeta(META);
164
+ let next = previous;
165
+ if (tr.docChanged) {
166
+ const mapped = /* @__PURE__ */ new Map();
167
+ for (const [id, person] of previous) {
168
+ mapped.set(id, {
169
+ ...person,
170
+ anchor: tr.mapping.map(person.anchor),
171
+ head: tr.mapping.map(person.head)
172
+ });
173
+ }
174
+ next = mapped;
175
+ }
176
+ if (!action) return next;
177
+ const copy = new Map(next);
178
+ if ("clear" in action) copy.clear();
179
+ else if ("remove" in action) copy.delete(action.remove);
180
+ else if (isPlacedPresence(action.set)) copy.set(action.set.clientId, action.set);
181
+ return copy;
182
+ }
183
+ },
184
+ decorations(ctx) {
185
+ const { tr, pluginState } = readEngine2(ctx);
186
+ const people = pluginState("remoteCursors");
187
+ if (!people?.size) return [];
188
+ const size = tr.doc.content.size;
189
+ const out = [];
190
+ for (const person of people.values()) {
191
+ const anchor = clamp(person.anchor, size);
192
+ const head = clamp(person.head, size);
193
+ if (anchor === null || head === null) continue;
194
+ const color = colorFor(person.clientId);
195
+ const name = readName(person);
196
+ if (anchor !== head) {
197
+ out.push({
198
+ type: "inline",
199
+ from: Math.min(anchor, head),
200
+ to: Math.max(anchor, head),
201
+ attrs: {
202
+ class: "matra-remote-selection",
203
+ style: `background-color: ${color}; opacity: 0.25`
204
+ }
205
+ });
206
+ }
207
+ out.push({
208
+ type: "widget",
209
+ pos: head,
210
+ // Keyed by client so the renderer reuses the caret element instead of
211
+ // tearing it down and rebuilding it on every keystroke.
212
+ key: `cursor-${person.clientId}`,
213
+ side: 1,
214
+ render: () => renderCaret(color, name)
156
215
  });
157
216
  }
158
- editor.getJSON();
159
- this.onUpdate?.(this.list());
160
- });
161
- }
162
- editor;
163
- onUpdate;
164
- people = /* @__PURE__ */ new Map();
165
- off;
166
- set(person) {
167
- this.people.set(person.clientId, person);
168
- this.onUpdate?.(this.list());
169
- }
170
- remove(clientId) {
171
- if (this.people.delete(clientId)) this.onUpdate?.(this.list());
172
- }
173
- list() {
174
- return [...this.people.values()];
175
- }
176
- destroy() {
177
- this.off();
178
- this.people.clear();
179
- }
180
- };
181
- function measure(doc) {
182
- if (typeof doc.text === "string") return doc.text.length;
183
- let size = 0;
184
- for (const child of doc.content ?? []) {
185
- size += typeof child.text === "string" ? child.text.length : measure(child) + 2;
217
+ return out;
218
+ },
219
+ commands: {
220
+ setPresence: (ctx, presence) => {
221
+ if (!isPlacedPresence(presence)) return false;
222
+ readEngine2(ctx).tr.setMeta(META, { set: presence });
223
+ return true;
224
+ },
225
+ removePresence: (ctx, clientId) => {
226
+ if (typeof clientId !== "string" || !clientId) return false;
227
+ readEngine2(ctx).tr.setMeta(META, { remove: clientId });
228
+ return true;
229
+ },
230
+ clearPresence: (ctx) => {
231
+ readEngine2(ctx).tr.setMeta(META, { clear: true });
232
+ return true;
233
+ }
234
+ }
235
+ };
236
+ }
237
+ function isPlacedPresence(value) {
238
+ if (!value || typeof value !== "object") return false;
239
+ const person = value;
240
+ return typeof person.clientId === "string" && person.clientId.length > 0 && isPlace(person.anchor) && isPlace(person.head);
241
+ }
242
+ function isPlace(value) {
243
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
244
+ }
245
+ function clamp(value, size) {
246
+ if (!isPlace(value)) return null;
247
+ return Math.min(value, size);
248
+ }
249
+ function renderCaret(color, name) {
250
+ const caret = document.createElement("span");
251
+ caret.className = "matra-remote-cursor";
252
+ caret.style.borderLeft = `2px solid ${color}`;
253
+ if (name !== null) {
254
+ const label = document.createElement("span");
255
+ label.className = "matra-remote-cursor-label";
256
+ label.style.backgroundColor = color;
257
+ label.textContent = name;
258
+ caret.appendChild(label);
186
259
  }
187
- return size;
260
+ return caret;
261
+ }
262
+ function readName(person) {
263
+ const name = person.meta?.name;
264
+ if (typeof name !== "string") return null;
265
+ const trimmed = name.trim();
266
+ if (!trimmed) return null;
267
+ return trimmed.slice(0, 40);
268
+ }
269
+ var remoteCursorCSS = `
270
+ .matra-remote-cursor {
271
+ position: relative;
272
+ margin-left: -1px;
273
+ margin-right: -1px;
274
+ pointer-events: none;
275
+ word-break: normal;
276
+ }
277
+ .matra-remote-cursor-label {
278
+ position: absolute;
279
+ top: -1.2em;
280
+ left: -2px;
281
+ padding: 0 4px;
282
+ border-radius: 3px;
283
+ color: #fff;
284
+ font-size: 11px;
285
+ line-height: 1.4;
286
+ white-space: nowrap;
287
+ user-select: none;
288
+ }
289
+ .matra-remote-selection {
290
+ border-radius: 2px;
188
291
  }
292
+ `;
189
293
 
190
- export { Authority, PresenceTracker, collab, getVersion, sendableSteps };
294
+ export { Authority, collab, colorFor, getVersion, remoteCursorCSS, remoteCursors, sendableSteps };
191
295
  //# sourceMappingURL=index.js.map
192
296
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/authority.ts","../src/collab.ts","../src/presence.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { CollabStep } from './types'\n\n/**\n * The server side of the protocol, as a plain object.\n *\n * An authority is barely anything: a list of steps and the rule that a client\n * may only append if it is up to date. Keeping it transport-free means the same\n * object works over WebSocket, HTTP polling or an in-memory channel in tests.\n */\nexport class Authority {\n private readonly history: CollabStep[] = []\n\n constructor(private readonly onChange?: (version: number) => void) {}\n\n get version(): number {\n return this.history.length\n }\n\n /**\n * Append steps if the client is current.\n *\n * Returns false when it is not — the client should pull what it missed,\n * rebase, and try again. Rejecting rather than merging is what keeps the\n * history linear and every client's version meaningful.\n */\n receive(version: number, steps: CollabStep[]): boolean {\n if (version !== this.version) return false\n this.history.push(...steps)\n this.onChange?.(this.version)\n return true\n }\n\n /** Everything that happened after `version`. */\n since(version: number): CollabStep[] {\n return this.history.slice(version)\n }\n}\n","import type { AnyDef, Command, Ctx, Editor, ExtensionDef } from '@matrajs/core'\nimport type { CollabStep, Sendable } from './types'\n\nexport interface CollabOptions {\n /** Identifies this client. Two clients must never share one. */\n clientId: string\n /** The version this client starts from. */\n version?: number\n}\n\n/** A local step, kept with what it takes to rewind and rebase it. */\nexport interface PendingStep {\n step: EngineStep\n /** The step that undoes it, computed against the document it applied to. */\n inverted: EngineStep\n json: Record<string, unknown>\n clientId: string\n}\n\nexport interface CollabState {\n version: number\n /** Local steps the authority has not confirmed yet. */\n unconfirmed: PendingStep[]\n}\n\n/** The slice of a step this package needs; the engine owns the real type. */\ninterface EngineStep {\n toJSON(): Record<string, unknown>\n invert(doc: unknown): EngineStep\n map(mapping: unknown): EngineStep | null\n}\n\nconst REMOTE = 'collab:remote'\nconst CONFIRM = 'collab:confirm'\nconst REBASED = 'collab:rebased'\n\n/**\n * Collaborative editing over a central authority.\n *\n * The protocol is the well-trodden one: a client sends the steps it has made\n * together with the version they applied to, and the authority accepts them\n * only if that version is still current. A client whose version is stale pulls\n * the steps it missed, rebases its own unconfirmed work over them, and tries\n * again.\n *\n * There is no CRDT here and no dependency. Rebasing already lives in the engine\n * — `Step.map` is what lets a local edit survive a remote one — so\n * collaboration is a version counter and a transport on top of it.\n */\nexport function collab(options: CollabOptions): ExtensionDef<\n {\n receiveCollabSteps: Command<[steps: CollabStep[]]>\n confirmCollabSteps: Command<[count: number]>\n },\n CollabState\n> {\n const clientId = options.clientId\n if (!clientId) throw new Error('Matra: collab needs a clientId')\n\n return {\n kind: 'extension',\n name: 'collab',\n state: {\n init: () => ({ version: options.version ?? 0, unconfirmed: [] }),\n apply: (ctx, previous) => {\n const engine = readEngine(ctx)\n const tr = engine.tr\n\n const confirmed = tr.getMeta(CONFIRM)\n if (typeof confirmed === 'number') {\n return {\n version: previous.version + confirmed,\n unconfirmed: previous.unconfirmed.slice(confirmed),\n }\n }\n\n const rebased = tr.getMeta(REBASED) as\n | { remote: number; unconfirmed: PendingStep[] }\n | undefined\n if (rebased) {\n return {\n version: previous.version + rebased.remote,\n unconfirmed: rebased.unconfirmed,\n }\n }\n\n if (!tr.steps.length) return previous\n if (tr.getMeta(REMOTE)) return previous\n\n // Local work: keep each step with its inverse, which is what lets it be\n // rewound and replayed when someone else's edit arrives first.\n const pending: PendingStep[] = tr.steps.map((step, index) => ({\n step,\n inverted: step.invert(tr.docs[index]),\n json: step.toJSON(),\n clientId,\n }))\n return {\n version: previous.version,\n unconfirmed: [...previous.unconfirmed, ...pending],\n }\n },\n },\n commands: {\n /**\n * Apply steps from other clients.\n *\n * Steps this client sent are skipped — they are already in the document,\n * and applying them twice would duplicate the edit. A step that no longer\n * applies is dropped rather than throwing: one bad message from a peer\n * must not take the editor down.\n */\n receiveCollabSteps: (ctx, incoming) => {\n if (!incoming?.length) return false\n const engine = readEngine(ctx)\n const foreign = incoming.filter((entry) => entry.clientId !== clientId)\n if (!foreign.length) return false\n\n const pending =\n (engine.pluginState('collab') as CollabState | undefined)?.unconfirmed ?? []\n const tr = engine.tr\n\n // Rewind local work so the remote steps land on the document the\n // authority actually has. Applying them on top of unsent local edits\n // would leave both the document and the outgoing positions wrong.\n for (let i = pending.length - 1; i >= 0; i--) {\n tr.maybeStep(pending[i]?.inverted as never)\n }\n\n const beforeRemote = tr.steps.length\n let applied = 0\n for (const entry of foreign) {\n const step = engine.stepFromJSON(entry.step)\n if (step && tr.maybeStep(step)) applied++\n }\n if (!applied) return false\n\n // Replay local work over the remote changes.\n const remoteMapping = tr.mapping.slice(beforeRemote)\n const rebased: PendingStep[] = []\n for (const entry of pending) {\n const mapped = entry.step.map(remoteMapping)\n if (!mapped) continue\n const docBefore = tr.doc\n if (!tr.maybeStep(mapped as never)) continue\n rebased.push({\n step: mapped,\n inverted: mapped.invert(docBefore),\n json: mapped.toJSON(),\n clientId,\n })\n }\n\n tr.setMeta(REBASED, { remote: applied, unconfirmed: rebased })\n return true\n },\n\n /** The authority accepted this many of our steps; stop tracking them. */\n confirmCollabSteps: (ctx, count) => {\n if (!Number.isInteger(count) || count <= 0) return false\n readEngine(ctx).tr.setMeta(CONFIRM, count)\n return true\n },\n },\n }\n}\n\n/** Steps this client has made that the authority has not seen. */\nexport function sendableSteps(editor: Editor<readonly AnyDef[]>): Sendable | null {\n const state = editor.extensionState<CollabState>('collab')\n if (!state?.unconfirmed.length) return null\n return {\n version: state.version,\n steps: state.unconfirmed.map((entry) => ({ step: entry.json, clientId: entry.clientId })),\n clientId: state.unconfirmed[0]?.clientId ?? '',\n }\n}\n\n/** The version of the document this client believes it is on. */\nexport function getVersion(editor: Editor<readonly AnyDef[]>): number {\n return editor.extensionState<CollabState>('collab')?.version ?? 0\n}\n\ninterface CollabEngine {\n tr: {\n steps: EngineStep[]\n docs: unknown[]\n doc: unknown\n mapping: { slice(from: number): unknown }\n getMeta(key: string): unknown\n setMeta(key: string, value: unknown): unknown\n maybeStep(step: unknown): boolean\n }\n stepFromJSON(json: Record<string, unknown>): EngineStep | null\n pluginState(key: string): unknown\n}\n\n/** Reach the engine the way bundled extensions do. */\nfunction readEngine(ctx: Ctx): CollabEngine {\n const access = (ctx as unknown as Record<symbol, CollabEngine>)[Symbol.for('matra.engine')]\n if (!access) throw new Error('Matra: collab ctx was created outside the engine')\n return access\n}\n","import type { AnyDef, Editor } from '@matrajs/core'\nimport type { Presence } from './types'\n\n/**\n * Other people's cursors, kept honest as the document changes.\n *\n * A remote caret arrives as a position in the sender's document. The moment\n * anything is typed locally that number is wrong, so every position is mapped\n * forward on each change. Rendering is the host application's job — the engine\n * has no decoration layer, and inventing one here would be the wrong place.\n */\nexport class PresenceTracker {\n private readonly people = new Map<string, Presence>()\n private readonly off: () => void\n\n constructor(\n private readonly editor: Editor<readonly AnyDef[]>,\n private readonly onUpdate?: (people: Presence[]) => void,\n ) {\n let previous = editor.getJSON()\n this.off = editor.on('change', () => {\n // Positions past the end of the document are clamped rather than dropped:\n // a cursor at the end of a paragraph someone just shortened should sit at\n // the new end, not disappear.\n const size = measure(editor.getJSON())\n for (const [id, person] of this.people) {\n this.people.set(id, {\n ...person,\n anchor: Math.min(person.anchor, size),\n head: Math.min(person.head, size),\n })\n }\n previous = editor.getJSON()\n void previous\n this.onUpdate?.(this.list())\n })\n }\n\n set(person: Presence): void {\n this.people.set(person.clientId, person)\n this.onUpdate?.(this.list())\n }\n\n remove(clientId: string): void {\n if (this.people.delete(clientId)) this.onUpdate?.(this.list())\n }\n\n list(): Presence[] {\n return [...this.people.values()]\n }\n\n destroy(): void {\n this.off()\n this.people.clear()\n }\n}\n\nfunction measure(doc: { content?: unknown[]; text?: string }): number {\n if (typeof doc.text === 'string') return doc.text.length\n let size = 0\n for (const child of (doc.content ?? []) as Array<{ content?: unknown[]; text?: string }>) {\n size += typeof child.text === 'string' ? child.text.length : measure(child) + 2\n }\n return size\n}\n"]}
1
+ {"version":3,"sources":["../src/authority.ts","../src/collab.ts","../src/remote-cursors.ts"],"names":["readEngine"],"mappings":"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","file":"index.js","sourcesContent":["import type { CollabStep } from './types'\n\n/**\n * The server side of the protocol, as a plain object.\n *\n * An authority is barely anything: a list of steps and the rule that a client\n * may only append if it is up to date. Keeping it transport-free means the same\n * object works over WebSocket, HTTP polling or an in-memory channel in tests.\n */\nexport class Authority {\n private readonly history: CollabStep[] = []\n\n constructor(private readonly onChange?: (version: number) => void) {}\n\n get version(): number {\n return this.history.length\n }\n\n /**\n * Append steps if the client is current.\n *\n * Returns false when it is not — the client should pull what it missed,\n * rebase, and try again. Rejecting rather than merging is what keeps the\n * history linear and every client's version meaningful.\n */\n receive(version: number, steps: CollabStep[]): boolean {\n if (version !== this.version) return false\n this.history.push(...steps)\n this.onChange?.(this.version)\n return true\n }\n\n /** Everything that happened after `version`. */\n since(version: number): CollabStep[] {\n return this.history.slice(version)\n }\n}\n","import type { AnyDef, Command, Ctx, Editor, ExtensionDef } from '@matrajs/core'\nimport type { CollabStep, Sendable } from './types'\n\nexport interface CollabOptions {\n /** Identifies this client. Two clients must never share one. */\n clientId: string\n /** The version this client starts from. */\n version?: number\n}\n\n/** A local step, kept with what it takes to rewind and rebase it. */\nexport interface PendingStep {\n step: EngineStep\n /** The step that undoes it, computed against the document it applied to. */\n inverted: EngineStep\n json: Record<string, unknown>\n clientId: string\n}\n\nexport interface CollabState {\n version: number\n /** Local steps the authority has not confirmed yet. */\n unconfirmed: PendingStep[]\n}\n\n/** The slice of a step this package needs; the engine owns the real type. */\ninterface EngineStep {\n toJSON(): Record<string, unknown>\n invert(doc: unknown): EngineStep\n map(mapping: unknown): EngineStep | null\n}\n\nconst REMOTE = 'collab:remote'\nconst CONFIRM = 'collab:confirm'\nconst REBASED = 'collab:rebased'\n\n/**\n * Collaborative editing over a central authority.\n *\n * The protocol is the well-trodden one: a client sends the steps it has made\n * together with the version they applied to, and the authority accepts them\n * only if that version is still current. A client whose version is stale pulls\n * the steps it missed, rebases its own unconfirmed work over them, and tries\n * again.\n *\n * There is no CRDT here and no dependency. Rebasing already lives in the engine\n * — `Step.map` is what lets a local edit survive a remote one — so\n * collaboration is a version counter and a transport on top of it.\n */\nexport function collab(options: CollabOptions): ExtensionDef<\n {\n receiveCollabSteps: Command<[steps: CollabStep[]]>\n confirmCollabSteps: Command<[count: number]>\n },\n CollabState\n> {\n const clientId = options.clientId\n if (!clientId) throw new Error('Matra: collab needs a clientId')\n\n return {\n kind: 'extension',\n name: 'collab',\n state: {\n init: () => ({ version: options.version ?? 0, unconfirmed: [] }),\n apply: (ctx, previous) => {\n const engine = readEngine(ctx)\n const tr = engine.tr\n\n const confirmed = tr.getMeta(CONFIRM)\n if (typeof confirmed === 'number') {\n return {\n version: previous.version + confirmed,\n unconfirmed: previous.unconfirmed.slice(confirmed),\n }\n }\n\n const rebased = tr.getMeta(REBASED) as\n | { remote: number; unconfirmed: PendingStep[] }\n | undefined\n if (rebased) {\n return {\n version: previous.version + rebased.remote,\n unconfirmed: rebased.unconfirmed,\n }\n }\n\n if (!tr.steps.length) return previous\n if (tr.getMeta(REMOTE)) return previous\n\n // Local work: keep each step with its inverse, which is what lets it be\n // rewound and replayed when someone else's edit arrives first.\n const pending: PendingStep[] = tr.steps.map((step, index) => ({\n step,\n inverted: step.invert(tr.docs[index]),\n json: step.toJSON(),\n clientId,\n }))\n return {\n version: previous.version,\n unconfirmed: [...previous.unconfirmed, ...pending],\n }\n },\n },\n commands: {\n /**\n * Apply steps from other clients.\n *\n * Steps this client sent are skipped — they are already in the document,\n * and applying them twice would duplicate the edit. A step that no longer\n * applies is dropped rather than throwing: one bad message from a peer\n * must not take the editor down.\n */\n receiveCollabSteps: (ctx, incoming) => {\n if (!incoming?.length) return false\n const engine = readEngine(ctx)\n const foreign = incoming.filter((entry) => entry.clientId !== clientId)\n if (!foreign.length) return false\n\n const pending =\n (engine.pluginState('collab') as CollabState | undefined)?.unconfirmed ?? []\n const tr = engine.tr\n\n // Rewind local work so the remote steps land on the document the\n // authority actually has. Applying them on top of unsent local edits\n // would leave both the document and the outgoing positions wrong.\n for (let i = pending.length - 1; i >= 0; i--) {\n tr.maybeStep(pending[i]?.inverted as never)\n }\n\n const beforeRemote = tr.steps.length\n let applied = 0\n for (const entry of foreign) {\n const step = engine.stepFromJSON(entry.step)\n if (step && tr.maybeStep(step)) applied++\n }\n if (!applied) return false\n\n // Replay local work over the remote changes.\n const remoteMapping = tr.mapping.slice(beforeRemote)\n const rebased: PendingStep[] = []\n for (const entry of pending) {\n const mapped = entry.step.map(remoteMapping)\n if (!mapped) continue\n const docBefore = tr.doc\n if (!tr.maybeStep(mapped as never)) continue\n rebased.push({\n step: mapped,\n inverted: mapped.invert(docBefore),\n json: mapped.toJSON(),\n clientId,\n })\n }\n\n tr.setMeta(REBASED, { remote: applied, unconfirmed: rebased })\n return true\n },\n\n /** The authority accepted this many of our steps; stop tracking them. */\n confirmCollabSteps: (ctx, count) => {\n if (!Number.isInteger(count) || count <= 0) return false\n readEngine(ctx).tr.setMeta(CONFIRM, count)\n return true\n },\n },\n }\n}\n\n/** Steps this client has made that the authority has not seen. */\nexport function sendableSteps(editor: Editor<readonly AnyDef[]>): Sendable | null {\n const state = editor.extensionState<CollabState>('collab')\n if (!state?.unconfirmed.length) return null\n return {\n version: state.version,\n steps: state.unconfirmed.map((entry) => ({ step: entry.json, clientId: entry.clientId })),\n clientId: state.unconfirmed[0]?.clientId ?? '',\n }\n}\n\n/** The version of the document this client believes it is on. */\nexport function getVersion(editor: Editor<readonly AnyDef[]>): number {\n return editor.extensionState<CollabState>('collab')?.version ?? 0\n}\n\ninterface CollabEngine {\n tr: {\n steps: EngineStep[]\n docs: unknown[]\n doc: unknown\n mapping: { slice(from: number): unknown }\n getMeta(key: string): unknown\n setMeta(key: string, value: unknown): unknown\n maybeStep(step: unknown): boolean\n }\n stepFromJSON(json: Record<string, unknown>): EngineStep | null\n pluginState(key: string): unknown\n}\n\n/** Reach the engine the way bundled extensions do. */\nfunction readEngine(ctx: Ctx): CollabEngine {\n const access = (ctx as unknown as Record<symbol, CollabEngine>)[Symbol.for('matra.engine')]\n if (!access) throw new Error('Matra: collab ctx was created outside the engine')\n return access\n}\n","import type { Command, Ctx, DecorationSpec, ExtensionDef, Pos } from '@matrajs/core'\nimport type { Presence } from './types'\n\n/** The slice of the engine this extension needs, reached the way collab does. */\ninterface CursorEngine {\n tr: {\n docChanged: boolean\n doc: { content: { size: number } }\n mapping: { map(pos: number, assoc?: -1 | 1): number }\n getMeta(key: string): unknown\n setMeta(key: string, value: unknown): unknown\n }\n pluginState(key: string): unknown\n}\n\nfunction readEngine(ctx: Ctx): CursorEngine {\n const access = (ctx as unknown as Record<symbol, CursorEngine>)[Symbol.for('matra.engine')]\n if (!access) throw new Error('Matra: remoteCursors ctx was created outside the engine')\n return access\n}\n\n/** Everyone else's caret, in this client's coordinates. */\nexport type RemoteCursors = ReadonlyMap<string, Presence>\n\n/**\n * A colour per person, chosen from the id so it is stable everywhere.\n *\n * Deriving it beats assigning one, because two clients that never speak still\n * agree on what colour a third person is.\n */\nexport function colorFor(clientId: string): string {\n let hash = 0\n for (let i = 0; i < clientId.length; i++) hash = (hash * 31 + clientId.charCodeAt(i)) | 0\n // Fixed saturation and lightness keep every colour legible on white and dark.\n return `hsl(${Math.abs(hash) % 360} 70% 45%)`\n}\n\nconst META = 'matra-presence'\n\n/** What a presence message asks the extension to do. */\ntype PresenceAction = { set: Presence } | { remove: string } | { clear: true }\n\n/**\n * Other people's carets and selections, drawn as decorations.\n *\n * A remote caret arrives as a position in the *sender's* document. The instant\n * anything is typed locally that number is wrong. The only correct fix is to\n * map it through the same steps the local document went through, which is why\n * this is an extension rather than a helper: extension state is reduced inside\n * the transaction pipeline, where the mapping actually lives.\n *\n * Clamping to the document size — the obvious shortcut — is wrong. Insert ten\n * characters at the start and every later caret should move ten forward;\n * clamping leaves them all pointing at the wrong word.\n */\nexport function remoteCursors(): ExtensionDef<\n {\n setPresence: Command<[presence: Presence]>\n removePresence: Command<[clientId: string]>\n clearPresence: Command\n },\n RemoteCursors\n> {\n return {\n kind: 'extension',\n name: 'remoteCursors',\n\n state: {\n init: () => new Map<string, Presence>(),\n\n apply(ctx, previous) {\n const { tr } = readEngine(ctx)\n const action = tr.getMeta(META) as PresenceAction | undefined\n let next = previous\n\n // Map first, then apply the message. A message arriving in the same\n // transaction as an edit already describes the document after it.\n if (tr.docChanged) {\n // This transaction's own mapping, not ctx.mark() — that one maps\n // changes made *after* it is taken, and by the time a reducer runs\n // the change it must account for is the one already in `tr`.\n const mapped = new Map<string, Presence>()\n for (const [id, person] of previous) {\n mapped.set(id, {\n ...person,\n anchor: tr.mapping.map(person.anchor),\n head: tr.mapping.map(person.head),\n })\n }\n next = mapped\n }\n\n if (!action) return next\n\n const copy = new Map(next)\n if ('clear' in action) copy.clear()\n else if ('remove' in action) copy.delete(action.remove)\n else if (isPlacedPresence(action.set)) copy.set(action.set.clientId, action.set)\n return copy\n },\n },\n\n decorations(ctx) {\n const { tr, pluginState } = readEngine(ctx)\n const people = pluginState('remoteCursors') as RemoteCursors | undefined\n if (!people?.size) return []\n\n const size = tr.doc.content.size\n const out: DecorationSpec[] = []\n\n for (const person of people.values()) {\n // A peer can send anything. A caret outside the document would throw\n // deep in the decoration mapper, so it is dropped here instead.\n const anchor = clamp(person.anchor, size)\n const head = clamp(person.head, size)\n if (anchor === null || head === null) continue\n\n const color = colorFor(person.clientId)\n const name = readName(person)\n\n if (anchor !== head) {\n out.push({\n type: 'inline',\n from: Math.min(anchor, head) as Pos,\n to: Math.max(anchor, head) as Pos,\n attrs: {\n class: 'matra-remote-selection',\n style: `background-color: ${color}; opacity: 0.25`,\n },\n })\n }\n\n out.push({\n type: 'widget',\n pos: head as Pos,\n // Keyed by client so the renderer reuses the caret element instead of\n // tearing it down and rebuilding it on every keystroke.\n key: `cursor-${person.clientId}`,\n side: 1,\n render: () => renderCaret(color, name),\n })\n }\n\n return out\n },\n\n commands: {\n setPresence: (ctx, presence) => {\n if (!isPlacedPresence(presence)) return false\n readEngine(ctx).tr.setMeta(META, { set: presence })\n return true\n },\n removePresence: (ctx, clientId) => {\n if (typeof clientId !== 'string' || !clientId) return false\n readEngine(ctx).tr.setMeta(META, { remove: clientId })\n return true\n },\n clearPresence: (ctx) => {\n readEngine(ctx).tr.setMeta(META, { clear: true })\n return true\n },\n },\n }\n}\n\n/** Is this a presence message we can place, or noise from the wire? */\nfunction isPlacedPresence(value: unknown): value is Presence {\n if (!value || typeof value !== 'object') return false\n const person = value as Presence\n return (\n typeof person.clientId === 'string' &&\n person.clientId.length > 0 &&\n isPlace(person.anchor) &&\n isPlace(person.head)\n )\n}\n\nfunction isPlace(value: unknown): value is number {\n return typeof value === 'number' && Number.isInteger(value) && value >= 0\n}\n\nfunction clamp(value: number, size: number): number | null {\n if (!isPlace(value)) return null\n return Math.min(value, size)\n}\n\n/**\n * The caret element.\n *\n * Built with `document.createElement` and `textContent` rather than a template\n * string: the label is a display name chosen by another user, and it must not\n * be able to become markup.\n */\nfunction renderCaret(color: string, name: string | null): HTMLElement {\n const caret = document.createElement('span')\n caret.className = 'matra-remote-cursor'\n caret.style.borderLeft = `2px solid ${color}`\n\n if (name !== null) {\n const label = document.createElement('span')\n label.className = 'matra-remote-cursor-label'\n label.style.backgroundColor = color\n label.textContent = name\n caret.appendChild(label)\n }\n return caret\n}\n\n/** A display name, if the sender supplied a usable one. */\nfunction readName(person: Presence): string | null {\n const name = person.meta?.name\n if (typeof name !== 'string') return null\n const trimmed = name.trim()\n if (!trimmed) return null\n // A name is a label, not an essay; a peer sending 10kB of it is an attack.\n return trimmed.slice(0, 40)\n}\n\n/** Enough styling to see a caret. Drop it in a stylesheet, or write your own. */\nexport const remoteCursorCSS = `\n.matra-remote-cursor {\n position: relative;\n margin-left: -1px;\n margin-right: -1px;\n pointer-events: none;\n word-break: normal;\n}\n.matra-remote-cursor-label {\n position: absolute;\n top: -1.2em;\n left: -2px;\n padding: 0 4px;\n border-radius: 3px;\n color: #fff;\n font-size: 11px;\n line-height: 1.4;\n white-space: nowrap;\n user-select: none;\n}\n.matra-remote-selection {\n border-radius: 2px;\n}\n`\n"]}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@matrajs/collab",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Collaborative editing for Matra — step exchange, rebasing and presence. No CRDT dependency.",
5
- "license": "MIT",
5
+ "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nahim Hossain Shohan",
7
7
  "homepage": "https://matrajs.com",
8
8
  "repository": {
@@ -37,10 +37,11 @@
37
37
  },
38
38
  "files": [
39
39
  "dist",
40
- "README.md"
40
+ "README.md",
41
+ "LICENSE"
41
42
  ],
42
43
  "dependencies": {
43
- "@matrajs/core": "^0.4.1"
44
+ "@matrajs/core": "^0.6.0"
44
45
  },
45
46
  "scripts": {
46
47
  "build": "tsup",