@jxsuite/collab 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/package.json +3 -3
- package/src/awareness-types.ts +8 -2
- package/src/index.ts +9 -0
- package/src/source-lock.ts +98 -0
package/README.md
CHANGED
|
@@ -68,7 +68,8 @@ one `error{read-only}`.
|
|
|
68
68
|
## Awareness (`./awareness-types`)
|
|
69
69
|
|
|
70
70
|
One project-level `Awareness` per connection. State shape:
|
|
71
|
-
`{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, selection? }` —
|
|
71
|
+
`{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, structuralSelection?, selection? }` —
|
|
72
|
+
file-tree presence (the plain `selection` field is y-monaco's in-buffer cursor)
|
|
72
73
|
and per-document cursor overlays both filter the same states. Identity comes from the server
|
|
73
74
|
`hello`; colors are deterministic (`colorForKey`).
|
|
74
75
|
|
|
@@ -77,3 +78,9 @@ and per-document cursor overlays both filter the same states. Identity comes fro
|
|
|
77
78
|
`StudioPlatform.collab?: (docPath) => Promise<CollabHandle | null>` where a handle carries
|
|
78
79
|
`{doc, awareness, whenSynced, identity(), flush(), onStatus(), onReset(), destroy()}`. `onReset`
|
|
79
80
|
fires on a `doc-reset`: the handle is dead — destroy it and re-acquire.
|
|
81
|
+
|
|
82
|
+
## Versioning
|
|
83
|
+
|
|
84
|
+
Published to npm as `@jxsuite/collab` — TypeScript source, like every `@jxsuite`
|
|
85
|
+
package, following the monorepo's release train. Within the monorepo, other
|
|
86
|
+
packages (`@jxsuite/server`, `@jxsuite/studio`) depend on it via `workspace:^`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Realtime co-editing for Jx Studio — Y.Doc schema, op bridge, structural differ, wire envelope, and provider contracts shared by every collab backend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
"./room": "./src/ws-room.ts"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
28
|
-
"provenance":
|
|
28
|
+
"provenance": true
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"test": "bun test --isolate",
|
|
32
32
|
"test:coverage": "bun test --isolate --coverage"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@jxsuite/schema": "
|
|
35
|
+
"@jxsuite/schema": "^0.35.0",
|
|
36
36
|
"lib0": "^0.2.99",
|
|
37
37
|
"y-protocols": "^1.0.6",
|
|
38
38
|
"yjs": "13.6.27"
|
package/src/awareness-types.ts
CHANGED
|
@@ -21,8 +21,14 @@ export interface CollabAwarenessState {
|
|
|
21
21
|
focusedPath: string | null;
|
|
22
22
|
/** Which representation the client is editing (drives the Phase-B canonical lock). */
|
|
23
23
|
mode?: "structure" | "source";
|
|
24
|
-
/** The client's structural selection in the focused document. */
|
|
25
|
-
|
|
24
|
+
/** The client's structural (canvas) selection in the focused document. */
|
|
25
|
+
structuralSelection?: JxPath | null;
|
|
26
|
+
/**
|
|
27
|
+
* RESERVED for y-monaco: the in-buffer text cursor as {anchor, head} Y.RelativePosition JSON.
|
|
28
|
+
* MonacoBinding owns this field via setLocalStateField("selection", …) while a code view is
|
|
29
|
+
* bound; nothing else may write it.
|
|
30
|
+
*/
|
|
31
|
+
selection?: { anchor: unknown; head: unknown } | null;
|
|
26
32
|
/**
|
|
27
33
|
* Whether this client may write (from the server hello). Drives reconciler election — the lowest
|
|
28
34
|
* write-capable clientID mirrors derived representations. Advisory only: the server drops updates
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,15 @@ export {
|
|
|
29
29
|
yEventsToDocOps,
|
|
30
30
|
} from "./op-bridge.ts";
|
|
31
31
|
export { deepEqual, diffDocs, replaceYStructure } from "./diff.ts";
|
|
32
|
+
export {
|
|
33
|
+
acquireSourceCanonical,
|
|
34
|
+
canonicalOf,
|
|
35
|
+
canonicalRevOf,
|
|
36
|
+
isSourceReconciler,
|
|
37
|
+
otherSourceEditors,
|
|
38
|
+
releaseSourceCanonical,
|
|
39
|
+
} from "./source-lock.ts";
|
|
40
|
+
export type { CanonicalRepresentation } from "./source-lock.ts";
|
|
32
41
|
export type { DiffOptions } from "./diff.ts";
|
|
33
42
|
export {
|
|
34
43
|
decodeFrame,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical-representation lock for source-mode co-editing (the Phase-B dual-representation
|
|
3
|
+
* design). `meta.canonical` says which representation is authoritative: "structure" (the default —
|
|
4
|
+
* canvas/inspector edits flow through the op bridge and the elected reconciler mirrors them into
|
|
5
|
+
* source text) or "source" (Monaco co-edits the Y.Text character-level; structural surfaces
|
|
6
|
+
* soft-freeze and the source reconciler mirrors parses back into the structure tree for live
|
|
7
|
+
* previews).
|
|
8
|
+
*
|
|
9
|
+
* Every flip bumps `meta.canonicalRev`; derived-representation writers stamp the rev they computed
|
|
10
|
+
* against and observers discard stale mirrors. Flips are LWW-idempotent under concurrency: both
|
|
11
|
+
* flippers serialize the same structure, so the merged text converges.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Doc } from "yjs";
|
|
15
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
16
|
+
import { metaMap, updateSourceText } from "./schema.ts";
|
|
17
|
+
import type { CollabAwarenessState } from "./awareness-types.ts";
|
|
18
|
+
|
|
19
|
+
export type CanonicalRepresentation = "structure" | "source";
|
|
20
|
+
|
|
21
|
+
export function canonicalOf(doc: Doc): CanonicalRepresentation {
|
|
22
|
+
return metaMap(doc).get("canonical") === "source" ? "source" : "structure";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function canonicalRevOf(doc: Doc): number {
|
|
26
|
+
return Number(metaMap(doc).get("canonicalRev") ?? 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Flip canonical to "source", seeding the text from `serialized` (the flipper's serialization of
|
|
31
|
+
* the current structure). No-op (false) when source already holds the lock.
|
|
32
|
+
*/
|
|
33
|
+
export function acquireSourceCanonical(doc: Doc, serialized: string, origin: unknown): boolean {
|
|
34
|
+
if (canonicalOf(doc) === "source") {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
doc.transact(() => {
|
|
38
|
+
updateSourceText(doc, serialized, origin);
|
|
39
|
+
const meta = metaMap(doc);
|
|
40
|
+
meta.set("canonical", "source");
|
|
41
|
+
meta.set("canonicalRev", canonicalRevOf(doc) + 1);
|
|
42
|
+
}, origin);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Flip canonical back to "structure" (the mirror is the caller's responsibility beforehand). */
|
|
47
|
+
export function releaseSourceCanonical(doc: Doc, origin: unknown): boolean {
|
|
48
|
+
if (canonicalOf(doc) !== "source") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
doc.transact(() => {
|
|
52
|
+
const meta = metaMap(doc);
|
|
53
|
+
meta.set("canonical", "structure");
|
|
54
|
+
meta.set("canonicalRev", canonicalRevOf(doc) + 1);
|
|
55
|
+
}, origin);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Peers (excluding `selfClientId`) currently source-editing this doc, per awareness. */
|
|
60
|
+
export function otherSourceEditors(
|
|
61
|
+
awareness: Awareness,
|
|
62
|
+
path: string,
|
|
63
|
+
selfClientId: number,
|
|
64
|
+
): number[] {
|
|
65
|
+
const editors: number[] = [];
|
|
66
|
+
for (const [clientId, raw] of awareness.getStates()) {
|
|
67
|
+
const state = raw as Partial<CollabAwarenessState>;
|
|
68
|
+
if (clientId !== selfClientId && state.mode === "source" && state.focusedPath === path) {
|
|
69
|
+
editors.push(clientId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return editors;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The source reconciler — the lowest write-capable clientID among this doc's source editors
|
|
77
|
+
* (including self when in source mode) — owns parsing Y.Text back into the structure tree.
|
|
78
|
+
*/
|
|
79
|
+
export function isSourceReconciler(awareness: Awareness, path: string): boolean {
|
|
80
|
+
const self = awareness.clientID;
|
|
81
|
+
const selfState = awareness.getStates().get(self) as Partial<CollabAwarenessState> | undefined;
|
|
82
|
+
if (selfState?.mode !== "source" || selfState.focusedPath !== path) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
let lowest = self;
|
|
86
|
+
for (const [clientId, raw] of awareness.getStates()) {
|
|
87
|
+
const state = raw as Partial<CollabAwarenessState>;
|
|
88
|
+
if (
|
|
89
|
+
state.mode === "source" &&
|
|
90
|
+
state.focusedPath === path &&
|
|
91
|
+
state.canWrite !== false &&
|
|
92
|
+
clientId < lowest
|
|
93
|
+
) {
|
|
94
|
+
lowest = clientId;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return lowest === self;
|
|
98
|
+
}
|