@gogitcms/editor 0.23.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/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// The declarative plugin API. A plugin is an npm package whose entrypoint
|
|
2
|
+
// default-exports `setup(cms, options)`; every registration made through `cms`
|
|
3
|
+
// is scoped to that plugin (see registry.ts). Docs: docs/plugin-system.md.
|
|
4
|
+
|
|
5
|
+
import type { ComponentType } from "react";
|
|
6
|
+
import type { CollabApi, EntryField, IconName } from "@gogitcms/design-system";
|
|
7
|
+
|
|
8
|
+
// A component contribution is always the loader form — `() => import("./X")`
|
|
9
|
+
// or `async () => ({ default: X })` for an already-loaded component. A direct
|
|
10
|
+
// function component would be indistinguishable from a loader at runtime, so
|
|
11
|
+
// the loader form is the only accepted shape; it is also what makes each
|
|
12
|
+
// screen its own lazy chunk under a bundler.
|
|
13
|
+
export type PluginComponentLoader<P> = () => Promise<{ default: ComponentType<P> }>;
|
|
14
|
+
|
|
15
|
+
// Field types a plugin field component may attach to. Mirrors the schema's
|
|
16
|
+
// closed `type` set (packages/importer/schema).
|
|
17
|
+
export type PluginFieldType = "string" | "boolean" | "integer" | "float" | "array" | "object";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A document as a plugin sees it — the editor's in-flight state, not the saved
|
|
21
|
+
* row. `fields` is the whole document rather than a diff, because a plugin that
|
|
22
|
+
* renders a preview needs every value, not only the changed ones.
|
|
23
|
+
*/
|
|
24
|
+
export type PluginDocument = {
|
|
25
|
+
id: string;
|
|
26
|
+
collection: string;
|
|
27
|
+
/** Repo-relative path, e.g. "content/blog/hello.md". */
|
|
28
|
+
path: string;
|
|
29
|
+
fields: Record<string, unknown>;
|
|
30
|
+
body?: string;
|
|
31
|
+
/** The field name the body is exposed as, when the schema declares one. */
|
|
32
|
+
bodyField?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extra props a route gets when it is rendered as a *tab* beside the editor
|
|
37
|
+
* rather than as a full screen at its URL.
|
|
38
|
+
*
|
|
39
|
+
* Absent on a screen. A route can serve both — it just checks whether this is
|
|
40
|
+
* there — which is what makes "open this route in a tab" a property of how the
|
|
41
|
+
* route was opened rather than a second kind of registration.
|
|
42
|
+
*/
|
|
43
|
+
export type PluginTabContext = {
|
|
44
|
+
/** The tab's id, as passed to openTab. */
|
|
45
|
+
id: string;
|
|
46
|
+
/** The document that was open when the tab was opened, or null. */
|
|
47
|
+
document: PluginDocument | null;
|
|
48
|
+
/**
|
|
49
|
+
* Subscribe to that document's in-flight edits. The host debounces before
|
|
50
|
+
* calling (~150 ms), so a subscriber does not need its own timer. Returns an
|
|
51
|
+
* unsubscribe function.
|
|
52
|
+
*/
|
|
53
|
+
onDraft: (cb: (doc: PluginDocument) => void) => () => void;
|
|
54
|
+
/** Open a document in the editor — the inverse direction, for click-to-edit. */
|
|
55
|
+
openDocument: (id: string, field?: string) => void;
|
|
56
|
+
/** Close this tab. */
|
|
57
|
+
close: () => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Props handed to a plugin route's screen. */
|
|
61
|
+
export type PluginScreenProps = {
|
|
62
|
+
workspaceId: string;
|
|
63
|
+
repositoryId: string;
|
|
64
|
+
branchId?: string;
|
|
65
|
+
/** Git ref name of the branch, when the host knows it. */
|
|
66
|
+
ref?: string;
|
|
67
|
+
/** Matched `:segment` params from the route path, plus any openTab params. */
|
|
68
|
+
params: Record<string, string>;
|
|
69
|
+
/** Navigate to another of this plugin's routes (a registered route path). */
|
|
70
|
+
navigate: (to: string) => void;
|
|
71
|
+
/** Base URL of the CMS API, for authenticated fetches. */
|
|
72
|
+
apiUrl: string;
|
|
73
|
+
/** Set only when this route is rendered as a tab (see PluginTabContext). */
|
|
74
|
+
tab?: PluginTabContext;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Props handed to a plugin field component — the field's data as one object.
|
|
79
|
+
* This is deliberately the design-system's FieldSlotArgs shape (the
|
|
80
|
+
* `renderField` seam), so plugin fields plug in exactly where the ProseMirror
|
|
81
|
+
* body editor already does.
|
|
82
|
+
*/
|
|
83
|
+
export type PluginFieldProps = {
|
|
84
|
+
/** The full schema field: name, label, type, constraints, nested fields. */
|
|
85
|
+
field: EntryField;
|
|
86
|
+
value: unknown;
|
|
87
|
+
onChange: (value: unknown) => void;
|
|
88
|
+
readOnly?: boolean;
|
|
89
|
+
/** Dotted path of the field within the document (present under collab). */
|
|
90
|
+
path?: string;
|
|
91
|
+
/** Live-collaboration binding when a collab session is active. */
|
|
92
|
+
collab?: CollabApi;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type PluginRoute = {
|
|
96
|
+
/** Relative path under the plugin's mount, e.g. "dashboard" or "reports/:kind". */
|
|
97
|
+
path: string;
|
|
98
|
+
title?: string;
|
|
99
|
+
component: PluginComponentLoader<PluginScreenProps>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type PluginSidebarLink = {
|
|
103
|
+
label: string;
|
|
104
|
+
icon?: IconName;
|
|
105
|
+
/** One of this plugin's route paths. */
|
|
106
|
+
to: string;
|
|
107
|
+
/** Sidebar section title to group under. Default: "Plugins". */
|
|
108
|
+
section?: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type PluginUserMenuLink = {
|
|
112
|
+
label: string;
|
|
113
|
+
icon?: IconName;
|
|
114
|
+
/** One of this plugin's route paths… */
|
|
115
|
+
to?: string;
|
|
116
|
+
/** …or an action. Exactly one of `to`/`onSelect` is required. */
|
|
117
|
+
onSelect?: () => void;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Where a plugin asks the host to open one of its routes as a tab. */
|
|
121
|
+
export type PluginTabSpec = {
|
|
122
|
+
/**
|
|
123
|
+
* Stable tab id. Opening the same id again activates the existing tab rather
|
|
124
|
+
* than stacking duplicates — so pressing Preview twice on one document does
|
|
125
|
+
* not end up with two preview columns.
|
|
126
|
+
*/
|
|
127
|
+
id: string;
|
|
128
|
+
/** Column header label. */
|
|
129
|
+
title: string;
|
|
130
|
+
/** One of this plugin's registered route paths. */
|
|
131
|
+
route: string;
|
|
132
|
+
/** Merged into the route component's `params`. */
|
|
133
|
+
params?: Record<string, string>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** What a document action is handed when the user presses its button. */
|
|
137
|
+
export type PluginDocumentActionContext = {
|
|
138
|
+
workspaceId: string;
|
|
139
|
+
repositoryId: string;
|
|
140
|
+
branchId?: string;
|
|
141
|
+
ref?: string;
|
|
142
|
+
apiUrl: string;
|
|
143
|
+
/** The document whose toolbar the button was pressed in. */
|
|
144
|
+
document: PluginDocument;
|
|
145
|
+
/** Open one of this plugin's routes as a column tab. */
|
|
146
|
+
openTab: (spec: PluginTabSpec) => void;
|
|
147
|
+
/** Open a document in the editor. */
|
|
148
|
+
openDocument: (id: string, field?: string) => void;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A button in a document's toolbar.
|
|
153
|
+
*
|
|
154
|
+
* `available` is what keeps the toolbar honest: a preview plugin configured for
|
|
155
|
+
* three of a repository's eight collections should show a Preview button on
|
|
156
|
+
* three of them, not eight with five that error. Returning false hides the
|
|
157
|
+
* button entirely rather than disabling it, because a disabled button the user
|
|
158
|
+
* cannot ever enable is worse than no button.
|
|
159
|
+
*/
|
|
160
|
+
export type PluginDocumentAction = {
|
|
161
|
+
/** Stable id, unique within the plugin. */
|
|
162
|
+
id: string;
|
|
163
|
+
label: string;
|
|
164
|
+
icon?: IconName;
|
|
165
|
+
/** Restrict to some collections; omitted means all. */
|
|
166
|
+
collections?: string[];
|
|
167
|
+
/** Final say on whether the button renders for a particular document. */
|
|
168
|
+
available?: (doc: PluginDocument) => boolean;
|
|
169
|
+
onSelect: (ctx: PluginDocumentActionContext) => void | Promise<void>;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type PluginFieldComponent = {
|
|
173
|
+
/**
|
|
174
|
+
* Bare component name; schemas address it as `plugin:<name>`. One global
|
|
175
|
+
* namespace across plugins — a collision is rejected with an error and the
|
|
176
|
+
* first registration wins.
|
|
177
|
+
*/
|
|
178
|
+
name: string;
|
|
179
|
+
/** Schema field types the component accepts. */
|
|
180
|
+
types: PluginFieldType[];
|
|
181
|
+
component: PluginComponentLoader<PluginFieldProps>;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The declarative API a plugin's setup function receives. */
|
|
185
|
+
export interface CmsPluginApi {
|
|
186
|
+
/** The plugin's own id (its package name) — for display/debugging. */
|
|
187
|
+
readonly id: string;
|
|
188
|
+
/**
|
|
189
|
+
* Mint a short-lived credential for a preview service, scoped to one
|
|
190
|
+
* repository.
|
|
191
|
+
*
|
|
192
|
+
* This is how a plugin authenticates to something that trusts the CMS without
|
|
193
|
+
* ever holding the user's access token — that one authenticates everything
|
|
194
|
+
* else the CMS can do, and a plugin is third-party code by design. The token
|
|
195
|
+
* expires in minutes and every CMS endpoint refuses it.
|
|
196
|
+
*
|
|
197
|
+
* Rejects when the host cannot mint them (an older host, or one with no CMS
|
|
198
|
+
* session).
|
|
199
|
+
*/
|
|
200
|
+
getPreviewToken(repositoryId: string): Promise<string>;
|
|
201
|
+
/** Register a screen at …/plugins/<pluginId>/<path> (repo-scoped). */
|
|
202
|
+
addRoute(route: PluginRoute): void;
|
|
203
|
+
/** Add a row to the sidebar linking to one of this plugin's routes. */
|
|
204
|
+
addSidebarLink(link: PluginSidebarLink): void;
|
|
205
|
+
/** Add an item to the user (avatar) menu. */
|
|
206
|
+
addUserMenuLink(link: PluginUserMenuLink): void;
|
|
207
|
+
/** Register an editor field component, addressed as `plugin:<name>`. */
|
|
208
|
+
registerFieldComponent(reg: PluginFieldComponent): void;
|
|
209
|
+
/**
|
|
210
|
+
* Add a button to the toolbar of an open document. Its handler can open one
|
|
211
|
+
* of this plugin's routes as a tab beside the editor — which is how a preview
|
|
212
|
+
* pane is built without the CMS knowing anything about previews.
|
|
213
|
+
*/
|
|
214
|
+
addDocumentAction(action: PluginDocumentAction): void;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A plugin's setup function — the package entrypoint's default export. */
|
|
218
|
+
export type PluginSetup = (cms: CmsPluginApi, options?: unknown) => void | Promise<void>;
|
|
219
|
+
|
|
220
|
+
export type PluginStatus = "pending" | "ready" | "failed";
|
|
221
|
+
|
|
222
|
+
/** One entry to load: how `loadPlugins` reaches a plugin's module. */
|
|
223
|
+
export type PluginLoadEntry = {
|
|
224
|
+
/** The plugin id (package name). */
|
|
225
|
+
id: string;
|
|
226
|
+
/** Options forwarded as the setup function's second argument. */
|
|
227
|
+
options?: unknown;
|
|
228
|
+
/** Imports the plugin module (dynamic `import()` in both hosts). */
|
|
229
|
+
load: () => Promise<unknown>;
|
|
230
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { colorForId, computeSplice } from "../pure";
|
|
2
|
+
|
|
3
|
+
describe("computeSplice", () => {
|
|
4
|
+
// Applying the returned splice to `cur` must reproduce `next` exactly.
|
|
5
|
+
const apply = (cur: string, next: string) => {
|
|
6
|
+
const { index, deleteCount, insert } = computeSplice(cur, next);
|
|
7
|
+
return cur.slice(0, index) + insert + cur.slice(index + deleteCount);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const cases: Array<[string, string]> = [
|
|
11
|
+
["", ""],
|
|
12
|
+
["", "hello"],
|
|
13
|
+
["hello", ""],
|
|
14
|
+
["hello", "hello"],
|
|
15
|
+
["hello", "hello world"], // append
|
|
16
|
+
["hello world", "hello"], // truncate
|
|
17
|
+
["hello", "jello"], // replace head
|
|
18
|
+
["hello", "helLo"], // replace middle
|
|
19
|
+
["cat", "cart"], // insert middle
|
|
20
|
+
["cart", "cat"], // delete middle
|
|
21
|
+
["aaaa", "aa"], // ambiguous repeats
|
|
22
|
+
["The quick brown fox", "The slow brown fox"],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
test.each(cases)("apply(computeSplice(%j, %j)) === next", (cur, next) => {
|
|
26
|
+
expect(apply(cur, next)).toBe(next);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("edits only the changed middle (preserves shared prefix/suffix)", () => {
|
|
30
|
+
// "Seeded title" -> "Seeded NEW title": prefix "Seeded ", suffix "title".
|
|
31
|
+
const s = computeSplice("Seeded title", "Seeded NEW title");
|
|
32
|
+
expect(s).toEqual({ index: 7, deleteCount: 0, insert: "NEW " });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a pure append does not delete anything", () => {
|
|
36
|
+
expect(computeSplice("abc", "abcdef")).toEqual({ index: 3, deleteCount: 0, insert: "def" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a pure deletion inserts nothing", () => {
|
|
40
|
+
expect(computeSplice("abcdef", "abc")).toEqual({ index: 3, deleteCount: 3, insert: "" });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("colorForId", () => {
|
|
45
|
+
test("is deterministic for the same id", () => {
|
|
46
|
+
expect(colorForId("user-123")).toBe(colorForId("user-123"));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("produces a valid hsl string", () => {
|
|
50
|
+
expect(colorForId("abc")).toMatch(/^hsl\(\d{1,3} 70% 45%\)$/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("varies across different ids", () => {
|
|
54
|
+
const colors = new Set(["a", "b", "c", "d", "e", "f", "g", "h"].map(colorForId));
|
|
55
|
+
// Not all identical — the hash spreads ids across hues.
|
|
56
|
+
expect(colors.size).toBeGreaterThan(1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AQjYhJqjDwAEAQ9mOnNlby5tZXRhVGl0bGUJU0VPIHRpdGxlBAEHZjp0aXRsZQVBbHBoYSgBA3JlZwhmZWF0dXJlZAF4KAEDcmVnBWNvdW50AXxAQAAAKAEDcmVnC3Nlby5ub2luZGV4AXkoAQNyZWcGYmxvY2tzAXUCdgIIX3ZhcmlhbnR3BGhlcm8HaGVhZGluZ3cHV2VsY29tZXYCCF92YXJpYW50dwdtZXRyaWNzBWl0ZW1zdQF2AgVsYWJlbHcHRGVwbG95cwV2YWx1ZXcCMTQoAQNyZWcEdGFncwF1AncCZ293A2NtcygBBG1ldGEEYmFzZQF35AF7ImJsb2NrcyI6W3siX3ZhcmlhbnQiOiJoZXJvIiwiaGVhZGluZyI6IldlbGNvbWUifSx7Il92YXJpYW50IjoibWV0cmljcyIsIml0ZW1zIjpbeyJsYWJlbCI6IkRlcGxveXMiLCJ2YWx1ZSI6IjE0In1dfV0sImNvdW50IjozLCJmZWF0dXJlZCI6dHJ1ZSwic2VvIjp7Im1ldGFUaXRsZSI6IlNFTyB0aXRsZSIsIm5vaW5kZXgiOmZhbHNlfSwidGFncyI6WyJnbyIsImNtcyJdLCJ0aXRsZSI6IkFscGhhIn0A
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// What the server seeds has to be what the editor can render.
|
|
2
|
+
//
|
|
3
|
+
// The Go tests prove the collab server converges with itself. They cannot prove
|
|
4
|
+
// the part the browser depends on: that Yjs decodes a seeded list into a real JS
|
|
5
|
+
// array of plain objects. A control that renders a list checks `Array.isArray`
|
|
6
|
+
// and shows nothing when that is false, which is indistinguishable — on screen —
|
|
7
|
+
// from a document with no items. Mixed lists sat in exactly that state, so the
|
|
8
|
+
// encoding gets a test rather than an assumption.
|
|
9
|
+
//
|
|
10
|
+
// The fixture is the literal update a seeded room sends a client, generated by
|
|
11
|
+
// packages/collab's TestFixtureForYjsDecode (WRITE_FIXTURE=1). Regenerate it
|
|
12
|
+
// there if the seeded shapes change.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import * as Y from "yjs";
|
|
17
|
+
|
|
18
|
+
const FIELD_PREFIX = "f:";
|
|
19
|
+
|
|
20
|
+
function seededDoc(): Y.Doc {
|
|
21
|
+
const base64 = readFileSync(join(__dirname, "seeded-room.base64"), "utf8").trim();
|
|
22
|
+
const doc = new Y.Doc();
|
|
23
|
+
Y.applyUpdate(doc, new Uint8Array(Buffer.from(base64, "base64")));
|
|
24
|
+
return doc;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("a room seeded by the Go server, read through Yjs", () => {
|
|
28
|
+
it("gives a mixed list back as an array the editor can render", () => {
|
|
29
|
+
const blocks = seededDoc().getMap("reg").get("blocks");
|
|
30
|
+
|
|
31
|
+
// The check every list control makes before it renders anything.
|
|
32
|
+
expect(Array.isArray(blocks)).toBe(true);
|
|
33
|
+
expect(blocks).toEqual([
|
|
34
|
+
{ _variant: "hero", heading: "Welcome" },
|
|
35
|
+
{ _variant: "metrics", items: [{ label: "Deploys", value: "14" }] },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps a variant's own nested list nested", () => {
|
|
40
|
+
const blocks = seededDoc().getMap("reg").get("blocks") as Record<string, unknown>[];
|
|
41
|
+
const metrics = blocks[1];
|
|
42
|
+
// An item whose fields arrived as something other than a plain object
|
|
43
|
+
// renders as an empty row inside a list that looks populated.
|
|
44
|
+
expect(Object.getPrototypeOf(metrics)).toBe(Object.prototype);
|
|
45
|
+
expect(Array.isArray(metrics.items)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("gives a list of primitives back as an array too", () => {
|
|
49
|
+
expect(seededDoc().getMap("reg").get("tags")).toEqual(["go", "cms"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("puts scalars in the register and strings in their own text", () => {
|
|
53
|
+
const doc = seededDoc();
|
|
54
|
+
const reg = doc.getMap("reg");
|
|
55
|
+
expect(reg.get("featured")).toBe(true);
|
|
56
|
+
expect(reg.get("count")).toBe(3);
|
|
57
|
+
expect(doc.getText(`${FIELD_PREFIX}title`).toString()).toBe("Alpha");
|
|
58
|
+
// Nested group leaves land at their dotted path, which is what the field
|
|
59
|
+
// controls bind to.
|
|
60
|
+
expect(doc.getText(`${FIELD_PREFIX}seo.metaTitle`).toString()).toBe("SEO title");
|
|
61
|
+
expect(reg.get("seo.noindex")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("hands out a stable reference for an unchanged value", () => {
|
|
65
|
+
// useSyncExternalStore re-renders until getSnapshot returns the same value
|
|
66
|
+
// twice; a decoder that rebuilt the array per read would spin the editor.
|
|
67
|
+
const reg = seededDoc().getMap("reg");
|
|
68
|
+
expect(reg.get("blocks")).toBe(reg.get("blocks"));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import { WebsocketProvider } from "y-websocket";
|
|
3
|
+
import { colorForId, computeSplice } from "./pure";
|
|
4
|
+
|
|
5
|
+
export { colorForId } from "./pure";
|
|
6
|
+
|
|
7
|
+
// A collaborator's identity, surfaced to presence UI.
|
|
8
|
+
export type CollabPeer = { id: string; name: string; color: string };
|
|
9
|
+
|
|
10
|
+
// A connected collaborator plus the field they are currently focused on (null
|
|
11
|
+
// when not editing any field). Drives the document's stacked presence avatars.
|
|
12
|
+
export type CollabParticipant = { id: string; name: string; color: string; focus: string | null };
|
|
13
|
+
|
|
14
|
+
// A live binding to one text field's Y.Text. Values converge across clients.
|
|
15
|
+
export type CollabText = {
|
|
16
|
+
get(): string;
|
|
17
|
+
set(next: string): void;
|
|
18
|
+
// Peers currently focused on this field (excludes the local user).
|
|
19
|
+
peers(): CollabPeer[];
|
|
20
|
+
// Fires on value change OR presence change for this field. Returns unsubscribe.
|
|
21
|
+
subscribe(cb: () => void): () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// A live binding to one scalar field (select/boolean/number/date) held as a
|
|
25
|
+
// last-writer-wins register in the shared "reg" Y.Map.
|
|
26
|
+
export type CollabRegister = {
|
|
27
|
+
get(): unknown;
|
|
28
|
+
set(next: unknown): void;
|
|
29
|
+
subscribe(cb: () => void): () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type CollabStatus = "connecting" | "connected" | "disconnected";
|
|
33
|
+
|
|
34
|
+
export type CollabUser = { id: string; name: string; color?: string };
|
|
35
|
+
|
|
36
|
+
// The per-document collaboration handle. Structurally matches design-system's
|
|
37
|
+
// CollabApi so it can be handed to <ContentBrowser> without either package
|
|
38
|
+
// importing the other.
|
|
39
|
+
export type DocCollab = {
|
|
40
|
+
text(path: string): CollabText;
|
|
41
|
+
register(path: string): CollabRegister;
|
|
42
|
+
// Announce/clear which field this user is editing (null = not focused).
|
|
43
|
+
setFocus(path: string | null): void;
|
|
44
|
+
// Presence for any field path (text or not), excluding the local user.
|
|
45
|
+
peersAt(path: string): CollabPeer[];
|
|
46
|
+
// Presence anywhere under a group path ("seo" matches focus "seo.title"),
|
|
47
|
+
// used to highlight a collapsed group whose child a peer is editing.
|
|
48
|
+
peersUnder(prefix: string): CollabPeer[];
|
|
49
|
+
// Every connected collaborator (excluding self), for the document's stacked
|
|
50
|
+
// presence avatars, each with the field they are currently editing.
|
|
51
|
+
participants(): CollabParticipant[];
|
|
52
|
+
subscribeAwareness(cb: () => void): () => void;
|
|
53
|
+
// Monotonic counter bumped on every awareness change, for tick-based presence
|
|
54
|
+
// subscriptions (useSyncExternalStore) that then read peersAt/peersUnder.
|
|
55
|
+
awarenessVersion(): number;
|
|
56
|
+
status(): CollabStatus;
|
|
57
|
+
subscribeStatus(cb: () => void): () => void;
|
|
58
|
+
// Whether the provider has completed its initial sync with the server room.
|
|
59
|
+
// Distinct from status(): a socket is "connected" before the first sync-step
|
|
60
|
+
// exchange finishes, and anything that reads the shared doc to decide
|
|
61
|
+
// something (e.g. "is the body fragment empty?") must wait for this.
|
|
62
|
+
synced(): boolean;
|
|
63
|
+
subscribeSynced(cb: () => void): () => void;
|
|
64
|
+
// Opaque handles the body (ProseMirror) editor binds to in a later phase.
|
|
65
|
+
readonly ydoc: Y.Doc;
|
|
66
|
+
readonly awareness: WebsocketProvider["awareness"];
|
|
67
|
+
destroy(): void;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type CreateDocCollabOptions = {
|
|
71
|
+
// Base WebSocket URL of the collab endpoint, e.g. "wss://host/collab/ws".
|
|
72
|
+
wsBase: string;
|
|
73
|
+
// Room = "<repositoryId>_<documentId>".
|
|
74
|
+
room: string;
|
|
75
|
+
user: CollabUser;
|
|
76
|
+
// Returns the current access token; called on every (re)connect so a refreshed
|
|
77
|
+
// token is used. The token rides the WebSocket subprotocol, never the URL.
|
|
78
|
+
getToken: () => string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const FIELD_PREFIX = "f:";
|
|
82
|
+
|
|
83
|
+
// Shallow equality of two peer lists (by id set, order-insensitive) so cached
|
|
84
|
+
// snapshots keep a stable reference across awareness events with no real change.
|
|
85
|
+
function samePeers(a: CollabPeer[], b: CollabPeer[]): boolean {
|
|
86
|
+
if (a.length !== b.length) return false;
|
|
87
|
+
const ids = new Set(a.map((p) => p.id));
|
|
88
|
+
return b.every((p) => ids.has(p.id));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// createDocCollab wires a Yjs doc to the collab server for one document.
|
|
92
|
+
export function createDocCollab(opts: CreateDocCollabOptions): DocCollab {
|
|
93
|
+
const color = opts.user.color ?? colorForId(opts.user.id);
|
|
94
|
+
const ydoc = new Y.Doc();
|
|
95
|
+
|
|
96
|
+
// Authenticate via a query-string token, the standard y-websocket pattern.
|
|
97
|
+
// (A subprotocol token was tried first, but Chrome resets a connection when
|
|
98
|
+
// the client offers a subprotocol the server does not echo, and ygo's
|
|
99
|
+
// upgrader cannot be made to echo one.) The token is short-lived and the
|
|
100
|
+
// server's request logger records only the path, never the query string, so
|
|
101
|
+
// it is not leaked to logs. `params` is a live reference: refreshing it before
|
|
102
|
+
// a reconnect lets the socket pick up a newer token.
|
|
103
|
+
const params: Record<string, string> = { token: opts.getToken() };
|
|
104
|
+
if (typeof console !== "undefined") {
|
|
105
|
+
console.log(`[collab ${opts.room}] connecting to ${opts.wsBase}/${opts.room}`, {
|
|
106
|
+
hasToken: !!params.token,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const provider = new WebsocketProvider(opts.wsBase, opts.room, ydoc, { params });
|
|
110
|
+
const awareness = provider.awareness;
|
|
111
|
+
awareness.setLocalStateField("user", { id: opts.user.id, name: opts.user.name, color });
|
|
112
|
+
awareness.setLocalStateField("focus", null);
|
|
113
|
+
|
|
114
|
+
// ---- presence snapshots (stable refs for useSyncExternalStore) ----
|
|
115
|
+
let peerBuckets = new Map<string, CollabPeer[]>();
|
|
116
|
+
const awarenessListeners = new Set<() => void>();
|
|
117
|
+
const recomputePeers = () => {
|
|
118
|
+
const next = new Map<string, CollabPeer[]>();
|
|
119
|
+
awareness.getStates().forEach((state, clientId) => {
|
|
120
|
+
if (clientId === awareness.clientID) return; // exclude self
|
|
121
|
+
const focus = state?.focus as string | null | undefined;
|
|
122
|
+
const user = state?.user as CollabPeer | undefined;
|
|
123
|
+
if (!focus || !user) return;
|
|
124
|
+
const list = next.get(focus) ?? [];
|
|
125
|
+
list.push(user);
|
|
126
|
+
next.set(focus, list);
|
|
127
|
+
});
|
|
128
|
+
// Preserve array references where the peer set is unchanged.
|
|
129
|
+
const merged = new Map<string, CollabPeer[]>();
|
|
130
|
+
const paths = new Set([...peerBuckets.keys(), ...next.keys()]);
|
|
131
|
+
paths.forEach((path) => {
|
|
132
|
+
const prev = peerBuckets.get(path) ?? [];
|
|
133
|
+
const cur = next.get(path) ?? [];
|
|
134
|
+
merged.set(path, samePeers(prev, cur) ? prev : cur);
|
|
135
|
+
});
|
|
136
|
+
peerBuckets = merged;
|
|
137
|
+
};
|
|
138
|
+
let awarenessVer = 0;
|
|
139
|
+
recomputePeers();
|
|
140
|
+
awareness.on("change", () => {
|
|
141
|
+
recomputePeers();
|
|
142
|
+
awarenessVer++;
|
|
143
|
+
awarenessListeners.forEach((cb) => cb());
|
|
144
|
+
});
|
|
145
|
+
const emptyPeers: CollabPeer[] = [];
|
|
146
|
+
|
|
147
|
+
// peersUnder returns every peer whose focus is the prefix itself or nested
|
|
148
|
+
// beneath it ("seo" matches "seo.title"), deduped by user id.
|
|
149
|
+
const peersUnder = (prefix: string): CollabPeer[] => {
|
|
150
|
+
const seen = new Set<string>();
|
|
151
|
+
const out: CollabPeer[] = [];
|
|
152
|
+
peerBuckets.forEach((peers, focus) => {
|
|
153
|
+
if (focus === prefix || focus.startsWith(prefix + ".")) {
|
|
154
|
+
for (const p of peers) {
|
|
155
|
+
if (!seen.has(p.id)) {
|
|
156
|
+
seen.add(p.id);
|
|
157
|
+
out.push(p);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return out;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ---- connection status ----
|
|
166
|
+
let status: CollabStatus = "connecting";
|
|
167
|
+
const statusListeners = new Set<() => void>();
|
|
168
|
+
const tag = `[collab ${opts.room}]`;
|
|
169
|
+
provider.on("status", (e: { status: "connecting" | "connected" | "disconnected" }) => {
|
|
170
|
+
status = e.status;
|
|
171
|
+
// Refresh the query token before the next connect so a reconnect after a
|
|
172
|
+
// token rotation uses the current one.
|
|
173
|
+
if (e.status !== "connected") params.token = opts.getToken();
|
|
174
|
+
if (typeof console !== "undefined") console.debug(`${tag} status: ${e.status}`);
|
|
175
|
+
statusListeners.forEach((cb) => cb());
|
|
176
|
+
});
|
|
177
|
+
const syncListeners = new Set<() => void>();
|
|
178
|
+
provider.on("sync", (isSynced: boolean) => {
|
|
179
|
+
if (typeof console !== "undefined") console.debug(`${tag} synced: ${isSynced}`);
|
|
180
|
+
syncListeners.forEach((cb) => cb());
|
|
181
|
+
});
|
|
182
|
+
// Surface handshake rejections (e.g. a blocked cross-origin upgrade or an
|
|
183
|
+
// invalid token) that otherwise look like a silent "connected but no sync".
|
|
184
|
+
provider.on("connection-close", (event: CloseEvent | null) => {
|
|
185
|
+
if (event && (event.code !== 1000 || event.reason) && typeof console !== "undefined") {
|
|
186
|
+
console.warn(`${tag} connection closed: code=${event.code} reason=${event.reason || "(none)"}`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
provider.on("connection-error", (event: Event) => {
|
|
190
|
+
if (typeof console !== "undefined") console.warn(`${tag} connection error`, event);
|
|
191
|
+
});
|
|
192
|
+
// One-shot probe: report the raw socket state a few seconds in, so a hung
|
|
193
|
+
// upgrade (readyState stays 0 = CONNECTING) is distinguishable from a silent
|
|
194
|
+
// close (3 = CLOSED) or a working connection (1 = OPEN).
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
if (typeof console === "undefined") return;
|
|
197
|
+
const p = provider as unknown as { ws?: WebSocket | null; wsconnected?: boolean };
|
|
198
|
+
const rs = p.ws ? p.ws.readyState : "no-socket";
|
|
199
|
+
const names: Record<number, string> = { 0: "CONNECTING", 1: "OPEN", 2: "CLOSING", 3: "CLOSED" };
|
|
200
|
+
console.log(`${tag} probe`, {
|
|
201
|
+
readyState: typeof rs === "number" ? `${rs} (${names[rs]})` : rs,
|
|
202
|
+
wsconnected: p.wsconnected,
|
|
203
|
+
synced: provider.synced,
|
|
204
|
+
});
|
|
205
|
+
}, 3000);
|
|
206
|
+
|
|
207
|
+
// ---- text bindings (memoized per path) ----
|
|
208
|
+
const textCache = new Map<string, CollabText>();
|
|
209
|
+
const textBinding = (path: string): CollabText => {
|
|
210
|
+
const existing = textCache.get(path);
|
|
211
|
+
if (existing) return existing;
|
|
212
|
+
const ytext = ydoc.getText(FIELD_PREFIX + path);
|
|
213
|
+
const binding: CollabText = {
|
|
214
|
+
get: () => ytext.toString(),
|
|
215
|
+
set: (next: string) => {
|
|
216
|
+
const cur = ytext.toString();
|
|
217
|
+
if (cur === next) return;
|
|
218
|
+
// Minimal splice: replace only the changed middle so unaffected text
|
|
219
|
+
// (and other clients' concurrent edits there) survive.
|
|
220
|
+
const { index, deleteCount, insert } = computeSplice(cur, next);
|
|
221
|
+
ydoc.transact(() => {
|
|
222
|
+
if (deleteCount > 0) ytext.delete(index, deleteCount);
|
|
223
|
+
if (insert) ytext.insert(index, insert);
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
peers: () => peerBuckets.get(path) ?? emptyPeers,
|
|
227
|
+
subscribe: (cb: () => void) => {
|
|
228
|
+
const onText = () => cb();
|
|
229
|
+
ytext.observe(onText);
|
|
230
|
+
awarenessListeners.add(cb);
|
|
231
|
+
return () => {
|
|
232
|
+
ytext.unobserve(onText);
|
|
233
|
+
awarenessListeners.delete(cb);
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
textCache.set(path, binding);
|
|
238
|
+
return binding;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// ---- register bindings (memoized per path) ----
|
|
242
|
+
const reg = ydoc.getMap("reg");
|
|
243
|
+
const regCache = new Map<string, CollabRegister>();
|
|
244
|
+
const registerBinding = (path: string): CollabRegister => {
|
|
245
|
+
const existing = regCache.get(path);
|
|
246
|
+
if (existing) return existing;
|
|
247
|
+
const binding: CollabRegister = {
|
|
248
|
+
get: () => reg.get(path),
|
|
249
|
+
set: (next: unknown) => {
|
|
250
|
+
if (reg.get(path) === next) return;
|
|
251
|
+
reg.set(path, next);
|
|
252
|
+
},
|
|
253
|
+
subscribe: (cb: () => void) => {
|
|
254
|
+
// Y.Map observe fires for any key; the component re-reads its own key.
|
|
255
|
+
const onChange = () => cb();
|
|
256
|
+
reg.observe(onChange);
|
|
257
|
+
return () => reg.unobserve(onChange);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
regCache.set(path, binding);
|
|
261
|
+
return binding;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
text: textBinding,
|
|
266
|
+
register: registerBinding,
|
|
267
|
+
setFocus: (path: string | null) => awareness.setLocalStateField("focus", path),
|
|
268
|
+
peersAt: (path: string) => peerBuckets.get(path) ?? emptyPeers,
|
|
269
|
+
peersUnder,
|
|
270
|
+
participants: () => {
|
|
271
|
+
const byId = new Map<string, CollabParticipant>();
|
|
272
|
+
awareness.getStates().forEach((state, clientId) => {
|
|
273
|
+
if (clientId === awareness.clientID) return; // exclude self
|
|
274
|
+
const user = state?.user as CollabPeer | undefined;
|
|
275
|
+
if (!user) return;
|
|
276
|
+
const focus = (state?.focus as string | null | undefined) ?? null;
|
|
277
|
+
const existing = byId.get(user.id);
|
|
278
|
+
// Dedup by user id (multiple tabs); prefer an entry that has a focus.
|
|
279
|
+
if (!existing || (!existing.focus && focus)) {
|
|
280
|
+
byId.set(user.id, { id: user.id, name: user.name, color: user.color, focus });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
return [...byId.values()];
|
|
284
|
+
},
|
|
285
|
+
awarenessVersion: () => awarenessVer,
|
|
286
|
+
subscribeAwareness: (cb: () => void) => {
|
|
287
|
+
awarenessListeners.add(cb);
|
|
288
|
+
return () => awarenessListeners.delete(cb);
|
|
289
|
+
},
|
|
290
|
+
status: () => status,
|
|
291
|
+
subscribeStatus: (cb: () => void) => {
|
|
292
|
+
statusListeners.add(cb);
|
|
293
|
+
return () => statusListeners.delete(cb);
|
|
294
|
+
},
|
|
295
|
+
synced: () => provider.synced,
|
|
296
|
+
subscribeSynced: (cb: () => void) => {
|
|
297
|
+
syncListeners.add(cb);
|
|
298
|
+
return () => syncListeners.delete(cb);
|
|
299
|
+
},
|
|
300
|
+
ydoc,
|
|
301
|
+
awareness,
|
|
302
|
+
destroy: () => {
|
|
303
|
+
awareness.setLocalState(null);
|
|
304
|
+
provider.destroy();
|
|
305
|
+
ydoc.destroy();
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|