@dxos/ui-editor 0.8.4-main.9be5663bfe → 0.8.4-main.bc2380dfbc

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 (144) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/browser/index.mjs +995 -879
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/types/index.mjs +26 -6
  7. package/dist/lib/browser/types/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/index.mjs +995 -878
  9. package/dist/lib/node-esm/index.mjs.map +4 -4
  10. package/dist/lib/node-esm/meta.json +1 -1
  11. package/dist/lib/node-esm/types/index.mjs +27 -6
  12. package/dist/lib/node-esm/types/index.mjs.map +4 -4
  13. package/dist/types/src/defaults.d.ts +1 -0
  14. package/dist/types/src/defaults.d.ts.map +1 -1
  15. package/dist/types/src/extensions/annotations.d.ts.map +1 -1
  16. package/dist/types/src/extensions/autocomplete/autocomplete.d.ts.map +1 -1
  17. package/dist/types/src/extensions/autocomplete/match.d.ts.map +1 -1
  18. package/dist/types/src/extensions/autocomplete/placeholder.d.ts +5 -2
  19. package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
  20. package/dist/types/src/extensions/autocomplete/typeahead.d.ts.map +1 -1
  21. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  22. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  23. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  25. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  26. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -1
  27. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  28. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
  29. package/dist/types/src/extensions/blast.d.ts.map +1 -1
  30. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  31. package/dist/types/src/extensions/debug.d.ts.map +1 -1
  32. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  33. package/dist/types/src/extensions/factories.d.ts +2 -1
  34. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  35. package/dist/types/src/extensions/factories.test.d.ts +2 -0
  36. package/dist/types/src/extensions/factories.test.d.ts.map +1 -0
  37. package/dist/types/src/extensions/focus.d.ts +1 -1
  38. package/dist/types/src/extensions/index.d.ts +2 -4
  39. package/dist/types/src/extensions/index.d.ts.map +1 -1
  40. package/dist/types/src/extensions/json.d.ts.map +1 -1
  41. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  42. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  43. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -1
  44. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
  45. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  46. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  47. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  49. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  51. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  52. package/dist/types/src/extensions/outliner/menu.d.ts.map +1 -1
  53. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -1
  54. package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -1
  55. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
  56. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  57. package/dist/types/src/extensions/replacer.d.ts.map +1 -1
  58. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts +18 -0
  59. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
  60. package/dist/types/src/extensions/scrolling/crawler.d.ts +75 -0
  61. package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
  62. package/dist/types/src/extensions/scrolling/index.d.ts +5 -0
  63. package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
  64. package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
  65. package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
  66. package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
  67. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  68. package/dist/types/src/extensions/snippets.d.ts +10 -0
  69. package/dist/types/src/extensions/snippets.d.ts.map +1 -0
  70. package/dist/types/src/extensions/submit.d.ts.map +1 -1
  71. package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -1
  72. package/dist/types/src/extensions/tags/fader.d.ts.map +1 -1
  73. package/dist/types/src/extensions/tags/index.d.ts +3 -1
  74. package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
  75. package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
  76. package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
  77. package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
  78. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
  79. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
  80. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
  81. package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
  82. package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
  83. package/dist/types/src/extensions/tags/xml-tags.d.ts +1 -8
  84. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  85. package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -1
  86. package/dist/types/src/index.d.ts +0 -1
  87. package/dist/types/src/index.d.ts.map +1 -1
  88. package/dist/types/src/styles/theme.d.ts.map +1 -1
  89. package/dist/types/src/types/types.d.ts +4 -4
  90. package/dist/types/src/types/types.d.ts.map +1 -1
  91. package/dist/types/src/util/cursor.d.ts.map +1 -1
  92. package/dist/types/src/util/debug.d.ts.map +1 -1
  93. package/dist/types/src/util/decorations.d.ts.map +1 -1
  94. package/dist/types/src/util/dom.d.ts.map +1 -1
  95. package/dist/types/src/util/facet.d.ts.map +1 -1
  96. package/dist/types/src/util/util.d.ts.map +1 -1
  97. package/dist/types/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +33 -36
  99. package/src/defaults.ts +12 -3
  100. package/src/extensions/autocomplete/placeholder.ts +37 -18
  101. package/src/extensions/automerge/automerge.test.tsx +35 -9
  102. package/src/extensions/automerge/automerge.ts +2 -4
  103. package/src/extensions/factories.test.ts +88 -0
  104. package/src/extensions/factories.ts +25 -6
  105. package/src/extensions/index.ts +2 -4
  106. package/src/extensions/outliner/outliner.ts +1 -1
  107. package/src/extensions/{auto-scroll.ts → scrolling/auto-scroll.ts} +88 -23
  108. package/src/extensions/{scroller.ts → scrolling/crawler.ts} +63 -45
  109. package/src/extensions/scrolling/index.ts +8 -0
  110. package/src/extensions/{scroll-past-end.ts → scrolling/scroll-past-end.ts} +6 -6
  111. package/src/extensions/scrolling/scroller.ts +27 -0
  112. package/src/extensions/snippets.ts +67 -0
  113. package/src/extensions/tags/index.ts +3 -1
  114. package/src/extensions/tags/testing/text.md +36 -0
  115. package/src/extensions/tags/testing/text.txt +35 -0
  116. package/src/extensions/tags/{wire.test.ts → typewriter.test.ts} +2 -2
  117. package/src/extensions/tags/typewriter.ts +594 -0
  118. package/src/extensions/tags/xml-block-decoration.ts +123 -0
  119. package/src/extensions/tags/xml-formatting.ts +125 -0
  120. package/src/extensions/tags/xml-tags.ts +6 -32
  121. package/src/extensions/tags/xml-util.test.ts +90 -3
  122. package/src/extensions/tags/xml-util.ts +62 -5
  123. package/src/index.ts +0 -1
  124. package/src/styles/theme.ts +20 -10
  125. package/src/types/types.ts +10 -2
  126. package/src/typings.d.ts +8 -0
  127. package/dist/lib/browser/chunk-HL3YF6WC.mjs +0 -22
  128. package/dist/lib/browser/chunk-HL3YF6WC.mjs.map +0 -7
  129. package/dist/lib/node-esm/chunk-YJZGD3LY.mjs +0 -24
  130. package/dist/lib/node-esm/chunk-YJZGD3LY.mjs.map +0 -7
  131. package/dist/types/src/extensions/auto-scroll.d.ts +0 -8
  132. package/dist/types/src/extensions/auto-scroll.d.ts.map +0 -1
  133. package/dist/types/src/extensions/scroll-past-end.d.ts.map +0 -1
  134. package/dist/types/src/extensions/scroller.d.ts +0 -63
  135. package/dist/types/src/extensions/scroller.d.ts.map +0 -1
  136. package/dist/types/src/extensions/tags/wire.d.ts +0 -23
  137. package/dist/types/src/extensions/tags/wire.d.ts.map +0 -1
  138. package/dist/types/src/extensions/tags/wire.test.d.ts +0 -2
  139. package/dist/types/src/extensions/tags/wire.test.d.ts.map +0 -1
  140. package/dist/types/src/extensions/typewriter.d.ts +0 -10
  141. package/dist/types/src/extensions/typewriter.d.ts.map +0 -1
  142. package/src/extensions/tags/wire.ts +0 -459
  143. package/src/extensions/typewriter.ts +0 -68
  144. /package/dist/types/src/extensions/{scroll-past-end.d.ts → scrolling/scroll-past-end.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/ui-editor",
3
- "version": "0.8.4-main.9be5663bfe",
3
+ "version": "0.8.4-main.bc2380dfbc",
4
4
  "description": "Text editor components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/dxos/dxos"
10
10
  },
