@moloodev/editor-sdk 0.0.0-stage → 0.1.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.
Files changed (110) hide show
  1. package/package.json +62 -3
  2. package/src/channel.ts +287 -0
  3. package/src/chrome-manifest.ts +111 -0
  4. package/src/commands/clipboard/router.ts +99 -0
  5. package/src/commands/clipboard/types.ts +20 -0
  6. package/src/commands/history/stack.ts +53 -0
  7. package/src/commands/icons/catalog.ts +172 -0
  8. package/src/commands/index.ts +100 -0
  9. package/src/commands/keys/display.ts +16 -0
  10. package/src/commands/keys/normalize.ts +55 -0
  11. package/src/commands/keys/reserved.ts +62 -0
  12. package/src/commands/keys/validate.ts +77 -0
  13. package/src/commands/manifest/configuration.ts +59 -0
  14. package/src/commands/manifest/contribution-manifest-schema.ts +56 -0
  15. package/src/commands/manifest/editor-document.ts +501 -0
  16. package/src/commands/manifest/ids.ts +2 -0
  17. package/src/commands/manifest/merge.ts +215 -0
  18. package/src/commands/manifest/types.ts +113 -0
  19. package/src/commands/manifest/validate-manifest.ts +45 -0
  20. package/src/commands/react/context.tsx +249 -0
  21. package/src/commands/react/hotkey-defaults.ts +11 -0
  22. package/src/commands/react/scope-context.tsx +31 -0
  23. package/src/commands/react/scope-provider.tsx +123 -0
  24. package/src/commands/react/store.ts +182 -0
  25. package/src/commands/registry/create.ts +55 -0
  26. package/src/commands/registry/types.ts +12 -0
  27. package/src/commands/when/evaluate.ts +80 -0
  28. package/src/commands/when/index.ts +19 -0
  29. package/src/commands/when/parse.ts +276 -0
  30. package/src/commands/when/types.ts +24 -0
  31. package/src/commands/when/validate.ts +20 -0
  32. package/src/crdt/awareness.ts +29 -0
  33. package/src/crdt/bytes.ts +26 -0
  34. package/src/crdt/connect.ts +285 -0
  35. package/src/crdt/diff.ts +86 -0
  36. package/src/crdt/doc.ts +67 -0
  37. package/src/crdt/front-matter.ts +45 -0
  38. package/src/crdt/index.ts +76 -0
  39. package/src/crdt/persist.ts +40 -0
  40. package/src/crdt/protocol.ts +119 -0
  41. package/src/crdt/shared.ts +104 -0
  42. package/src/crdt/text-body.ts +125 -0
  43. package/src/crdt/undo.ts +59 -0
  44. package/src/crdt/vite-plugin.ts +42 -0
  45. package/src/crdt/yjs.ts +16 -0
  46. package/src/editor-common.ts +160 -0
  47. package/src/editor-id.ts +56 -0
  48. package/src/frame-attributes.ts +9 -0
  49. package/src/index.ts +229 -0
  50. package/src/json-schema.ts +13 -0
  51. package/src/layout/bind-toolbar.ts +60 -0
  52. package/src/layout/catalog.ts +82 -0
  53. package/src/layout/chrome/command-affordances.ts +47 -0
  54. package/src/layout/chrome/command-layout-state.ts +42 -0
  55. package/src/layout/chrome/editor-menu.tsx +358 -0
  56. package/src/layout/chrome/editor-shortcut-keys.tsx +27 -0
  57. package/src/layout/chrome/menu-styles.ts +3 -0
  58. package/src/layout/chrome/mid-menu-nodes.ts +27 -0
  59. package/src/layout/chrome/use-editor-command-affordances.ts +33 -0
  60. package/src/layout/chrome/visible-menu-leaves.ts +15 -0
  61. package/src/layout/content-error-notice.tsx +31 -0
  62. package/src/layout/define-editor-layout.ts +60 -0
  63. package/src/layout/define-editor-layout.types.ts +57 -0
  64. package/src/layout/doc-undo-bridge.tsx +22 -0
  65. package/src/layout/dock-zones.tsx +61 -0
  66. package/src/layout/drop.ts +39 -0
  67. package/src/layout/editor-layout.tsx +287 -0
  68. package/src/layout/editor-loading.tsx +23 -0
  69. package/src/layout/frame-layout-binding.tsx +74 -0
  70. package/src/layout/frame.ts +23 -0
  71. package/src/layout/hide.ts +103 -0
  72. package/src/layout/index.ts +14 -0
  73. package/src/layout/layout-binding.tsx +42 -0
  74. package/src/layout/layout-storage-key.ts +6 -0
  75. package/src/layout/layout-values.ts +48 -0
  76. package/src/layout/mutate.ts +486 -0
  77. package/src/layout/nesting-context.tsx +20 -0
  78. package/src/layout/panel-header-action.tsx +29 -0
  79. package/src/layout/panel-header.tsx +168 -0
  80. package/src/layout/panel-hide-menu.tsx +6 -0
  81. package/src/layout/panel-menu.tsx +85 -0
  82. package/src/layout/panel-section.tsx +114 -0
  83. package/src/layout/panel-size.ts +94 -0
  84. package/src/layout/panel-title.tsx +5 -0
  85. package/src/layout/registry.tsx +333 -0
  86. package/src/layout/restore-panel.ts +26 -0
  87. package/src/layout/slots-context.tsx +189 -0
  88. package/src/layout/tab-button.tsx +60 -0
  89. package/src/layout/toolbar-scroll.ts +22 -0
  90. package/src/layout/toolbar.tsx +168 -0
  91. package/src/layout/types.ts +41 -0
  92. package/src/layout/use-collapsed-sections.ts +50 -0
  93. package/src/layout/validate-layout-spec.ts +143 -0
  94. package/src/message-samples.ts +377 -0
  95. package/src/origin-headers.ts +57 -0
  96. package/src/print/index.ts +10 -0
  97. package/src/print/inspect.ts +16 -0
  98. package/src/print/schema.ts +122 -0
  99. package/src/protocol-entry.ts +19 -0
  100. package/src/protocol.ts +471 -0
  101. package/src/render-result.ts +25 -0
  102. package/src/view/editor-chrome.ts +28 -0
  103. package/src/view/hooks.ts +260 -0
  104. package/src/view/mount-editor-view.ts +36 -0
  105. package/src/view/port-binding.ts +127 -0
  106. package/src/view/port-commands.ts +36 -0
  107. package/src/view/port.ts +35 -0
  108. package/src/view/theme-class.ts +11 -0
  109. package/src/view.ts +313 -0
  110. package/README.md +0 -3
