@dxos/ui-editor 0.8.4-main.fcfe5033a5 → 0.9.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 (169) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +1 -1
  3. package/dist/lib/browser/index.mjs +1258 -1004
  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 +1258 -1003
  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.map +1 -1
  14. package/dist/types/src/extensions/annotations.d.ts.map +1 -1
  15. package/dist/types/src/extensions/autocomplete/autocomplete.d.ts.map +1 -1
  16. package/dist/types/src/extensions/autocomplete/match.d.ts.map +1 -1
  17. package/dist/types/src/extensions/autocomplete/placeholder.d.ts +5 -2
  18. package/dist/types/src/extensions/autocomplete/placeholder.d.ts.map +1 -1
  19. package/dist/types/src/extensions/autocomplete/typeahead.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/automerge.d.ts +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 +1 -1
  23. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  24. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  25. package/dist/types/src/extensions/automerge/sync.d.ts +1 -1
  26. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  27. package/dist/types/src/extensions/automerge/update-automerge.d.ts +1 -1
  28. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  29. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -1
  30. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  31. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
  32. package/dist/types/src/extensions/blast.d.ts.map +1 -1
  33. package/dist/types/src/extensions/comments.d.ts +19 -1
  34. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  35. package/dist/types/src/extensions/debug.d.ts.map +1 -1
  36. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  37. package/dist/types/src/extensions/factories.d.ts +3 -2
  38. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  39. package/dist/types/src/extensions/factories.test.d.ts +2 -0
  40. package/dist/types/src/extensions/factories.test.d.ts.map +1 -0
  41. package/dist/types/src/extensions/focus.d.ts +1 -1
  42. package/dist/types/src/extensions/index.d.ts +3 -4
  43. package/dist/types/src/extensions/index.d.ts.map +1 -1
  44. package/dist/types/src/extensions/json.d.ts +1 -1
  45. package/dist/types/src/extensions/json.d.ts.map +1 -1
  46. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  47. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  48. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -1
  49. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
  50. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  51. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  52. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  53. package/dist/types/src/extensions/markdown/image.d.ts +13 -2
  54. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  55. package/dist/types/src/extensions/markdown/image.test.d.ts +2 -0
  56. package/dist/types/src/extensions/markdown/image.test.d.ts.map +1 -0
  57. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  58. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  59. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  60. package/dist/types/src/extensions/outliner/menu.d.ts.map +1 -1
  61. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -1
  62. package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -1
  63. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
  64. package/dist/types/src/extensions/preview/preview.d.ts +2 -2
  65. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  66. package/dist/types/src/extensions/replacer.d.ts.map +1 -1
  67. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts +18 -0
  68. package/dist/types/src/extensions/scrolling/auto-scroll.d.ts.map +1 -0
  69. package/dist/types/src/extensions/scrolling/crawler.d.ts +83 -0
  70. package/dist/types/src/extensions/scrolling/crawler.d.ts.map +1 -0
  71. package/dist/types/src/extensions/scrolling/index.d.ts +6 -0
  72. package/dist/types/src/extensions/scrolling/index.d.ts.map +1 -0
  73. package/dist/types/src/extensions/scrolling/scroll-past-end.d.ts.map +1 -0
  74. package/dist/types/src/extensions/scrolling/scrollbar-autohide.d.ts +15 -0
  75. package/dist/types/src/extensions/scrolling/scrollbar-autohide.d.ts.map +1 -0
  76. package/dist/types/src/extensions/scrolling/scroller.d.ts +16 -0
  77. package/dist/types/src/extensions/scrolling/scroller.d.ts.map +1 -0
  78. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  79. package/dist/types/src/extensions/snippets.d.ts +10 -0
  80. package/dist/types/src/extensions/snippets.d.ts.map +1 -0
  81. package/dist/types/src/extensions/spacing.d.ts +3 -0
  82. package/dist/types/src/extensions/spacing.d.ts.map +1 -0
  83. package/dist/types/src/extensions/submit.d.ts.map +1 -1
  84. package/dist/types/src/extensions/tags/extended-markdown.d.ts.map +1 -1
  85. package/dist/types/src/extensions/tags/fader.d.ts.map +1 -1
  86. package/dist/types/src/extensions/tags/index.d.ts +3 -1
  87. package/dist/types/src/extensions/tags/index.d.ts.map +1 -1
  88. package/dist/types/src/extensions/tags/typewriter.d.ts +43 -0
  89. package/dist/types/src/extensions/tags/typewriter.d.ts.map +1 -0
  90. package/dist/types/src/extensions/tags/typewriter.test.d.ts +2 -0
  91. package/dist/types/src/extensions/tags/typewriter.test.d.ts.map +1 -0
  92. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts +31 -0
  93. package/dist/types/src/extensions/tags/xml-block-decoration.d.ts.map +1 -0
  94. package/dist/types/src/extensions/tags/xml-formatting.d.ts +24 -0
  95. package/dist/types/src/extensions/tags/xml-formatting.d.ts.map +1 -0
  96. package/dist/types/src/extensions/tags/xml-tags.d.ts +1 -8
  97. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  98. package/dist/types/src/extensions/tags/xml-util.d.ts.map +1 -1
  99. package/dist/types/src/index.d.ts +0 -1
  100. package/dist/types/src/index.d.ts.map +1 -1
  101. package/dist/types/src/styles/theme.d.ts.map +1 -1
  102. package/dist/types/src/types/types.d.ts +2 -2
  103. package/dist/types/src/types/types.d.ts.map +1 -1
  104. package/dist/types/src/util/cursor.d.ts.map +1 -1
  105. package/dist/types/src/util/debug.d.ts.map +1 -1
  106. package/dist/types/src/util/decorations.d.ts.map +1 -1
  107. package/dist/types/src/util/dom.d.ts.map +1 -1
  108. package/dist/types/src/util/facet.d.ts.map +1 -1
  109. package/dist/types/src/util/util.d.ts.map +1 -1
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +55 -57
  112. package/src/defaults.ts +6 -4
  113. package/src/extensions/autocomplete/placeholder.ts +37 -18
  114. package/src/extensions/automerge/automerge.test.tsx +35 -9
  115. package/src/extensions/automerge/automerge.ts +1 -1
  116. package/src/extensions/automerge/cursor.ts +1 -1
  117. package/src/extensions/automerge/sync.ts +1 -1
  118. package/src/extensions/automerge/update-automerge.ts +1 -1
  119. package/src/extensions/comments.ts +54 -31
  120. package/src/extensions/factories.test.ts +88 -0
  121. package/src/extensions/factories.ts +22 -4
  122. package/src/extensions/index.ts +3 -4
  123. package/src/extensions/json.ts +1 -1
  124. package/src/extensions/markdown/decorate.ts +1 -1
  125. package/src/extensions/markdown/image.test.ts +54 -0
  126. package/src/extensions/markdown/image.ts +70 -9
  127. package/src/extensions/markdown/link.ts +7 -2
  128. package/src/extensions/outliner/outliner.ts +1 -1
  129. package/src/extensions/preview/preview.ts +14 -12
  130. package/src/extensions/scrolling/auto-scroll.ts +261 -0
  131. package/src/extensions/{scroller.ts → scrolling/crawler.ts} +89 -48
  132. package/src/extensions/scrolling/index.ts +9 -0
  133. package/src/extensions/{scroll-past-end.ts → scrolling/scroll-past-end.ts} +6 -6
  134. package/src/extensions/scrolling/scrollbar-autohide.ts +61 -0
  135. package/src/extensions/scrolling/scroller.ts +27 -0
  136. package/src/extensions/snippets.ts +67 -0
  137. package/src/extensions/spacing.ts +15 -0
  138. package/src/extensions/tags/index.ts +3 -1
  139. package/src/extensions/tags/testing/text.md +36 -0
  140. package/src/extensions/tags/testing/text.txt +35 -0
  141. package/src/extensions/tags/{wire.test.ts → typewriter.test.ts} +2 -2
  142. package/src/extensions/tags/typewriter.ts +594 -0
  143. package/src/extensions/tags/xml-block-decoration.ts +123 -0
  144. package/src/extensions/tags/xml-formatting.ts +125 -0
  145. package/src/extensions/tags/xml-tags.ts +6 -32
  146. package/src/extensions/tags/xml-util.test.ts +90 -3
  147. package/src/extensions/tags/xml-util.ts +62 -5
  148. package/src/index.ts +0 -1
  149. package/src/styles/theme.ts +23 -13
  150. package/src/typings.d.ts +8 -0
  151. package/dist/lib/browser/chunk-D724USEC.mjs +0 -34
  152. package/dist/lib/browser/chunk-D724USEC.mjs.map +0 -7
  153. package/dist/lib/node-esm/chunk-JRVJWKQF.mjs +0 -36
  154. package/dist/lib/node-esm/chunk-JRVJWKQF.mjs.map +0 -7
  155. package/dist/types/src/extensions/auto-scroll.d.ts +0 -8
  156. package/dist/types/src/extensions/auto-scroll.d.ts.map +0 -1
  157. package/dist/types/src/extensions/scroll-past-end.d.ts.map +0 -1
  158. package/dist/types/src/extensions/scroller.d.ts +0 -63
  159. package/dist/types/src/extensions/scroller.d.ts.map +0 -1
  160. package/dist/types/src/extensions/tags/wire.d.ts +0 -23
  161. package/dist/types/src/extensions/tags/wire.d.ts.map +0 -1
  162. package/dist/types/src/extensions/tags/wire.test.d.ts +0 -2
  163. package/dist/types/src/extensions/tags/wire.test.d.ts.map +0 -1
  164. package/dist/types/src/extensions/typewriter.d.ts +0 -10
  165. package/dist/types/src/extensions/typewriter.d.ts.map +0 -1
  166. package/src/extensions/auto-scroll.ts +0 -179
  167. package/src/extensions/tags/wire.ts +0 -459
  168. package/src/extensions/typewriter.ts +0 -68
  169. /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.fcfe5033a5",