11
- "license": "MIT",
11
+ "license": "FSL-1.1-Apache-2.0",
12
12
  "author": "DXOS.org",
13
13
  "sideEffects": false,
14
14
  "type": "module",
@@ -27,15 +27,12 @@
27
27
  }
28
28
  },
29
29
  "types": "dist/types/src/index.d.ts",
30
- "typesVersions": {
31
- "*": {}
32
- },
33
30
  "files": [
34
31
  "dist",
35
32
  "src"
36
33
  ],
37
34
  "dependencies": {
38
- "@automerge/automerge": "3.2.3",
35
+ "@automerge/automerge": "3.2.6",
39
36
  "@codemirror/autocomplete": "^6.19.0",
40
37
  "@codemirror/commands": "^6.8.1",
41
38
  "@codemirror/lang-html": "^6.4.11",
@@ -50,7 +47,7 @@
50
47
  "@codemirror/search": "^6.5.11",
51
48
  "@codemirror/state": "^6.5.2",
52
49
  "@codemirror/theme-one-dark": "^6.1.3",
53
- "@codemirror/view": "^6.38.4",
50
+ "@codemirror/view": "^6.38.5",
54
51
  "@lezer/common": "^1.2.2",
55
52
  "@lezer/generator": "^1.7.1",
56
53
  "@lezer/highlight": "^1.2.1",
@@ -65,27 +62,27 @@
65
62
  "lodash.merge": "^4.6.2",
66
63
  "lodash.sortby": "^4.7.0",
67
64
  "style-mod": "^4.1.0",
68
- "@dxos/context": "0.8.4-main.9be5663bfe",
69
- "@dxos/app-graph": "0.8.4-main.9be5663bfe",
70
- "@dxos/debug": "0.8.4-main.9be5663bfe",
71
- "@dxos/client": "0.8.4-main.9be5663bfe",
72
- "@dxos/display-name": "0.8.4-main.9be5663bfe",
73
- "@dxos/echo": "0.8.4-main.9be5663bfe",
74
- "@dxos/echo-db": "0.8.4-main.9be5663bfe",
75
- "@dxos/invariant": "0.8.4-main.9be5663bfe",
76
- "@dxos/async": "0.8.4-main.9be5663bfe",
77
- "@dxos/lit-ui": "0.8.4-main.9be5663bfe",
78
- "@dxos/ui": "0.8.4-main.9be5663bfe",
79
- "@dxos/ui-theme": "0.8.4-main.9be5663bfe",
80
- "@dxos/protocols": "0.8.4-main.9be5663bfe",
81
- "@dxos/util": "0.8.4-main.9be5663bfe",
82
- "@dxos/log": "0.8.4-main.9be5663bfe",
83
- "@dxos/ui-types": "0.8.4-main.9be5663bfe"
65
+ "@dxos/app-graph": "0.8.4-main.bc2380dfbc",
66
+ "@dxos/async": "0.8.4-main.bc2380dfbc",
67
+ "@dxos/client": "0.8.4-main.bc2380dfbc",
68
+ "@dxos/debug": "0.8.4-main.bc2380dfbc",
69
+ "@dxos/context": "0.8.4-main.bc2380dfbc",
70
+ "@dxos/echo": "0.8.4-main.bc2380dfbc",
71
+ "@dxos/display-name": "0.8.4-main.bc2380dfbc",
72
+ "@dxos/invariant": "0.8.4-main.bc2380dfbc",
73
+ "@dxos/echo-db": "0.8.4-main.bc2380dfbc",
74
+ "@dxos/log": "0.8.4-main.bc2380dfbc",
75
+ "@dxos/lit-ui": "0.8.4-main.bc2380dfbc",
76
+ "@dxos/protocols": "0.8.4-main.bc2380dfbc",
77
+ "@dxos/ui-theme": "0.8.4-main.bc2380dfbc",
78
+ "@dxos/ui": "0.8.4-main.bc2380dfbc",
79
+ "@dxos/ui-types": "0.8.4-main.bc2380dfbc",
80
+ "@dxos/util": "0.8.4-main.bc2380dfbc"
84
81
  },