@@ -0,0 +1,86 @@
1
+ /** One contiguous replacement, in UTF-16 code units like `String.length`. */
2
+ type TextEdit = {
3
+ start: number;
4
+ deleteCount: number;
5
+ insert: string;
6
+ };
7
+
8
+ const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
9
+ const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
10
+
11
+ /** Minimal single-span edit turning `before` into `after`; null when equal. */
12
+ export function diffText(before: string, after: string): TextEdit | null {
13
+ if (before === after) return null;
14
+ const shortest = Math.min(before.length, after.length);
15
+ let start = 0;
16
+ while (
17
+ start < shortest &&
18
+ before.charCodeAt(start) === after.charCodeAt(start)
19
+ )
20
+ start++;
21
+ let endBefore = before.length;
22
+ let endAfter = after.length;
23
+ while (
24
+ endBefore > start &&
25
+ endAfter > start &&
26
+ before.charCodeAt(endBefore - 1) === after.charCodeAt(endAfter - 1)
27
+ ) {
28
+ endBefore--;
29
+ endAfter--;
30
+ }
31
+ // Never split a surrogate pair: a concurrent merge would otherwise interleave halves.
32
+ if (start > 0 && isHighSurrogate(before.charCodeAt(start - 1))) start--;
33
+ if (endBefore < before.length && isLowSurrogate(before.charCodeAt(endBefore))) {
34
+ endBefore++;
35
+ endAfter++;
36
+ }
37
+ return {
38
+ start,
39
+ deleteCount: endBefore - start,
40
+ insert: after.slice(start, endAfter),
41
+ };
42
+ }
43
+
44
+ export function applyTextEdit(text: string, edit: TextEdit): string {
45
+ return (
46
+ text.slice(0, edit.start) +
47
+ edit.insert +
48
+ text.slice(edit.start + edit.deleteCount)
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Re-express an edit made against `base` in the coordinates of `current`, the
54
+ * text a concurrent change produced from `base`. Disjoint spans commute exactly.
55
+ * When the spans overlap, the concurrent replacement is kept intact and the local
56
+ * insertion lands right after it; the editor shows the merged result immediately.
57
+ */
58
+ export function rebaseEdit(
59
+ edit: TextEdit,
60
+ base: string,
61
+ current: string,
62
+ ): TextEdit {
63
+ const remote = diffText(base, current);
64
+ if (!remote) return edit;
65
+ const localEnd = edit.start + edit.deleteCount;
66
+ const remoteEnd = remote.start + remote.deleteCount;
67
+ if (localEnd <= remote.start) return edit;
68
+ if (edit.start >= remoteEnd) {
69
+ return {
70
+ ...edit,
71
+ start: edit.start + remote.insert.length - remote.deleteCount,
72
+ };
73
+ }
74
+ if (edit.start < remote.start) {
75
+ return {
76
+ start: edit.start,
77
+ deleteCount: remote.start - edit.start,
78
+ insert: edit.insert,
79
+ };
80
+ }
81
+ return {
82
+ start: remote.start + remote.insert.length,
83
+ deleteCount: Math.max(0, localEnd - remoteEnd),
84
+ insert: edit.insert,
85
+ };
86
+ }
@@ -0,0 +1,67 @@
1
+ import { Y } from "./yjs";
2
+
3
+ export type Doc = Y.Doc;
4
+
5
+ export const REMOTE_ORIGIN = "remote";
6
+ export const LOCAL_ORIGIN = "local";
7
+ /**
8
+ * Format schema migrations of the working copy. Synced and persisted like local
9
+ * edits, but never captured by undo: undoing one would unstamp the version.
10
+ */
11
+ export const SCHEMA_ORIGIN = "schema";
12
+
13
+ export function createDoc(): Doc {
14
+ return new Y.Doc();
15
+ }
16
+
17
+ export function destroyDoc(doc: Doc): void {
18
+ doc.destroy();
19
+ }
20
+
21
+ export function transact(
22
+ doc: Doc,
23
+ fn: () => void,
24
+ origin: unknown = LOCAL_ORIGIN,
25
+ ): void {
26
+ doc.transact(fn, origin);
27
+ }
28
+
29
+ export function applyUpdate(
30
+ doc: Doc,
31
+ update: Uint8Array,
32
+ origin: unknown = REMOTE_ORIGIN,
33
+ ): void {
34
+ Y.applyUpdate(doc, update, origin);
35
+ }
36
+
37
+ export function encodeState(doc: Doc, vector?: Uint8Array): Uint8Array {
38
+ return Y.encodeStateAsUpdate(doc, vector);
39
+ }
40
+
41
+ export function encodeVector(doc: Doc): Uint8Array {
42
+ return Y.encodeStateVector(doc);
43
+ }
44
+
45
+ export function mergeUpdates(updates: readonly Uint8Array[]): Uint8Array {
46
+ return Y.mergeUpdates([...updates]);
47
+ }
48
+
49
+ export function getMap(doc: Doc, name: string): Y.Map<unknown> {
50
+ return doc.getMap(name);
51
+ }
52
+
53
+ export function getArray(doc: Doc, name: string): Y.Array<unknown> {
54
+ return doc.getArray(name);
55
+ }
56
+
57
+ export function toJson(doc: Doc): Record<string, unknown> {
58
+ // `Doc.toJSON()` omits a map until it is accessed on this instance.
59
+ return { root: getMap(doc, "root").toJSON() };
60
+ }
61
+
62
+ export function observe(doc: Doc, listener: () => void): () => void {
63
+ doc.on("update", listener);
64
+ return () => {
65
+ doc.off("update", listener);
66
+ };
67
+ }
@@ -0,0 +1,45 @@
1
+ export type FrontMatterSpan = {
2
+ readonly from: number;
3
+ readonly to: number;
4
+ readonly yamlFrom: number;
5
+ readonly yamlTo: number;
6
+ };
7
+
8
+ function isFence(line: string): boolean {
9
+ if (line.length < 3 || line[0] !== "-" || line[1] !== "-" || line[2] !== "-") return false;
10
+ for (let index = 3; index < line.length; index += 1) {
11
+ const char = line[index];
12
+ if (char !== " " && char !== "\t") return false;
13
+ }
14
+ return true;
15
+ }
16
+
17
+ function readLine(text: string, start: number): { readonly end: number; readonly next: number } {
18
+ let index = start;
19
+ while (index < text.length) {
20
+ const char = text[index];
21
+ if (char === "\n") return { end: index, next: index + 1 };
22
+ if (char === "\r") {
23
+ const paired = index + 1 < text.length && text[index + 1] === "\n";
24
+ return { end: index, next: index + (paired ? 2 : 1) };
25
+ }
26
+ index += 1;
27
+ }
28
+ return { end: text.length, next: text.length };
29
+ }
30
+
31
+ export function frontMatterSpan(text: string): FrontMatterSpan | null {
32
+ const opener = readLine(text, 0);
33
+ if (opener.next === opener.end || !isFence(text.slice(0, opener.end))) return null;
34
+ const yamlFrom = opener.next;
35
+ let start = yamlFrom;
36
+ while (start < text.length) {
37
+ const line = readLine(text, start);
38
+ if (isFence(text.slice(start, line.end))) {
39
+ return { from: 0, to: line.next, yamlFrom, yamlTo: start };
40
+ }
41
+ if (line.next === line.end) return null;
42
+ start = line.next;
43
+ }
44
+ return null;
45
+ }
@@ -0,0 +1,76 @@
1
+ export {
2
+ createDoc,
3
+ destroyDoc,
4
+ transact,
5
+ applyUpdate,
6
+ encodeState,
7
+ encodeVector,
8
+ mergeUpdates,
9
+ getMap,
10
+ getArray,
11
+ toJson,
12
+ observe,
13
+ REMOTE_ORIGIN,
14
+ LOCAL_ORIGIN,
15
+ SCHEMA_ORIGIN,
16
+ type Doc,
17
+ } from "./doc";
18
+ export { createUndo, stopCapturing, type DocUndo } from "./undo";
19
+ export {
20
+ createAwareness,
21
+ setLocalPresence,
22
+ localPresence,
23
+ states,
24
+ destroyPresence,
25
+ type Presence,
26
+ } from "./awareness";
27
+ export { openLocal, type LocalPersist } from "./persist";
28
+ export {
29
+ connectSync,
30
+ connectPresence,
31
+ shouldReconnect,
32
+ nextReconnectDelay,
33
+ type SocketFactory,
34
+ type SocketHandle,
35
+ type SyncControl,
36
+ type SyncStatus,
37
+ } from "./connect";
38
+ export { toBase64, fromBase64 } from "./bytes";
39
+ export {
40
+ writeJson,
41
+ toShared,
42
+ createMap,
43
+ createArray,
44
+ createText,
45
+ isMap,
46
+ isArray,
47
+ isText,
48
+ rootMap,
49
+ type SharedMap,
50
+ type SharedArray,
51
+ type SharedText,
52
+ } from "./shared";
53
+ export {
54
+ encodeSyncStep1,
55
+ encodeSyncUpdate,
56
+ syncOpenMessages,
57
+ encodeAwarenessUpdate,
58
+ readSync,
59
+ isSyncStep1,
60
+ applyAwarenessMessage,
61
+ validateAwarenessMessage,
62
+ extractSyncUpdate,
63
+ peekType,
64
+ MSG_SYNC,
65
+ MSG_AWARENESS,
66
+ } from "./protocol";
67
+ export { applyTextEdit, diffText, rebaseEdit } from "./diff";
68
+ export { frontMatterSpan, type FrontMatterSpan } from "./front-matter";
69
+ export {
70
+ TEXT_BODY_SCHEMA_VERSION,
71
+ blankTextBodyPayload,
72
+ readBody,
73
+ readsTextBodySchemaVersion,
74
+ writeBody,
75
+ writeTextBodyContent,
76
+ } from "./text-body";
@@ -0,0 +1,40 @@
1
+ import type { Doc } from "./doc";
2
+
3
+ export type LocalPersist = {
4
+ whenSynced: Promise<void>;
5
+ destroy: () => void;
6
+ };
7
+
8
+ type Persistence = {
9
+ whenSynced: Promise<unknown>;
10
+ destroy: () => Promise<void> | void;
11
+ };
12
+
13
+ /** IndexedDB is client-only. Load it on demand so server sync does not import `yjs` twice. */
14
+ export function openLocal(doc: Doc, key: string): LocalPersist {
15
+ let persistence: Persistence | null = null;
16
+ let cancelled = false;
17
+ let resolveSynced: () => void = () => {};
18
+ const whenSynced = new Promise<void>((resolve) => {
19
+ resolveSynced = resolve;
20
+ });
21
+
22
+ void import("y-indexeddb")
23
+ .then(({ IndexeddbPersistence }) => {
24
+ if (cancelled) {
25
+ resolveSynced();
26
+ return;
27
+ }
28
+ persistence = new IndexeddbPersistence(key, doc);
29
+ void persistence.whenSynced.then(() => resolveSynced());
30
+ })
31
+ .catch(() => resolveSynced());
32
+
33
+ return {
34
+ whenSynced,
35
+ destroy() {
36
+ cancelled = true;
37
+ void persistence?.destroy();
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,119 @@
1
+ import { awarenessProtocol, decoding, encoding, syncProtocol } from "./yjs";
2
+ import type { Doc } from "./doc";
3
+ import { encodeState, REMOTE_ORIGIN } from "./doc";
4
+ import type { Presence } from "./awareness";
5
+
6
+ export const MSG_SYNC = 0;
7
+ export const MSG_AWARENESS = 1;
8
+
9
+ export function readSync(doc: Doc, message: Uint8Array): Uint8Array | null {
10
+ const decoder = decoding.createDecoder(message);
11
+ const encoder = encoding.createEncoder();
12
+ const messageType = decoding.readVarUint(decoder);
13
+ if (messageType !== MSG_SYNC) return null;
14
+ encoding.writeVarUint(encoder, MSG_SYNC);
15
+ const written = syncProtocol.readSyncMessage(
16
+ decoder,
17
+ encoder,
18
+ doc,
19
+ REMOTE_ORIGIN,
20
+ (error) => {
21
+ throw error;
22
+ },
23
+ );
24
+ if (decoding.hasContent(decoder)) throw new Error("invalid_sync_frame");
25
+ if (written === 0 && encoding.length(encoder) <= 1) return null;
26
+ return encoding.toUint8Array(encoder);
27
+ }
28
+
29
+ export function encodeSyncStep1(doc: Doc): Uint8Array {
30
+ const encoder = encoding.createEncoder();
31
+ encoding.writeVarUint(encoder, MSG_SYNC);
32
+ syncProtocol.writeSyncStep1(encoder, doc);
33
+ return encoding.toUint8Array(encoder);
34
+ }
35
+
36
+ export function encodeSyncUpdate(update: Uint8Array): Uint8Array {
37
+ const encoder = encoding.createEncoder();
38
+ encoding.writeVarUint(encoder, MSG_SYNC);
39
+ syncProtocol.writeUpdate(encoder, update);
40
+ return encoding.toUint8Array(encoder);
41
+ }
42
+
43
+ /** Handshake plus a persistable full-state update. Step1 alone cannot publish client-only edits. */
44
+ export function syncOpenMessages(doc: Doc): Uint8Array[] {
45
+ return [encodeSyncStep1(doc), encodeSyncUpdate(encodeState(doc))];
46
+ }
47
+
48
+ export function encodeAwarenessUpdate(
49
+ presence: Presence,
50
+ clients?: number[],
51
+ ): Uint8Array {
52
+ const ids = clients ?? [...presence.getStates().keys()];
53
+ const encoder = encoding.createEncoder();
54
+ encoding.writeVarUint(encoder, MSG_AWARENESS);
55
+ encoding.writeVarUint8Array(
56
+ encoder,
57
+ awarenessProtocol.encodeAwarenessUpdate(presence, ids),
58
+ );
59
+ return encoding.toUint8Array(encoder);
60
+ }
61
+
62
+ export function applyAwarenessMessage(
63
+ presence: Presence,
64
+ message: Uint8Array,
65
+ ): void {
66
+ const decoder = decoding.createDecoder(message);
67
+ const messageType = decoding.readVarUint(decoder);
68
+ if (messageType !== MSG_AWARENESS) return;
69
+ awarenessProtocol.applyAwarenessUpdate(
70
+ presence,
71
+ decoding.readVarUint8Array(decoder),
72
+ REMOTE_ORIGIN,
73
+ );
74
+ }
75
+
76
+ export function peekType(message: Uint8Array): number {
77
+ const decoder = decoding.createDecoder(message);
78
+ return decoding.readVarUint(decoder);
79
+ }
80
+
81
+ /** Apply a raw Yjs update extracted from a sync-update frame, or pass through. */
82
+ export function extractSyncUpdate(message: Uint8Array): Uint8Array | null {
83
+ const decoder = decoding.createDecoder(message);
84
+ const type = decoding.readVarUint(decoder);
85
+ if (type !== MSG_SYNC) return null;
86
+ const syncType = decoding.readVarUint(decoder);
87
+ if (syncType !== syncProtocol.messageYjsUpdate) return null;
88
+ return decoding.readVarUint8Array(decoder);
89
+ }
90
+
91
+ /** Validate the full awareness envelope before a server relays it to other clients. */
92
+ export function validateAwarenessMessage(message: Uint8Array): void {
93
+ const outer = decoding.createDecoder(message);
94
+ if (decoding.readVarUint(outer) !== MSG_AWARENESS)
95
+ throw new Error("invalid_awareness");
96
+ const body = decoding.createDecoder(decoding.readVarUint8Array(outer));
97
+ const count = decoding.readVarUint(body);
98
+ if (count > message.byteLength) throw new Error("invalid_awareness");
99
+ for (let index = 0; index < count; index++) {
100
+ decoding.readVarUint(body); // client id
101
+ decoding.readVarUint(body); // clock
102
+ const state: unknown = JSON.parse(decoding.readVarString(body));
103
+ if (state !== null && (typeof state !== "object" || Array.isArray(state)))
104
+ throw new Error("invalid_awareness");
105
+ }
106
+ if (decoding.hasContent(body) || decoding.hasContent(outer))
107
+ throw new Error("invalid_awareness");
108
+ }
109
+
110
+ /** Step1 only asks for the missing state; answering it never mutates a document. */
111
+ export function isSyncStep1(message: Uint8Array): boolean {
112
+ const decoder = decoding.createDecoder(message);
113
+ if (decoding.readVarUint(decoder) !== MSG_SYNC) return false;
114
+ if (decoding.readVarUint(decoder) !== syncProtocol.messageYjsSyncStep1)
115
+ return false;
116
+ decoding.readVarUint8Array(decoder);
117
+ if (decoding.hasContent(decoder)) throw new Error("invalid_sync_frame");
118
+ return true;
119
+ }
@@ -0,0 +1,104 @@
1
+ import { getMap, transact, type Doc } from "./doc";
2
+ import { Y } from "./yjs";
3
+
4
+ export type SharedMap = Y.Map<unknown>;
5
+ export type SharedArray = Y.Array<unknown>;
6
+ /** Character-level mergeable text. Projects to a plain string in `toJson`. */
7
+ export type SharedText = Y.Text;
8
+
9
+ export function createMap(): SharedMap {
10
+ return new Y.Map();
11
+ }
12
+
13
+ export function createArray(): SharedArray {
14
+ return new Y.Array();
15
+ }
16
+
17
+ export function createText(initial = ""): SharedText {
18
+ return new Y.Text(initial);
19
+ }
20
+
21
+ export function isMap(value: unknown): value is SharedMap {
22
+ return value instanceof Y.Map;
23
+ }
24
+
25
+ export function isArray(value: unknown): value is SharedArray {
26
+ return value instanceof Y.Array;
27
+ }
28
+
29
+ export function isText(value: unknown): value is SharedText {
30
+ return value instanceof Y.Text;
31
+ }
32
+
33
+ function writeIntoMap(map: SharedMap, value: Record<string, unknown>): void {
34
+ for (const [key, nested] of Object.entries(value)) {
35
+ writeInto(map, key, nested);
36
+ }
37
+ }
38
+
39
+ function writeInto(
40
+ parent: SharedMap,
41
+ key: string,
42
+ value: unknown,
43
+ ): void {
44
+ if (Array.isArray(value)) {
45
+ const arr = createArray();
46
+ parent.set(key, arr);
47
+ for (const item of value) {
48
+ if (item && typeof item === "object" && !Array.isArray(item)) {
49
+ const nested = createMap();
50
+ arr.push([nested]);
51
+ writeIntoMap(nested, item as Record<string, unknown>);
52
+ continue;
53
+ }
54
+ if (Array.isArray(item)) {
55
+ const nested = createArray();
56
+ arr.push([nested]);
57
+ for (const child of item) nested.push([child]);
58
+ continue;
59
+ }
60
+ arr.push([item]);
61
+ }
62
+ return;
63
+ }
64
+ if (value && typeof value === "object") {
65
+ const nested = createMap();
66
+ parent.set(key, nested);
67
+ writeIntoMap(nested, value as Record<string, unknown>);
68
+ return;
69
+ }
70
+ parent.set(key, value);
71
+ }
72
+
73
+ export function toShared(value: unknown): unknown {
74
+ if (Array.isArray(value)) {
75
+ const arr = createArray();
76
+ for (const item of value) {
77
+ arr.push([toShared(item)]);
78
+ }
79
+ return arr;
80
+ }
81
+ if (value && typeof value === "object") {
82
+ const map = createMap();
83
+ for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
84
+ map.set(key, toShared(nested));
85
+ }
86
+ return map;
87
+ }
88
+ return value;
89
+ }
90
+
91
+ /** Write a JSON object as nested shared types under `root`. */
92
+ export function writeJson(doc: Doc, payload: Record<string, unknown>): void {
93
+ transact(doc, () => {
94
+ const root = getMap(doc, "root");
95
+ for (const key of [...root.keys()]) {
96
+ root.delete(key);
97
+ }
98
+ writeIntoMap(root, payload);
99
+ });
100
+ }
101
+
102
+ export function rootMap(doc: Doc): SharedMap {
103
+ return getMap(doc, "root");
104
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ SCHEMA_ORIGIN,
3
+ transact,
4
+ type Doc,
5
+ } from "./doc";
6
+ import { diffText, rebaseEdit } from "./diff";
7
+ import {
8
+ createText,
9
+ isText,
10
+ rootMap,
11
+ toShared,
12
+ type SharedMap,
13
+ } from "./shared";
14
+
15
+ /** v2 stores `body` as shared text so concurrent edits merge per character. */
16
+ export const TEXT_BODY_SCHEMA_VERSION = 2;
17
+
18
+ const READABLE_TEXT_BODY_VERSIONS: ReadonlySet<unknown> = new Set([
19
+ 1,
20
+ TEXT_BODY_SCHEMA_VERSION,
21
+ ]);
22
+
23
+ export function readsTextBodySchemaVersion(version: unknown): boolean {
24
+ return READABLE_TEXT_BODY_VERSIONS.has(version);
25
+ }
26
+
27
+ export function readBody(
28
+ data: Record<string, unknown> | null | undefined,
29
+ ): string {
30
+ if (!data) return "";
31
+ return typeof data.body === "string" ? data.body : "";
32
+ }
33
+
34
+ function assertReadable(root: SharedMap): void {
35
+ if (root.has("version") && !readsTextBodySchemaVersion(root.get("version")))
36
+ throw new Error("unsupported_schema");
37
+ }
38
+
39
+ /**
40
+ * Apply the editor's change to the shared body and return the merged text.
41
+ *
42
+ * `base` is the text the editor derived `body` from. Only the difference between
43
+ * them is written, so concurrent edits elsewhere in the body survive; when the
44
+ * shared body already moved past `base`, the edit is rebased onto it. Legacy
45
+ * string bodies migrate to the current schema on their first real edit. That
46
+ * migration is not undoable: undo restores the text, never the old version.
47
+ */
48
+ export function writeBody(doc: Doc, body: string, base?: string): string {
49
+ const root = rootMap(doc);
50
+ assertReadable(root);
51
+ const current = root.get("body");
52
+ if (isText(current)) {
53
+ const currentText = current.toString();
54
+ const edit = diffText(base ?? currentText, body);
55
+ if (!edit) return currentText;
56
+ const placed =
57
+ base !== undefined && base !== currentText
58
+ ? rebaseEdit(edit, base, currentText)
59
+ : edit;
60
+ transact(doc, () => {
61
+ if (placed.deleteCount > 0)
62
+ current.delete(placed.start, placed.deleteCount);
63
+ if (placed.insert) current.insert(placed.start, placed.insert);
64
+ });
65
+ return current.toString();
66
+ }
67
+ if (current !== undefined && typeof current !== "string")
68
+ throw new Error("invalid_body");
69
+ const legacy = current ?? "";
70
+ const edit = diffText(base ?? legacy, body);
71
+ if (!edit) return legacy;
72
+ const placed =
73
+ base !== undefined && base !== legacy
74
+ ? rebaseEdit(edit, base, legacy)
75
+ : edit;
76
+ transact(
77
+ doc,
78
+ () => {
79
+ root.set("body", createText(legacy));
80
+ root.set("version", TEXT_BODY_SCHEMA_VERSION);
81
+ },
82
+ SCHEMA_ORIGIN,
83
+ );
84
+ const text = root.get("body");
85
+ if (!isText(text)) throw new Error("invalid_body");
86
+ transact(doc, () => {
87
+ if (placed.deleteCount > 0) text.delete(placed.start, placed.deleteCount);
88
+ if (placed.insert) text.insert(placed.start, placed.insert);
89
+ });
90
+ return text.toString();
91
+ }
92
+
93
+ /**
94
+ * Server-side JSON → shared types in the shape text documents store. Unversioned
95
+ * payloads become the current version; explicit versions are written as given
96
+ * so write gates can still judge them.
97
+ */
98
+ export function writeTextBodyContent(
99
+ doc: Doc,
100
+ payload: Record<string, unknown>,
101
+ ): void {
102
+ transact(doc, () => {
103
+ const root = rootMap(doc);
104
+ for (const key of [...root.keys()]) root.delete(key);
105
+ const version = Object.hasOwn(payload, "version")
106
+ ? payload.version
107
+ : TEXT_BODY_SCHEMA_VERSION;
108
+ for (const [key, value] of Object.entries(payload)) {
109
+ if (key === "version") continue;
110
+ root.set(
111
+ key,
112
+ key === "body" &&
113
+ typeof value === "string" &&
114
+ version === TEXT_BODY_SCHEMA_VERSION
115
+ ? createText(value)
116
+ : toShared(value),
117
+ );
118
+ }
119
+ root.set("version", version);
120
+ });
121
+ }
122
+
123
+ export function blankTextBodyPayload(): Record<string, unknown> {
124
+ return { body: "" };
125
+ }