3
+ "version": "0.9.0",
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,66 +27,64 @@
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",
39
- "@codemirror/autocomplete": "^6.19.0",
40
- "@codemirror/commands": "^6.8.1",
35
+ "@automerge/automerge": "3.3.0-fragments.1",
36
+ "@codemirror/autocomplete": "^6.20.2",
37
+ "@codemirror/commands": "^6.10.3",
41
38
  "@codemirror/lang-html": "^6.4.11",
42
- "@codemirror/lang-javascript": "^6.2.4",
39
+ "@codemirror/lang-javascript": "^6.2.5",
43
40
  "@codemirror/lang-json": "^6.0.2",
44
- "@codemirror/lang-markdown": "6.3.4",
41
+ "@codemirror/lang-markdown": "6.5.0",
45
42
  "@codemirror/lang-xml": "^6.1.0",
46
- "@codemirror/lang-yaml": "^6.1.2",
47
- "@codemirror/language": "^6.11.3",
48
- "@codemirror/language-data": "^6.5.1",
49
- "@codemirror/lint": "6.8.5",
50
- "@codemirror/search": "^6.5.11",
51
- "@codemirror/state": "^6.5.2",
43
+ "@codemirror/lang-yaml": "^6.1.3",
44
+ "@codemirror/language": "^6.12.3",
45
+ "@codemirror/language-data": "^6.5.2",
46
+ "@codemirror/lint": "6.9.6",
47
+ "@codemirror/search": "^6.7.0",
48
+ "@codemirror/state": "^6.6.0",
52
49
  "@codemirror/theme-one-dark": "^6.1.3",