85
82
  "devDependencies": {
86
- "@automerge/automerge": "3.2.3",
87
- "@automerge/automerge-repo": "2.5.1",
88
- "@automerge/automerge-repo-network-broadcastchannel": "2.5.1",
83
+ "@automerge/automerge": "3.2.6",
84
+ "@automerge/automerge-repo": "2.6.0-subduction.17",
85
+ "@automerge/automerge-repo-network-broadcastchannel": "2.6.0-subduction.17",
89
86
  "@effect/platform": "0.94.4",
90
87
  "@types/chai": "^4.2.15",
91
88
  "@types/chai-dom": "^1.11.0",
@@ -98,21 +95,21 @@
98
95
  "happy-dom": "^20.0.0",
99
96
  "jsdom": "^27.0.0",
100
97
  "mocha": "^10.6.0",
101
- "vite": "^7.1.11",
98
+ "vite": "^8.0.13",
102
99
  "vite-plugin-top-level-await": "^1.6.0",
103
- "vite-plugin-wasm": "^3.5.0",
104
- "@dxos/echo": "0.8.4-main.9be5663bfe",
105
- "@dxos/keyboard": "0.8.4-main.9be5663bfe",
106
- "@dxos/config": "0.8.4-main.9be5663bfe",
107
- "@dxos/schema": "0.8.4-main.9be5663bfe",
108
- "@dxos/storybook-utils": "0.8.4-main.9be5663bfe",
109
- "@dxos/ui-theme": "0.8.4-main.9be5663bfe",
110
- "@dxos/random": "0.8.4-main.9be5663bfe"
100
+ "vite-plugin-wasm": "^3.6.0",
101
+ "@dxos/echo": "0.8.4-main.bc2380dfbc",
102
+ "@dxos/config": "0.8.4-main.bc2380dfbc",
103
+ "@dxos/random": "0.8.4-main.bc2380dfbc",
104
+ "@dxos/schema": "0.8.4-main.bc2380dfbc",
105
+ "@dxos/ui-theme": "0.8.4-main.bc2380dfbc",
106
+ "@dxos/keyboard": "0.8.4-main.bc2380dfbc",
107
+ "@dxos/storybook-utils": "0.8.4-main.bc2380dfbc"
111
108
  },