53
- "@codemirror/view": "^6.38.4",
54
- "@lezer/common": "^1.2.2",
55
- "@lezer/generator": "^1.7.1",
56
- "@lezer/highlight": "^1.2.1",
57
- "@lezer/markdown": "^1.3.1",
58
- "@replit/codemirror-vim": "^6.2.1",
50
+ "@codemirror/view": "^6.43.0",
51
+ "@lezer/common": "^1.5.2",
52
+ "@lezer/generator": "^1.8.0",
53
+ "@lezer/highlight": "^1.2.3",
54
+ "@lezer/markdown": "^1.6.3",
55
+ "@replit/codemirror-vim": "^6.3.0",
59
56
  "@replit/codemirror-vscode-keymap": "^6.0.2",
60
57
  "@uiw/codemirror-theme-vscode": "^4.25.2",
61
58
  "ajv": "^8.18.0",
62
- "codemirror": "^6.0.1",
59
+ "codemirror": "^6.0.2",
63
60
  "lib0": "^0.2.65",
64
61
  "lodash.defaultsdeep": "^4.6.1",
65
62
  "lodash.merge": "^4.6.2",
66
63
  "lodash.sortby": "^4.7.0",
67
64
  "style-mod": "^4.1.0",
68
- "@dxos/async": "0.8.4-main.fcfe5033a5",
69
- "@dxos/app-graph": "0.8.4-main.fcfe5033a5",
70
- "@dxos/context": "0.8.4-main.fcfe5033a5",
71
- "@dxos/client": "0.8.4-main.fcfe5033a5",
72
- "@dxos/debug": "0.8.4-main.fcfe5033a5",
73
- "@dxos/display-name": "0.8.4-main.fcfe5033a5",
74
- "@dxos/echo": "0.8.4-main.fcfe5033a5",
75
- "@dxos/echo-db": "0.8.4-main.fcfe5033a5",
76
- "@dxos/invariant": "0.8.4-main.fcfe5033a5",
77
- "@dxos/lit-ui": "0.8.4-main.fcfe5033a5",
78
- "@dxos/log": "0.8.4-main.fcfe5033a5",
79
- "@dxos/ui": "0.8.4-main.fcfe5033a5",
80
- "@dxos/protocols": "0.8.4-main.fcfe5033a5",
81
- "@dxos/ui-theme": "0.8.4-main.fcfe5033a5",
82
- "@dxos/ui-types": "0.8.4-main.fcfe5033a5",
83
- "@dxos/util": "0.8.4-main.fcfe5033a5"
65
+ "@dxos/async": "0.9.0",
66
+ "@dxos/client": "0.9.0",
67
+ "@dxos/app-graph": "0.9.0",
68
+ "@dxos/display-name": "0.9.0",
69
+ "@dxos/context": "0.9.0",
70
+ "@dxos/echo": "0.9.0",
71
+ "@dxos/echo-client": "0.9.0",
72
+ "@dxos/debug": "0.9.0",
73
+ "@dxos/keys": "0.9.0",
74
+ "@dxos/invariant": "0.9.0",
75
+ "@dxos/lit-ui": "0.9.0",
76
+ "@dxos/log": "0.9.0",
77
+ "@dxos/ui": "0.9.0",
78
+ "@dxos/protocols": "0.9.0",
79
+ "@dxos/ui-theme": "0.9.0",
80
+ "@dxos/ui-types": "0.9.0",
81
+ "@dxos/util": "0.9.0"
84
82
  },
85
83
  "devDependencies": {
86
- "@automerge/automerge": "3.2.3",
87
- "@automerge/automerge-repo": "2.5.1",
88
- "@automerge/automerge-repo-network-broadcastchannel": "2.5.1",
89
- "@effect/platform": "0.94.4",
84
+ "@automerge/automerge": "3.3.0-fragments.1",
85
+ "@automerge/automerge-repo": "2.6.0-subduction.23",
86
+ "@automerge/automerge-repo-network-broadcastchannel": "2.6.0-subduction.23",
87
+ "@effect/platform": "0.96.1",
90
88
  "@types/chai": "^4.2.15",
91
89
  "@types/chai-dom": "^1.11.0",
92
90
  "@types/lodash.defaultsdeep": "^4.6.6",
@@ -94,25 +92,25 @@
94
92
  "@types/lodash.sortby": "^4.7.7",
95
93
  "chai": "^4.4.1",
96
94
  "chai-dom": "^1.11.0",
97
- "effect": "3.20.0",
95
+ "effect": "3.21.3",
98
96
  "happy-dom": "^20.0.0",
99
- "jsdom": "^27.0.0",
97
+ "jsdom": "^29.1.1",
100
98
  "mocha": "^10.6.0",
101
- "vite": "^7.1.11",
99
+ "vite": "^8.0.16",
102
100
  "vite-plugin-top-level-await": "^1.6.0",
103
- "vite-plugin-wasm": "^3.5.0",
104
- "@dxos/echo": "0.8.4-main.fcfe5033a5",
105
- "@dxos/config": "0.8.4-main.fcfe5033a5",
106
- "@dxos/random": "0.8.4-main.fcfe5033a5",
107
- "@dxos/keyboard": "0.8.4-main.fcfe5033a5",
108
- "@dxos/storybook-utils": "0.8.4-main.fcfe5033a5",
109
- "@dxos/schema": "0.8.4-main.fcfe5033a5",
110
- "@dxos/ui-theme": "0.8.4-main.fcfe5033a5"
101
+ "vite-plugin-wasm": "^3.6.0",
102
+ "@dxos/config": "0.9.0",
103
+ "@dxos/echo": "0.9.0",
104
+ "@dxos/keyboard": "0.9.0",
105
+ "@dxos/random": "0.9.0",
106
+ "@dxos/schema": "0.9.0",
107
+ "@dxos/storybook-utils": "0.9.0",
108
+ "@dxos/ui-theme": "0.9.0"
111
109
  },
112
110
  "peerDependencies": {
113
- "@effect/platform": "0.94.4",
114
- "effect": "3.20.0",
115
- "@dxos/ui-theme": "0.8.4-main.fcfe5033a5"
111
+ "@effect/platform": "0.96.1",
112
+ "effect": "3.21.3",
113
+ "@dxos/ui-theme": "0.9.0"
116
114
  },