112
109
  "peerDependencies": {
113
110
  "@effect/platform": "0.94.4",
114
111
  "effect": "3.20.0",
115
- "@dxos/ui-theme": "0.8.4-main.9be5663bfe"
112
+ "@dxos/ui-theme": "0.8.4-main.bc2380dfbc"
116
113
  },
117
114
  "publishConfig": {
118
115
  "access": "public"
package/src/defaults.ts CHANGED
@@ -22,8 +22,11 @@ export const documentSlots: ThemeExtensionsOptions['slots'] = {
22
22
  * NOTE: Max width - 4rem = 2rem left/right margin (or 2rem gutter plus 1rem left/right margin).
23
23
  */
24
24
  className: mx(
25
- // NOTE: Container for widget sizing (must have `max-w-[100cqi]`).
26
- 'dx-size-container',
25
+ // Inline-size container for widget sizing (children use `max-w-[100cqi]`).
26
+ // NOTE: Use inline-size, not full size containment — `container-type: size` on the
27
+ // editor content breaks CodeMirror's viewport measurement, leaving blank gaps during
28
+ // scroll until a click forces a re-measure.
29
+ 'dx-inline-size-container',
27
30
  // Wider margin for web (vs. mobile).
28
31
  'pointer-fine:max-w-[min(50rem,100%-4rem)] pointer-coarse:max-w-[min(50rem,100%-2rem)]',
29
32
  'mx-auto! w-full',
@@ -33,6 +36,12 @@ export const documentSlots: ThemeExtensionsOptions['slots'] = {
33
36
 
34
37
  export const compactSlots: ThemeExtensionsOptions['slots'] = {
35
38
  content: {
36
- className: 'mx-2! w-full',
39
+ className: 'mx-2!',
40
+ },
41
+ };
42
+
43
+ export const mobileSlots: ThemeExtensionsOptions['slots'] = {
44
+ content: {
45
+ className: 'mx-2!',
37
46
  },
38
47
  };
@@ -13,42 +13,61 @@ type Content = string | HTMLElement | ((view: EditorView) => HTMLElement);
13
13
  export type PlaceholderOptions = {
14
14
  content: Content;
15
15
  delay?: number;
16
+ focusOnly?: boolean;
16
17
  };
17
18
 
18
19
  /**
19
- * Shows a transient placeholder at the current cursor position.
20
+ * Shows a transient placeholder at the current cursor position. When
21
+ * `focusOnly` is set the placeholder is suppressed unless the editor has DOM
22
+ * focus, and is hidden again when focus is lost.
20
23
  */
21
- export const placeholder = ({ content, delay = 3_000 }: PlaceholderOptions): Extension => {
24
+ export const placeholder = ({ content, delay = 3_000, focusOnly = false }: PlaceholderOptions): Extension => {
22
25
  const plugin = ViewPlugin.fromClass(
23
26
  class {
24
27
  _timeout: ReturnType<typeof setTimeout> | undefined;
25
28
  _decorations = Decoration.none;
26
29
 
27
30
  update(update: ViewUpdate) {
31
+ // React to actual user activity only. The empty `view.update([])`
32
+ // dispatched from the timeout below carries no doc/selection/focus
33
+ // change, so it falls through here as a no-op — that's how the
34
+ // freshly-set widget survives long enough for the decoration
35
+ // provider to read it. Without this gate, the unconditional reset
36
+ // would clobber the decoration in the same tick and the placeholder
37
+ // would never visibly render.
38
+ if (!update.docChanged && !update.selectionSet && !update.focusChanged) {
39
+ return;
40
+ }
41
+
28
42
  if (this._timeout) {
29
43
  window.clearTimeout(this._timeout);
30
44
  this._timeout = undefined;
31
45
  }
46
+ this._decorations = Decoration.none;
47
+
48
+ // Honour `focusOnly`: when the option is set and the editor isn't
49
+ // focused, leave the placeholder hidden and skip rescheduling. The
50
+ // next `focusChanged` update reschedules once focus returns.
51
+ if (focusOnly && !update.view.hasFocus) {
52
+ return;
53
+ }
32
54
 
33
- // Check if the active line (where cursor is) is empty.
34
55
  const activeLine = update.view.state.doc.lineAt(update.view.state.selection.main.head);
35
- const isEmpty = activeLine.text.trim() === '';
36
- if (isEmpty) {
37
- // Create widget decoration at the start of the current line.
38
- const lineStart = activeLine.from;
39
- this._timeout = setTimeout(() => {
40
- this._decorations = Decoration.set([
41
- Decoration.widget({
42
- widget: new PlaceholderWidget(content),
43
- side: 1,
44
- }).range(lineStart),
45
- ]);
46
-
47
- update.view.update([]);
48
- }, delay);
56
+ if (activeLine.text.trim() !== '') {
57
+ return;
49
58
  }
50
59
 
51
- this._decorations = Decoration.none;
60
+ const lineStart = activeLine.from;
61
+ const view = update.view;
62
+ this._timeout = setTimeout(() => {
63
+ this._decorations = Decoration.set([
64
+ Decoration.widget({
65
+ widget: new PlaceholderWidget(content),
66
+ side: 1,
67
+ }).range(lineStart),
68
+ ]);
69
+ view.update([]);
70
+ }, delay);
52
71
  }
53
72
 
54
73
  destroy() {
@@ -2,23 +2,43 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { type DocHandle, Repo } from '@automerge/automerge-repo';
5
+ import { type ChangeFn, type ChangeOptions, type Doc, type Heads } from '@automerge/automerge';
6
+ import { type DocHandle, Repo, decodeHeads, encodeHeads, initSubduction } from '@automerge/automerge-repo';
6
7
  import { EditorState } from '@codemirror/state';
7
8
  import { EditorView } from '@codemirror/view';
8
9
  import { render, screen } from '@testing-library/react';
9
10
  import React, { type FC, useEffect, useRef, useState } from 'react';
10
- import { describe, test } from 'vitest';
11
+ import { beforeAll, describe, test } from 'vitest';
11
12
 
13
+ import { type IDocHandle } from '@dxos/echo-db';
12
14
  import { getDeep } from '@dxos/util';
13
15
 
14
16
  import { automerge } from './automerge';
15
17
 
18
+ // Adapter: `IDocHandle` (used by `DocHandleProxy` in production) takes raw `Heads` (hex)
19
+ // at `changeAt`, but automerge-repo's `DocHandle.changeAt` expects bs58check-encoded
20
+ // `UrlHeads`. Encode on the way in, decode on the way out so the test's repo-backed
21
+ // handle satisfies the extension's `IDocHandle` contract.
22
+ const adaptRepoHandle = <T,>(handle: DocHandle<T>): IDocHandle<T> => ({
23
+ doc: () => handle.doc() as Doc<T> | undefined,
24
+ change: (callback: ChangeFn<T>, options?: ChangeOptions<T>) =>
25
+ options ? handle.change(callback, options) : handle.change(callback),
26
+ changeAt: (heads: Heads, callback: ChangeFn<T>, options?: ChangeOptions<T>): Heads | undefined => {
27
+ const encoded = options
28
+ ? handle.changeAt(encodeHeads(heads), callback, options)
29
+ : handle.changeAt(encodeHeads(heads), callback);
30
+ return encoded ? decodeHeads(encoded) : undefined;
31
+ },
32
+ addListener: (event, listener) => handle.on(event, listener),
33
+ removeListener: (event, listener) => handle.off(event, listener),
34
+ });
35
+
36
+ const path = ['text'];
37
+
16
38
  type TestObject = {
17
39
  text: string;
18
40
  };
19
41
 
20
- const path = ['text'];
21
-
22
42
  class Generator {
23
43
  constructor(private readonly _handle: DocHandle<TestObject>) {}
24
44
  update(text: string): void {
@@ -33,8 +53,7 @@ const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ han
33
53
  const [view, setView] = useState<EditorView>();
34
54
  useEffect(() => {
35
55
  const extensions = [
36
- // TODO(mykola): Fix types.
37
- automerge({ handle: handle as any, path }),
56
+ automerge({ handle: adaptRepoHandle(handle), path }),
38
57
  EditorView.updateListener.of((update) => {
39
58
  if (view.state.doc.toString() === 'hello!') {
40
59
  // Update editor.
@@ -58,19 +77,26 @@ const Test: FC<{ handle: DocHandle<TestObject>; generator: Generator }> = ({ han
58
77
  return <div ref={ref} data-testid='editor' />;
59
78
  };
60
79
 
80
+ // TODO(burdon): Test history/undo.
81
+ // TODO(burdon): https://testing-library.com/docs/react-testing-library/example-intro/
82
+
61
83
  describe('Automerge', () => {
84
+ // Subduction-fork `Repo` constructs a `MemorySigner` internally; WASM must be
85
+ // initialized first or the constructor throws `'set_subduction_logger' of undefined`.
86
+ beforeAll(async () => {
87
+ await initSubduction();
88
+ });
89
+
62
90
  test('basic sync', ({ expect }) => {
63
91
  const repo = new Repo({ network: [] });
64
92
  const handle = repo.create<TestObject>();
65
93
  const generator = new Generator(handle);
66
94
  render(<Test handle={handle} generator={generator} />);
95
+
67
96
  const editor = screen.getByTestId('editor');
68
97
  expect(editor.textContent).toBe('');
69
98
 
70
99
  generator.update('hello!');
71
100
  expect(editor.textContent).toBe('hello world!');
72
101
  });
73
-
74
- // TODO(burdon): Test history/undo.
75
- // TODO(burdon): https://testing-library.com/docs/react-testing-library/example-intro/
76
102
  });
@@ -13,7 +13,7 @@ import { DocAccessor } from '@dxos/echo-db';
13
13
  import { Cursor } from '../../util';
14
14
  import { initialSync } from '../state';
15
15
  import { cursorConverter } from './cursor';
16
- import { type State, isReconcile, updateHeadsEffect } from './defs';
16
+ import { type State, isReconcile, reconcileAnnotation, updateHeadsEffect } from './defs';
17
17
  import { Syncer } from './sync';
18
18
 
19
19
  export const automerge = (accessor: DocAccessor): Extension => {
@@ -71,11 +71,9 @@ export const automerge = (accessor: DocAccessor): Extension => {
71
71
  const value = DocAccessor.getValue<string>(accessor);
72
72
  const current = this._view.state.doc.toString();
73
73
  if (value !== current) {
74
- // TODO(burdon): This attempts to set the initial state.
75
- console.warn('ENABLING INITIAL SYNC -- THIS MAY BE A REGRESSION');
76
74
  this._view.dispatch({
77
75
  changes: { from: 0, to: this._view.state.doc.length, insert: value },
78
- annotations: initialSync,
76
+ annotations: [initialSync, reconcileAnnotation.of(true)],
79
77
  });
80
78
  }
81
79
  });
@@ -0,0 +1,88 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { markdown, markdownLanguage, insertNewlineContinueMarkup } from '@codemirror/lang-markdown';
6
+ import { EditorSelection, EditorState } from '@codemirror/state';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { createBasicExtensions } from './factories';
10
+
11
+ describe('createBasicExtensions readOnly', () => {
12
+ test('drops user-initiated edits when readOnly is true', ({ expect }) => {
13
+ const state = EditorState.create({
14
+ doc: 'hello',
15
+ extensions: [createBasicExtensions({ readOnly: true })],
16
+ });
17
+ const tr = state.update({
18
+ changes: { from: state.doc.length, insert: ' world' },
19
+ userEvent: 'input.type',
20
+ });
21
+ expect(tr.state.doc.toString()).toBe('hello');
22
+ });
23
+
24
+ for (const userEvent of ['delete.forward', 'undo', 'redo']) {
25
+ test(`drops '${userEvent}' user events when readOnly is true`, ({ expect }) => {
26
+ const state = EditorState.create({
27
+ doc: 'hello',
28
+ extensions: [createBasicExtensions({ readOnly: true })],
29
+ });
30
+ const tr = state.update({
31
+ changes: { from: state.doc.length, insert: ' world' },
32
+ userEvent,
33
+ });
34
+ expect(tr.state.doc.toString()).toBe('hello');
35
+ });
36
+ }
37
+
38
+ test('allows programmatic dispatches (no userEvent) when readOnly is true', ({ expect }) => {
39
+ // Streaming consumers (e.g. MarkdownStream) populate the doc programmatically — those
40
+ // transactions must pass even though the editor is read-only to the user.
41
+ const state = EditorState.create({
42
+ doc: 'hello',
43
+ extensions: [createBasicExtensions({ readOnly: true })],
44
+ });
45
+ const tr = state.update({ changes: { from: state.doc.length, insert: ' world' } });
46
+ expect(tr.state.doc.toString()).toBe('hello world');
47
+ });
48
+
49
+ test('selection-only transactions still apply when readOnly', ({ expect }) => {
50
+ const state = EditorState.create({
51
+ doc: 'hello',
52
+ extensions: [createBasicExtensions({ readOnly: true })],
53
+ });
54
+ const tr = state.update({ selection: EditorSelection.cursor(2) });
55
+ expect(tr.state.selection.main.head).toBe(2);
56
+ });
57
+
58
+ test('doc-changing transactions apply normally when readOnly is false', ({ expect }) => {
59
+ const state = EditorState.create({
60
+ doc: 'hello',
61
+ extensions: [createBasicExtensions({ readOnly: false })],
62
+ });
63
+ const tr = state.update({ changes: { from: state.doc.length, insert: ' world' } });
64
+ expect(tr.state.doc.toString()).toBe('hello world');
65
+ });
66
+
67
+ // Regression: in MarkdownStream `readOnly: true` must also block the markdown extension's
68
+ // Enter handler (`insertNewlineContinueMarkup`), which programmatically dispatches a list
69
+ // continuation regardless of the readOnly facet.
70
+ test('markdown insertNewlineContinueMarkup is suppressed when readOnly', ({ expect }) => {
71
+ const doc = '- one\n- two';
72
+ const state = EditorState.create({
73
+ doc,
74
+ selection: EditorSelection.cursor(doc.length),
75
+ extensions: [createBasicExtensions({ readOnly: true }), markdown({ base: markdownLanguage })],
76
+ });
77
+ let dispatched: any;
78
+ insertNewlineContinueMarkup({
79
+ state,
80
+ dispatch: (tr) => {
81
+ dispatched = tr;
82
+ },
83
+ });
84
+ if (dispatched) {
85
+ expect(dispatched.state.doc.toString()).toBe(doc);
86
+ }
87
+ });
88
+ });
@@ -33,7 +33,7 @@ import { baseTheme, createFontTheme, editorGutter } from '../styles';
33
33
  import { automerge } from './automerge';
34
34
  import { SpaceAwarenessProvider, awareness } from './awareness';
35
35
  import { focus } from './focus';
36
- import { scrollPastEnd } from './scroll-past-end';
36
+ import { scrollPastEnd } from './scrolling';
37
37
 
38
38
  //
39
39
  // Basic
@@ -143,6 +143,19 @@ export const createBasicExtensions = (propsProp?: BasicExtensionsOptions): Exten
143
143
  props.lineWrapping && EditorView.lineWrapping,
144
144
  props.placeholder && placeholder(props.placeholder),
145
145
  props.readOnly !== undefined && EditorState.readOnly.of(props.readOnly),
146
+ // `EditorState.readOnly` is advisory — CodeMirror doesn't auto-reject doc-changing
147
+ // transactions. Some extensions (e.g. `@codemirror/lang-markdown`'s Enter handler that
148
+ // continues a list) dispatch programmatic edits regardless. Drop user-initiated edits
149
+ // (`input` / `delete` keymap dispatches plus `undo` / `redo` from the history extension)
150
+ // but pass programmatic dispatches — streaming `MarkdownStream` and similar consumers
151
+ // depend on being able to populate the doc themselves.
152
+ props.readOnly &&
153
+ EditorState.transactionFilter.of((tr) =>
154
+ tr.docChanged &&
155
+ (tr.isUserEvent('input') || tr.isUserEvent('delete') || tr.isUserEvent('undo') || tr.isUserEvent('redo'))
156
+ ? []
157
+ : tr,
158
+ ),
146
159
  props.scrollPastEnd && scrollPastEnd(),
147
160
  props.tabbable && tabbable,
148
161
  props.tabSize && EditorState.tabSize.of(props.tabSize),
@@ -179,12 +192,12 @@ export const createBasicExtensions = (propsProp?: BasicExtensionsOptions): Exten
179
192
  export type ThemeExtensionsOptions = {
180
193
  monospace?: boolean;
181
194
  themeMode?: ThemeMode;
195
+ scrollbarThin?: boolean;
182
196
  slots?: {
183
197
  editor?: {
184
198
  className?: string;
185
199
  };
186
200
  scroller?: {
187
- // NOTE: Do not apply vertical padding to scroll container.
188
201
  className?: string;
189
202
  };
190
203
  content?: {
@@ -218,11 +231,12 @@ export const defaultStyles = {
218
231
  */
219
232
  export const createThemeExtensions = ({
220
233
  monospace,
221
- themeMode,
234
+ scrollbarThin,
222
235
  slots: slotsProp,
223
236
  syntaxHighlighting: syntaxHighlightingProp,
237
+ themeMode,
224
238
  }: ThemeExtensionsOptions = {}): Extension => {
225
- const slots = defaultsDeep({}, slotsProp, defaultThemeSlots);
239
+ const slots: NonNullable<ThemeExtensionsOptions['slots']> = defaultsDeep({}, slotsProp, defaultThemeSlots);
226
240
  return [
227
241
  baseTheme,
228
242
  EditorView.darkTheme.of(themeMode === 'dark'),
@@ -231,11 +245,16 @@ export const createThemeExtensions = ({
231
245
  syntaxHighlighting(HighlightStyle.define(themeMode === 'dark' ? defaultStyles.dark : defaultStyles.light)),
232
246
  slots.editor?.className && EditorView.editorAttributes.of({ class: slots.editor.className }),
233
247
  slots.content?.className && EditorView.contentAttributes.of({ class: slots.content.className }),
234
- slots.scroll?.className &&
248
+ (slots.scroller?.className || scrollbarThin) &&
235
249
  ViewPlugin.fromClass(
236
250
  class {
237
251
  constructor(view: EditorView) {
238
- view.scrollDOM.classList.add(...slots.scroll.className.split(/\s+/));
252
+ if (slots.scroller?.className) {
253
+ view.scrollDOM.classList.add(...slots.scroller.className.split(/\s+/));
254
+ }
255
+ if (scrollbarThin) {
256
+ view.scrollDOM.style.setProperty('--scrollbar-size', '4px');
257
+ }
239
258
  }
240
259
  },
241
260
  ),
@@ -4,7 +4,6 @@
4
4
 
5
5
  export * from './annotations';
6
6
  export * from './autocomplete';
7
- export * from './auto-scroll';
8
7
  export * from './automerge';
9
8
  export * from './awareness';
10
9
  export * from './blast';
@@ -26,10 +25,9 @@ export * from './modes';
26
25
  export * from './outliner';
27
26
  export * from './preview';
28
27
  export * from './replacer';
29
- export * from './scroll-past-end';
30
- export * from './scroller';
28
+ export * from './scrolling';
31
29
  export * from './selection';
30
+ export * from './snippets';
32
31
  export * from './state';
33
32
  export * from './submit';
34
33
  export * from './tags';
35
- export * from './typewriter';
@@ -156,7 +156,7 @@ const decorations = () => [
156
156
  },
157
157
 
158
158
  '.cm-list-item-focused': {
159
- borderColor: 'var(--color-neutral-focus-indicator)',
159
+ borderColor: 'var(--color-focus-ring-subtle)',
160
160
  },
161
161
  '&:focus-within .cm-list-item-selected': {
162
162
  borderColor: 'var(--color-separator)',