117
115
  "publishConfig": {
118
116
  "access": "public"
package/src/defaults.ts CHANGED
@@ -6,10 +6,9 @@ import { mx } from '@dxos/ui-theme';
6
6
 
7
7
  import { type ThemeExtensionsOptions } from './extensions';
8
8
 
9
- // NOTE: Padding is added to the editor to account for the focus ring (since otherwise the CM gutter will clip it)
10
9
  export const editorClassNames = (role?: string) =>
11
10
  mx(
12
- 'dx-attention-surface p-0.5 data-[toolbar=disabled]:pt-2 dx-focus-ring-inset',
11
+ 'dx-attention-surface data-[toolbar=disabled]:pt-2 dx-focus-ring-inset',
13
12
  role === 'section' ? '[&_.cm-scroller]:overflow-hidden [&_.cm-scroller]:min-h-24' : 'dx-container overflow-hidden',
14
13
  );
15
14
 
@@ -22,8 +21,11 @@ export const documentSlots: ThemeExtensionsOptions['slots'] = {
22
21
  * NOTE: Max width - 4rem = 2rem left/right margin (or 2rem gutter plus 1rem left/right margin).
23
22
  */
24
23
  className: mx(
25
- // NOTE: Container for widget sizing (must have `max-w-[100cqi]`).
26
- 'dx-size-container',
24
+ // Inline-size container for widget sizing (children use `max-w-[100cqi]`).
25
+ // NOTE: Use inline-size, not full size containment — `container-type: size` on the
26
+ // editor content breaks CodeMirror's viewport measurement, leaving blank gaps during
27
+ // scroll until a click forces a re-measure.
28
+ 'dx-inline-size-container',
27
29
  // Wider margin for web (vs. mobile).
28
30
  'pointer-fine:max-w-[min(50rem,100%-4rem)] pointer-coarse:max-w-[min(50rem,100%-2rem)]',
29
31
  'mx-auto! w-full',
@@ -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-client';
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
  });
@@ -8,7 +8,7 @@ import { next as A } from '@automerge/automerge';
8
8
  import { type Extension, StateField, Transaction } from '@codemirror/state';
9
9
  import { EditorView, ViewPlugin } from '@codemirror/view';
10
10
 
11
- import { DocAccessor } from '@dxos/echo-db';
11
+ import { DocAccessor } from '@dxos/echo-client';
12
12
 
13
13
  import { Cursor } from '../../util';
14
14
  import { initialSync } from '../state';
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { type DocAccessor, fromCursor, toCursor } from '@dxos/echo-db';
5
+ import { type DocAccessor, fromCursor, toCursor } from '@dxos/echo-client';
6
6
  import { log } from '@dxos/log';
7
7
 
8
8
  import { type CursorConverter } from '../../util';
@@ -8,7 +8,7 @@ import { next as A } from '@automerge/automerge';
8
8
  import { type StateField } from '@codemirror/state';
9
9
  import { type EditorView } from '@codemirror/view';
10
10
 
11
- import { type IDocHandle } from '@dxos/echo-db';
11
+ import { type IDocHandle } from '@dxos/echo-client';
12
12
  import { log } from '@dxos/log';
13
13
 
14
14
  import { type State, getLastHeads, getPath, isReconcile, reconcileAnnotation, updateHeads } from './defs';
@@ -7,7 +7,7 @@
7
7
  import { next as A, type Heads } from '@automerge/automerge';
8
8
  import { type EditorState, type StateField, type Text, type Transaction } from '@codemirror/state';
9
9
 
10
- import { type IDocHandle } from '@dxos/echo-db';
10
+ import { type IDocHandle } from '@dxos/echo-client';
11
11
 
12
12
  import { type State } from './defs';
13
13
 
@@ -100,26 +100,26 @@ export const commentsState = StateField.define<CommentsState>({
100
100
  * NOTE: Matches search.
101
101
  */
102
102
  const styles = EditorView.theme({
103
- '.cm-comment, .cm-comment-current': {
104
- padding: '3px 0',
105
- color: 'var(--color-cm-comment-text)',
106
- backgroundColor: 'var(--color-cm-comment-surface)',
107
- },
108
- '.cm-comment > span, .cm-comment-current > span': {
103
+ '.cm-comment > span': {
109
104
  boxDecorationBreak: 'clone',
110
- boxShadow: '0 0 1px 3px var(--color-cm-comment-surface)',
105
+ boxShadow: '0 0 0 3px var(--color-cm-comment-surface)',
111
106
  backgroundColor: 'var(--color-cm-comment-surface)',
112
- color: 'var(--color-cm-comment-text)',
107
+ color: 'var(--color-cm-comment-text) !important',
113
108
  cursor: 'pointer',
114
109
  },
110
+ '.cm-comment[data-current="1"] > span': {
111
+ boxShadow: '0 0 0 3px var(--color-cm-comment-current-surface)',
112
+ backgroundColor: 'var(--color-cm-comment-current-surface)',
113
+ },
115
114
  });
116
115
 
117
116
  const createCommentMark = (id: string, isCurrent: boolean) =>
118
117
  Decoration.mark({
119
- class: isCurrent ? 'cm-comment-current' : 'cm-comment',
118
+ class: 'cm-comment',
120
119
  attributes: {
121
120
  'data-testid': 'cm-comment',
122
121
  'data-comment-id': id,
122
+ 'data-current': isCurrent ? '1' : '0',
123
123
  },
124
124
  });
125
125
 
@@ -510,34 +510,57 @@ export const comments = (options: CommentsOptions = {}): Extension => {
510
510
  // Utils.
511
511
  //
512
512
 
513
- export const scrollThreadIntoView = (view: EditorView, id: string, center = true) => {
513
+ /**
514
+ * Whether the [from, to] document range is entirely within the editor's scroll
515
+ * viewport. Returns false if either endpoint isn't currently rendered (off-screen).
516
+ */
517
+ export const isRangeVisible = (view: EditorView, range: { from: number; to: number }): boolean => {
518
+ const from = view.coordsAtPos(range.from);
519
+ const to = view.coordsAtPos(range.to);
520
+ if (!from || !to) {
521
+ return false;
522
+ }
523
+
524
+ const { top, bottom } = view.scrollDOM.getBoundingClientRect();
525
+ return from.top >= top && to.bottom <= bottom;
526
+ };
527
+
528
+ export type ScrollThreadOptions = {
529
+ /** Vertical alignment when scrolling. */
530
+ y?: 'start' | 'center' | 'end' | 'nearest';
531
+ /** Vertical margin (px) to keep around the target when scrolling. */
532
+ yMargin?: number;
533
+ };
534
+
535
+ /**
536
+ * Scroll the comment thread with the given id into view (if not already entirely
537
+ * visible) and mark it the current comment so the editor highlights it.
538
+ */
539
+ export const scrollThreadIntoView = (
540
+ view: EditorView,
541
+ id: string,
542
+ { y = 'center', yMargin }: ScrollThreadOptions = {},
543
+ ) => {
514
544
  const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
515
545
  if (!comment?.comment.cursor) {
516
546
  return;
517
547
  }
548
+
518
549
  const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
519
- if (range) {
520
- const currentSelection = view.state.selection.main;
521
- const currentScrollPosition = view.scrollDOM.scrollTop;
522
- const targetScrollPosition = view.coordsAtPos(range.from)?.top;
523
-
524
- const needsScroll =
525
- targetScrollPosition !== undefined &&
526
- (targetScrollPosition < currentScrollPosition ||
527
- targetScrollPosition > currentScrollPosition + view.scrollDOM.clientHeight);
528
-
529
- const needsSelectionUpdate = currentSelection.from !== range.from || currentSelection.to !== range.from;
530
-
531
- if (needsScroll || needsSelectionUpdate) {
532
- view.dispatch({
533
- selection: needsSelectionUpdate ? { anchor: range.from } : undefined,
534
- effects: [
535
- needsScroll ? EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined) : [],
536
- needsSelectionUpdate ? setSelection.of({ current: id }) : [],
537
- ].flat(),
538
- });
539
- }
550
+ if (!range) {
551
+ return;
540
552
  }
553
+
554
+ const { from, to } = view.state.selection.main;
555
+ const needsSelectionUpdate = from !== range.from || to !== range.from;
556
+ view.dispatch({
557
+ selection: needsSelectionUpdate ? { anchor: range.from } : undefined,
558
+ effects: [
559
+ isRangeVisible(view, range) ? [] : EditorView.scrollIntoView(range.from, { y, yMargin }),
560
+ // Always mark this thread current so the highlight follows the selected thread.
561
+ setSelection.of({ current: id }),
562
+ ].flat(),
563
+ });
541
564
  };
542
565
 
543
566
  /**
@@ -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
+ });