@react-x11/components 0.8.0 → 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 (323) hide show
  1. package/README.md +95 -34
  2. package/dist/charts/node.d.ts +1 -1
  3. package/dist/charts/node.d.ts.map +1 -1
  4. package/dist/charts/node.js +6 -2
  5. package/dist/charts/node.js.map +1 -1
  6. package/dist/embed/client.d.ts +48 -3
  7. package/dist/embed/client.d.ts.map +1 -1
  8. package/dist/embed/client.js +79 -5
  9. package/dist/embed/client.js.map +1 -1
  10. package/dist/embed/index.d.ts +1 -1
  11. package/dist/embed/index.d.ts.map +1 -1
  12. package/dist/embed/index.js +1 -1
  13. package/dist/embed/index.js.map +1 -1
  14. package/dist/html/node.d.ts +3 -1
  15. package/dist/html/node.d.ts.map +1 -1
  16. package/dist/html/node.js +7 -5
  17. package/dist/html/node.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/{markdown → internal/markdown}/ast.d.ts +12 -0
  23. package/dist/internal/markdown/ast.d.ts.map +1 -0
  24. package/dist/internal/markdown/ast.js +22 -0
  25. package/dist/internal/markdown/ast.js.map +1 -0
  26. package/dist/internal/markdown/parse.d.ts.map +1 -0
  27. package/dist/{markdown → internal/markdown}/parse.js +112 -22
  28. package/dist/internal/markdown/parse.js.map +1 -0
  29. package/dist/internal/markdown/stringify.d.ts +4 -0
  30. package/dist/internal/markdown/stringify.d.ts.map +1 -0
  31. package/dist/internal/markdown/stringify.js +684 -0
  32. package/dist/internal/markdown/stringify.js.map +1 -0
  33. package/dist/internal/markdown/tags.d.ts.map +1 -0
  34. package/dist/internal/markdown/tags.js.map +1 -0
  35. package/dist/internal/scroll.d.ts +3 -2
  36. package/dist/internal/scroll.d.ts.map +1 -1
  37. package/dist/internal/scroll.js +1 -0
  38. package/dist/internal/scroll.js.map +1 -1
  39. package/dist/maps/anchors.d.ts +98 -0
  40. package/dist/maps/anchors.d.ts.map +1 -0
  41. package/dist/maps/anchors.js +531 -0
  42. package/dist/maps/anchors.js.map +1 -0
  43. package/dist/maps/controller.d.ts +283 -0
  44. package/dist/maps/controller.d.ts.map +1 -0
  45. package/dist/maps/controller.js +743 -0
  46. package/dist/maps/controller.js.map +1 -0
  47. package/dist/maps/gl/buckets.d.ts +95 -0
  48. package/dist/maps/gl/buckets.d.ts.map +1 -0
  49. package/dist/maps/gl/buckets.js +362 -0
  50. package/dist/maps/gl/buckets.js.map +1 -0
  51. package/dist/maps/gl/build-worker.d.ts +13 -0
  52. package/dist/maps/gl/build-worker.d.ts.map +1 -0
  53. package/dist/maps/gl/build-worker.js +44 -0
  54. package/dist/maps/gl/build-worker.js.map +1 -0
  55. package/dist/maps/gl/color.d.ts +8 -0
  56. package/dist/maps/gl/color.d.ts.map +1 -0
  57. package/dist/maps/gl/color.js +57 -0
  58. package/dist/maps/gl/color.js.map +1 -0
  59. package/dist/maps/gl/cover.d.ts +51 -0
  60. package/dist/maps/gl/cover.d.ts.map +1 -0
  61. package/dist/maps/gl/cover.js +124 -0
  62. package/dist/maps/gl/cover.js.map +1 -0
  63. package/dist/maps/gl/index.d.ts +3 -0
  64. package/dist/maps/gl/index.d.ts.map +1 -0
  65. package/dist/maps/gl/index.js +20 -0
  66. package/dist/maps/gl/index.js.map +1 -0
  67. package/dist/maps/gl/markers.d.ts +28 -0
  68. package/dist/maps/gl/markers.d.ts.map +1 -0
  69. package/dist/maps/gl/markers.js +60 -0
  70. package/dist/maps/gl/markers.js.map +1 -0
  71. package/dist/maps/gl/overlays.d.ts +68 -0
  72. package/dist/maps/gl/overlays.d.ts.map +1 -0
  73. package/dist/maps/gl/overlays.js +300 -0
  74. package/dist/maps/gl/overlays.js.map +1 -0
  75. package/dist/maps/gl/pane.d.ts +12 -0
  76. package/dist/maps/gl/pane.d.ts.map +1 -0
  77. package/dist/maps/gl/pane.js +37 -0
  78. package/dist/maps/gl/pane.js.map +1 -0
  79. package/dist/maps/gl/placement.d.ts +111 -0
  80. package/dist/maps/gl/placement.d.ts.map +1 -0
  81. package/dist/maps/gl/placement.js +567 -0
  82. package/dist/maps/gl/placement.js.map +1 -0
  83. package/dist/maps/gl/renderer.d.ts +352 -0
  84. package/dist/maps/gl/renderer.d.ts.map +1 -0
  85. package/dist/maps/gl/renderer.js +1127 -0
  86. package/dist/maps/gl/renderer.js.map +1 -0
  87. package/dist/maps/gl/shaders.d.ts +120 -0
  88. package/dist/maps/gl/shaders.d.ts.map +1 -0
  89. package/dist/maps/gl/shaders.js +451 -0
  90. package/dist/maps/gl/shaders.js.map +1 -0
  91. package/dist/maps/gl/store.d.ts +115 -0
  92. package/dist/maps/gl/store.d.ts.map +1 -0
  93. package/dist/maps/gl/store.js +469 -0
  94. package/dist/maps/gl/store.js.map +1 -0
  95. package/dist/maps/gl/text.d.ts +154 -0
  96. package/dist/maps/gl/text.d.ts.map +1 -0
  97. package/dist/maps/gl/text.js +382 -0
  98. package/dist/maps/gl/text.js.map +1 -0
  99. package/dist/maps/gl/view.d.ts +54 -0
  100. package/dist/maps/gl/view.d.ts.map +1 -0
  101. package/dist/maps/gl/view.js +974 -0
  102. package/dist/maps/gl/view.js.map +1 -0
  103. package/dist/maps/index.d.ts +1 -1
  104. package/dist/maps/index.d.ts.map +1 -1
  105. package/dist/maps/index.js +216 -52
  106. package/dist/maps/index.js.map +1 -1
  107. package/dist/maps/labels.d.ts +37 -16
  108. package/dist/maps/labels.d.ts.map +1 -1
  109. package/dist/maps/labels.js +188 -119
  110. package/dist/maps/labels.js.map +1 -1
  111. package/dist/maps/node.d.ts +162 -56
  112. package/dist/maps/node.d.ts.map +1 -1
  113. package/dist/maps/node.js +607 -515
  114. package/dist/maps/node.js.map +1 -1
  115. package/dist/maps/overlay.d.ts +72 -9
  116. package/dist/maps/overlay.d.ts.map +1 -1
  117. package/dist/maps/overlay.js +108 -28
  118. package/dist/maps/overlay.js.map +1 -1
  119. package/dist/maps/paint.d.ts +3 -0
  120. package/dist/maps/paint.d.ts.map +1 -1
  121. package/dist/maps/paint.js.map +1 -1
  122. package/dist/maps/proj.d.ts +14 -0
  123. package/dist/maps/proj.d.ts.map +1 -1
  124. package/dist/maps/proj.js +28 -0
  125. package/dist/maps/proj.js.map +1 -1
  126. package/dist/maps/renderer.d.ts +69 -0
  127. package/dist/maps/renderer.d.ts.map +1 -0
  128. package/dist/maps/renderer.js +66 -0
  129. package/dist/maps/renderer.js.map +1 -0
  130. package/dist/maps/sources.d.ts +25 -3
  131. package/dist/maps/sources.d.ts.map +1 -1
  132. package/dist/maps/sources.js +17 -0
  133. package/dist/maps/sources.js.map +1 -1
  134. package/dist/maps/theme.d.ts +19 -0
  135. package/dist/maps/theme.d.ts.map +1 -0
  136. package/dist/maps/theme.js +44 -0
  137. package/dist/maps/theme.js.map +1 -0
  138. package/dist/maps/tiles.d.ts +117 -17
  139. package/dist/maps/tiles.d.ts.map +1 -1
  140. package/dist/maps/tiles.js +163 -30
  141. package/dist/maps/tiles.js.map +1 -1
  142. package/dist/maps/types.d.ts +188 -20
  143. package/dist/maps/types.d.ts.map +1 -1
  144. package/dist/markdown/expressions.d.ts +1 -1
  145. package/dist/markdown/expressions.d.ts.map +1 -1
  146. package/dist/markdown/expressions.js.map +1 -1
  147. package/dist/markdown/index.d.ts +7 -6
  148. package/dist/markdown/index.d.ts.map +1 -1
  149. package/dist/markdown/index.js +9 -7
  150. package/dist/markdown/index.js.map +1 -1
  151. package/dist/markdown/spans.d.ts +1 -1
  152. package/dist/markdown/spans.d.ts.map +1 -1
  153. package/dist/media-player/index.d.ts +11 -3
  154. package/dist/media-player/index.d.ts.map +1 -1
  155. package/dist/media-player/index.js +13 -2
  156. package/dist/media-player/index.js.map +1 -1
  157. package/dist/rich-text-editor/clipboard.d.ts +23 -0
  158. package/dist/rich-text-editor/clipboard.d.ts.map +1 -0
  159. package/dist/rich-text-editor/clipboard.js +307 -0
  160. package/dist/rich-text-editor/clipboard.js.map +1 -0
  161. package/dist/rich-text-editor/collab.d.ts +32 -0
  162. package/dist/rich-text-editor/collab.d.ts.map +1 -0
  163. package/dist/rich-text-editor/collab.js +55 -0
  164. package/dist/rich-text-editor/collab.js.map +1 -0
  165. package/dist/rich-text-editor/commands.d.ts +50 -0
  166. package/dist/rich-text-editor/commands.d.ts.map +1 -0
  167. package/dist/rich-text-editor/commands.js +376 -0
  168. package/dist/rich-text-editor/commands.js.map +1 -0
  169. package/dist/rich-text-editor/drag.d.ts +68 -0
  170. package/dist/rich-text-editor/drag.d.ts.map +1 -0
  171. package/dist/rich-text-editor/drag.js +148 -0
  172. package/dist/rich-text-editor/drag.js.map +1 -0
  173. package/dist/rich-text-editor/html.d.ts +145 -0
  174. package/dist/rich-text-editor/html.d.ts.map +1 -0
  175. package/dist/rich-text-editor/html.js +516 -0
  176. package/dist/rich-text-editor/html.js.map +1 -0
  177. package/dist/rich-text-editor/index.d.ts +263 -0
  178. package/dist/rich-text-editor/index.d.ts.map +1 -0
  179. package/dist/rich-text-editor/index.js +822 -0
  180. package/dist/rich-text-editor/index.js.map +1 -0
  181. package/dist/rich-text-editor/inline.d.ts +94 -0
  182. package/dist/rich-text-editor/inline.d.ts.map +1 -0
  183. package/dist/rich-text-editor/inline.js +252 -0
  184. package/dist/rich-text-editor/inline.js.map +1 -0
  185. package/dist/rich-text-editor/keymap.d.ts +71 -0
  186. package/dist/rich-text-editor/keymap.d.ts.map +1 -0
  187. package/dist/rich-text-editor/keymap.js +393 -0
  188. package/dist/rich-text-editor/keymap.js.map +1 -0
  189. package/dist/rich-text-editor/keys.d.ts +29 -0
  190. package/dist/rich-text-editor/keys.d.ts.map +1 -0
  191. package/dist/rich-text-editor/keys.js +93 -0
  192. package/dist/rich-text-editor/keys.js.map +1 -0
  193. package/dist/rich-text-editor/look.d.ts +71 -0
  194. package/dist/rich-text-editor/look.d.ts.map +1 -0
  195. package/dist/rich-text-editor/look.js +342 -0
  196. package/dist/rich-text-editor/look.js.map +1 -0
  197. package/dist/rich-text-editor/markdown.d.ts +18 -0
  198. package/dist/rich-text-editor/markdown.d.ts.map +1 -0
  199. package/dist/rich-text-editor/markdown.js +496 -0
  200. package/dist/rich-text-editor/markdown.js.map +1 -0
  201. package/dist/rich-text-editor/nodes.d.ts +135 -0
  202. package/dist/rich-text-editor/nodes.d.ts.map +1 -0
  203. package/dist/rich-text-editor/nodes.js +397 -0
  204. package/dist/rich-text-editor/nodes.js.map +1 -0
  205. package/dist/rich-text-editor/render.d.ts +68 -0
  206. package/dist/rich-text-editor/render.d.ts.map +1 -0
  207. package/dist/rich-text-editor/render.js +500 -0
  208. package/dist/rich-text-editor/render.js.map +1 -0
  209. package/dist/rich-text-editor/schema.d.ts +16 -0
  210. package/dist/rich-text-editor/schema.d.ts.map +1 -0
  211. package/dist/rich-text-editor/schema.js +386 -0
  212. package/dist/rich-text-editor/schema.js.map +1 -0
  213. package/dist/rich-text-editor/store.d.ts +11 -0
  214. package/dist/rich-text-editor/store.d.ts.map +1 -0
  215. package/dist/rich-text-editor/store.js +43 -0
  216. package/dist/rich-text-editor/store.js.map +1 -0
  217. package/dist/rich-text-editor/suggest.d.ts +127 -0
  218. package/dist/rich-text-editor/suggest.d.ts.map +1 -0
  219. package/dist/rich-text-editor/suggest.js +0 -0
  220. package/dist/rich-text-editor/suggest.js.map +1 -0
  221. package/dist/rich-text-editor/tables.d.ts +41 -0
  222. package/dist/rich-text-editor/tables.d.ts.map +1 -0
  223. package/dist/rich-text-editor/tables.js +204 -0
  224. package/dist/rich-text-editor/tables.js.map +1 -0
  225. package/dist/rich-text-editor/toolbar.d.ts +58 -0
  226. package/dist/rich-text-editor/toolbar.d.ts.map +1 -0
  227. package/dist/rich-text-editor/toolbar.js +548 -0
  228. package/dist/rich-text-editor/toolbar.js.map +1 -0
  229. package/dist/rich-text-editor/view.d.ts +368 -0
  230. package/dist/rich-text-editor/view.d.ts.map +1 -0
  231. package/dist/rich-text-editor/view.js +1968 -0
  232. package/dist/rich-text-editor/view.js.map +1 -0
  233. package/dist/rich-text-editor/virtual.d.ts +54 -0
  234. package/dist/rich-text-editor/virtual.d.ts.map +1 -0
  235. package/dist/rich-text-editor/virtual.js +262 -0
  236. package/dist/rich-text-editor/virtual.js.map +1 -0
  237. package/dist/richtext/node.d.ts +25 -2
  238. package/dist/richtext/node.d.ts.map +1 -1
  239. package/dist/richtext/node.js +46 -24
  240. package/dist/richtext/node.js.map +1 -1
  241. package/dist/terminal/backends.d.ts +4 -1
  242. package/dist/terminal/backends.d.ts.map +1 -1
  243. package/dist/terminal/backends.js.map +1 -1
  244. package/dist/terminal/index.d.ts +18 -7
  245. package/dist/terminal/index.d.ts.map +1 -1
  246. package/dist/terminal/index.js +28 -11
  247. package/dist/terminal/index.js.map +1 -1
  248. package/package.json +23 -4
  249. package/src/charts/node.ts +6 -2
  250. package/src/embed/client.ts +102 -8
  251. package/src/embed/index.ts +2 -0
  252. package/src/html/node.ts +7 -5
  253. package/src/index.ts +2 -0
  254. package/src/{markdown → internal/markdown}/ast.ts +24 -7
  255. package/src/{markdown → internal/markdown}/parse.ts +134 -24
  256. package/src/internal/markdown/stringify.ts +772 -0
  257. package/src/internal/scroll.ts +5 -3
  258. package/src/maps/anchors.ts +607 -0
  259. package/src/maps/controller.ts +999 -0
  260. package/src/maps/gl/buckets.ts +457 -0
  261. package/src/maps/gl/build-worker.ts +55 -0
  262. package/src/maps/gl/color.ts +62 -0
  263. package/src/maps/gl/cover.ts +170 -0
  264. package/src/maps/gl/index.ts +23 -0
  265. package/src/maps/gl/markers.ts +83 -0
  266. package/src/maps/gl/overlays.ts +379 -0
  267. package/src/maps/gl/pane.ts +42 -0
  268. package/src/maps/gl/placement.ts +816 -0
  269. package/src/maps/gl/renderer.ts +1576 -0
  270. package/src/maps/gl/shaders.ts +486 -0
  271. package/src/maps/gl/store.ts +566 -0
  272. package/src/maps/gl/text.ts +495 -0
  273. package/src/maps/gl/view.ts +1224 -0
  274. package/src/maps/index.ts +270 -81
  275. package/src/maps/labels.ts +214 -121
  276. package/src/maps/node.ts +763 -629
  277. package/src/maps/overlay.ts +148 -43
  278. package/src/maps/paint.ts +3 -0
  279. package/src/maps/proj.ts +31 -0
  280. package/src/maps/renderer.ts +155 -0
  281. package/src/maps/sources.ts +38 -3
  282. package/src/maps/theme.ts +51 -0
  283. package/src/maps/tiles.ts +191 -33
  284. package/src/maps/types.ts +188 -20
  285. package/src/markdown/expressions.ts +5 -1
  286. package/src/markdown/index.ts +14 -11
  287. package/src/markdown/spans.ts +1 -1
  288. package/src/media-player/index.ts +18 -4
  289. package/src/rich-text-editor/clipboard.ts +439 -0
  290. package/src/rich-text-editor/collab.ts +91 -0
  291. package/src/rich-text-editor/commands.ts +448 -0
  292. package/src/rich-text-editor/dom-globals.d.ts +23 -0
  293. package/src/rich-text-editor/drag.ts +211 -0
  294. package/src/rich-text-editor/html.ts +619 -0
  295. package/src/rich-text-editor/index.ts +1331 -0
  296. package/src/rich-text-editor/inline.ts +345 -0
  297. package/src/rich-text-editor/keymap.ts +510 -0
  298. package/src/rich-text-editor/keys.ts +119 -0
  299. package/src/rich-text-editor/look.ts +427 -0
  300. package/src/rich-text-editor/markdown.ts +653 -0
  301. package/src/rich-text-editor/nodes.ts +497 -0
  302. package/src/rich-text-editor/render.ts +800 -0
  303. package/src/rich-text-editor/schema.ts +431 -0
  304. package/src/rich-text-editor/store.ts +39 -0
  305. package/src/rich-text-editor/suggest.ts +597 -0
  306. package/src/rich-text-editor/tables.ts +272 -0
  307. package/src/rich-text-editor/toolbar.ts +724 -0
  308. package/src/rich-text-editor/view.ts +2287 -0
  309. package/src/rich-text-editor/virtual.ts +347 -0
  310. package/src/richtext/node.ts +59 -30
  311. package/src/terminal/backends.ts +4 -1
  312. package/src/terminal/index.ts +43 -15
  313. package/dist/markdown/ast.d.ts.map +0 -1
  314. package/dist/markdown/ast.js +0 -17
  315. package/dist/markdown/ast.js.map +0 -1
  316. package/dist/markdown/parse.d.ts.map +0 -1
  317. package/dist/markdown/parse.js.map +0 -1
  318. package/dist/markdown/tags.d.ts.map +0 -1
  319. package/dist/markdown/tags.js.map +0 -1
  320. /package/dist/{markdown → internal/markdown}/parse.d.ts +0 -0
  321. /package/dist/{markdown → internal/markdown}/tags.d.ts +0 -0
  322. /package/dist/{markdown → internal/markdown}/tags.js +0 -0
  323. /package/src/{markdown → internal/markdown}/tags.ts +0 -0
@@ -0,0 +1,2287 @@
1
+ // The editor view: ProseMirror's `EditorView` contract, drawn with react-x11.
2
+ //
3
+ // ProseMirror splits an editor in two. The *state* — the document, the
4
+ // selection, the plugins' state fields and every transformation of them — is
5
+ // plain data and runs anywhere. The *view* turns it into DOM, reads the DOM's
6
+ // geometry and events back, and is the only half that assumes a browser. So
7
+ // this file is a second view: the same contract, the same props, the same
8
+ // plugin hooks, over react-x11 elements instead of a DOM. A plugin written
9
+ // for ProseMirror — prosemirror-history, -inputrules, -keymap, -commands,
10
+ // y-prosemirror's sync — is written against the state and that contract, and
11
+ // runs here unchanged. What it cannot have is `view.dom` as a DOM node; the
12
+ // ledger of what is and is not answered is in docs/prd-rich-text-editor.md.
13
+ //
14
+ // How the work divides:
15
+ //
16
+ // - **dispatch → state → keys → render.** A transaction is applied, the
17
+ // block keys are carried through its mapping (`./keys.ts`), and React is
18
+ // asked to render only when the document changed. A selection change
19
+ // renders nothing: the caret and the selection bands belong to the text
20
+ // nodes and are set from here (`syncBlock`).
21
+ // - **Geometry is asked of the nodes.** `posAtCoords`, `coordsAtPos`, line
22
+ // motion and hit testing read the mounted text blocks' layouts through
23
+ // core's text accessors — device pixels, divided on the way out, because a
24
+ // pointer event and everything ProseMirror's API speaks are logical pixels
25
+ // (react-x11 docs/scale.md; AGENTS.md "know which unit you are in").
26
+ // - **What a browser does natively is done here**: caret motion, deleting a
27
+ // character or a word, inserting typed text, the clipboard. It runs only
28
+ // when no plugin's `handleKeyDown` claimed the key first — the same order a
29
+ // contenteditable gives ProseMirror.
30
+ import { splitBlock } from 'prosemirror-commands';
31
+ import { redo, redoDepth, undo, undoDepth } from 'prosemirror-history';
32
+ import { Slice } from 'prosemirror-model';
33
+ import type { Node as PMNode } from 'prosemirror-model';
34
+ import {
35
+ AllSelection,
36
+ NodeSelection,
37
+ Selection,
38
+ TextSelection,
39
+ } from 'prosemirror-state';
40
+ import type {
41
+ Command,
42
+ EditorState,
43
+ PluginView,
44
+ Transaction,
45
+ } from 'prosemirror-state';
46
+ import { Mapping } from 'prosemirror-transform';
47
+ import type {
48
+ Decoration,
49
+ DecorationSource,
50
+ DirectEditorProps,
51
+ EditorProps,
52
+ EditorView,
53
+ } from 'prosemirror-view';
54
+ import { closeEditMenu, editMenuOpen, openEditMenu } from 'react-x11';
55
+ import type {
56
+ Clipboard,
57
+ DrawnNode,
58
+ MouseEvent as X11MouseEvent,
59
+ ScrollableNode,
60
+ } from 'react-x11';
61
+ import { CARET_BLINK_MS } from 'react-x11/node';
62
+ import type { A11yTextState } from 'react-x11/node';
63
+
64
+ import { startInterval, stopInterval } from '../code-language/timers.js';
65
+ import type { TimerId } from '../code-language/timers.js';
66
+ import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
67
+ import type { LayoutTick } from '../internal/timers.js';
68
+ import { scaleOf } from '../internal/units.js';
69
+ import { parseFromClipboard, serializeForClipboard } from './clipboard.js';
70
+ import type { ClipboardContent, ClipboardView } from './clipboard.js';
71
+ import { caretOf } from './collab.js';
72
+ import {
73
+ domDrop,
74
+ dropTransaction,
75
+ filesSlice,
76
+ SLICE_TYPE,
77
+ textOf,
78
+ } from './drag.js';
79
+ import type { DragPayload, DropAnswer, DropLike } from './drag.js';
80
+ import type { InlineDecoration, InlineWidget, RunStyle } from './inline.js';
81
+ import { BlockKeys } from './keys.js';
82
+ import { primaryModifierOf, toDomKeyEvent } from './keymap.js';
83
+ import type { DomKeyEvent, PrimaryModifier } from './keymap.js';
84
+ import { styleFromCSS } from './look.js';
85
+ import type { EditorHost, EditorTextNode, RichEditorNode } from './nodes.js';
86
+ import { KeyedStore } from './store.js';
87
+ import type { BlockWindow } from './virtual.js';
88
+
89
+ type KeyEvent = Parameters<EditorHost['keyDown']>[0];
90
+ type ComposeEvent = Parameters<EditorHost['composition']>[0];
91
+
92
+ /** ProseMirror's direct props, less the state — the view holds that. */
93
+ export type ViewProps = Omit<DirectEditorProps, 'state'>;
94
+
95
+ /** What the component tells the view that is not ProseMirror's to say. */
96
+ export interface ViewConfig {
97
+ /** The document changed: React has blocks to render. */
98
+ onRender(): void;
99
+ /** Every state change, with the transactions that made it. */
100
+ onUpdate?(prev: EditorState, transactions: readonly Transaction[]): void;
101
+ onFocusChange?(focused: boolean): void;
102
+ clipboard: Clipboard | null;
103
+ /** Shown, muted, in an empty document. */
104
+ placeholder?: string;
105
+ colors: {
106
+ selection: string;
107
+ selectionBlurred: string;
108
+ caret: string;
109
+ placeholder: string;
110
+ preedit: string;
111
+ };
112
+ /** How a decoration's `class` looks — the one part of a DOM decoration
113
+ * this view cannot read off the decoration itself. */
114
+ decorationClasses?: Readonly<Record<string, RunStyle>>;
115
+ }
116
+
117
+ /** One block's decorations, as its renderer reads them. `sig` is what makes
118
+ * "the same decorations" the same object, so a block whose decorations did
119
+ * not move does not re-render. */
120
+ export interface BlockDecorations {
121
+ inline: readonly InlineDecoration[];
122
+ widgets: readonly InlineWidget[];
123
+ /** A node decoration's look for the block's own box. */
124
+ box: { background?: string; border?: string } | null;
125
+ sig: string;
126
+ }
127
+
128
+ export type MoveUnit =
129
+ | 'left'
130
+ | 'right'
131
+ | 'up'
132
+ | 'down'
133
+ | 'wordBackward'
134
+ | 'wordForward'
135
+ | 'lineStart'
136
+ | 'lineEnd'
137
+ | 'docStart'
138
+ | 'docEnd'
139
+ | 'pageUp'
140
+ | 'pageDown';
141
+
142
+ /** Motions made from where the caret is drawn — a line, a page, a
143
+ * line's ends — which wait for its block when a long document has it
144
+ * scrolled out of the window. */
145
+ const LAID_OUT: ReadonlySet<MoveUnit> = new Set<MoveUnit>([
146
+ 'up',
147
+ 'down',
148
+ 'pageUp',
149
+ 'pageDown',
150
+ 'lineStart',
151
+ 'lineEnd',
152
+ ]);
153
+
154
+ interface Drag {
155
+ anchor: number;
156
+ unit: 'char' | 'word' | 'block';
157
+ origin: { from: number; to: number };
158
+ }
159
+
160
+ interface Coords {
161
+ left: number;
162
+ right: number;
163
+ top: number;
164
+ bottom: number;
165
+ }
166
+
167
+ interface Hit {
168
+ pos: number;
169
+ inside: number;
170
+ atom: boolean;
171
+ }
172
+
173
+ /** A collaborator's caret in a block: an offset inside it, a colour. */
174
+ interface RemoteCaretAt {
175
+ pos: number;
176
+ color: string;
177
+ }
178
+
179
+ const XK_TAB = 0xff09;
180
+ const XK_ISO_LEFT_TAB = 0xfe20;
181
+ const XK_ESCAPE = 0xff1b;
182
+
183
+ const NO_CARETS: readonly { index: number; color: string }[] = [];
184
+
185
+ // --- text helpers ----------------------------------------------------------
186
+
187
+ /** A textblock's text with every inline leaf as one character — so offsets
188
+ * into it are ProseMirror's offsets into the block. */
189
+ function blockText(node: PMNode): string {
190
+ return node.textBetween(0, node.content.size, undefined, '');
191
+ }
192
+
193
+ let graphemes: Intl.Segmenter | null = null;
194
+ let words: Intl.Segmenter | null = null;
195
+
196
+ function segmenter(kind: 'grapheme' | 'word'): Intl.Segmenter | null {
197
+ if (typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function')
198
+ return null;
199
+ if (kind === 'grapheme')
200
+ return (graphemes ??= new Intl.Segmenter(undefined, {
201
+ granularity: 'grapheme',
202
+ }));
203
+ return (words ??= new Intl.Segmenter(undefined, { granularity: 'word' }));
204
+ }
205
+
206
+ /** One user-perceived character from `offset`: a flag, a family emoji, an
207
+ * accent on its letter — never half of any of them. */
208
+ function graphemeStep(text: string, offset: number, dir: -1 | 1): number {
209
+ const seg = segmenter('grapheme');
210
+ if (seg) {
211
+ if (dir > 0) {
212
+ const s = seg.segment(text).containing(offset);
213
+ return s ? s.index + s.segment.length : text.length;
214
+ }
215
+ if (offset <= 0) return 0;
216
+ const s = seg.segment(text).containing(offset - 1);
217
+ return s ? s.index : 0;
218
+ }
219
+ if (dir > 0)
220
+ return Math.min(
221
+ text.length,
222
+ offset + ((text.codePointAt(offset) ?? 0) > 0xffff ? 2 : 1),
223
+ );
224
+ const low = text.charCodeAt(offset - 1);
225
+ return Math.max(0, offset - (low >= 0xdc00 && low <= 0xdfff ? 2 : 1));
226
+ }
227
+
228
+ interface WordSeg {
229
+ start: number;
230
+ end: number;
231
+ word: boolean;
232
+ }
233
+
234
+ function wordSegments(text: string): WordSeg[] {
235
+ const seg = segmenter('word');
236
+ if (seg) {
237
+ return [...seg.segment(text)].map((s) => ({
238
+ start: s.index,
239
+ end: s.index + s.segment.length,
240
+ word: !!s.isWordLike,
241
+ }));
242
+ }
243
+ const out: WordSeg[] = [];
244
+ for (const m of text.matchAll(/[\p{L}\p{N}_]+|\s+|[^\p{L}\p{N}_\s]/gu)) {
245
+ out.push({
246
+ start: m.index,
247
+ end: m.index + m[0].length,
248
+ word: /[\p{L}\p{N}_]/u.test(m[0]),
249
+ });
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /** Ctrl+Arrow: over any run of spaces and punctuation, then the word. */
255
+ function wordStep(text: string, offset: number, dir: -1 | 1): number {
256
+ const segs = wordSegments(text);
257
+ if (dir < 0) {
258
+ let i = segs.length - 1;
259
+ while (i >= 0 && segs[i].start >= offset) i--;
260
+ while (i >= 0 && !segs[i].word) i--;
261
+ return i >= 0 ? segs[i].start : 0;
262
+ }
263
+ let i = 0;
264
+ while (i < segs.length && segs[i].end <= offset) i++;
265
+ while (i < segs.length && !segs[i].word) i++;
266
+ return i < segs.length ? segs[i].end : text.length;
267
+ }
268
+
269
+ /** The word a double click lands on, or the run of spaces or punctuation
270
+ * it lands on when that is what is there. */
271
+ function wordRange(text: string, offset: number): [number, number] {
272
+ const segs = wordSegments(text);
273
+ const at = offset < text.length ? offset : offset - 1;
274
+ for (const s of segs)
275
+ if (at >= s.start && at < s.end) return [s.start, s.end];
276
+ return [offset, offset];
277
+ }
278
+
279
+ function isEmptyDoc(doc: PMNode): boolean {
280
+ return (
281
+ doc.childCount === 1 &&
282
+ !!doc.firstChild?.isTextblock &&
283
+ doc.firstChild.content.size === 0
284
+ );
285
+ }
286
+
287
+ /** What an assistive technology reads a leaf as. */
288
+ function leafText(node: PMNode): string {
289
+ const own = node.type.spec.leafText?.(node);
290
+ if (own !== undefined) return own;
291
+ const name = node.type.name;
292
+ if (name === 'hard_break' || name === 'hardBreak') return '\n';
293
+ if (name === 'image') return String(node.attrs.alt ?? '') || '';
294
+ return '';
295
+ }
296
+
297
+ function mappingOf(trs: readonly Transaction[], doc: PMNode): Mapping | null {
298
+ if (trs.length === 0 || trs[0].before !== doc) return null;
299
+ const mapping = new Mapping();
300
+ for (const tr of trs) mapping.appendMapping(tr.mapping);
301
+ return mapping;
302
+ }
303
+
304
+ function collectDecorations(source: DecorationSource, out: Decoration[]): void {
305
+ const s = source as unknown as {
306
+ find?: () => Decoration[];
307
+ members?: readonly DecorationSource[];
308
+ };
309
+ if (typeof s.find === 'function') out.push(...s.find());
310
+ else if (Array.isArray(s.members))
311
+ for (const m of s.members) collectDecorations(m, out);
312
+ }
313
+
314
+ function sliceSingleNode(slice: Slice): PMNode | null {
315
+ return slice.openStart === 0 &&
316
+ slice.openEnd === 0 &&
317
+ slice.content.childCount === 1
318
+ ? slice.content.firstChild
319
+ : null;
320
+ }
321
+
322
+ // --- the view ----------------------------------------------------------------
323
+
324
+ export class RichEditorView implements EditorHost, ClipboardView {
325
+ state: EditorState;
326
+ private direct: ViewProps;
327
+ private config: ViewConfig;
328
+ /** The block keys React renders under — see `./keys.ts`. */
329
+ readonly keys: BlockKeys;
330
+ /** Per-block decorations, for the block's own renderer to subscribe to. */
331
+ readonly decorations = new KeyedStore<BlockDecorations>();
332
+ /** Which block a node selection has selected, for its renderer. */
333
+ readonly selectedBlocks = new KeyedStore<boolean>();
334
+
335
+ private root: RichEditorNode | null = null;
336
+ private texts = new Map<string, EditorTextNode>();
337
+ /** Block atoms — a rule, an image block, an atom node view — by key: what
338
+ * a press can land on that is not text. */
339
+ private boxes = new Map<string, DrawnNode>();
340
+ private scroller: ScrollableNode | null = null;
341
+ /** A long document's window (./virtual.ts): which top-level blocks are
342
+ * drawn, and how one that is not gets drawn. Null while all of them
343
+ * are. */
344
+ private blockWindow: BlockWindow | null = null;
345
+ private pluginViews: PluginView[] = [];
346
+ /** Whether the component has asked for plugin views
347
+ * (`mountPluginViews`), and whether they are made now: they live while
348
+ * the root element does. */
349
+ private pluginViewsWanted = false;
350
+ private pluginViewsLive = false;
351
+ /** Collaborators' carets by block key, from widgets `remoteCaret`
352
+ * built (./collab.ts). */
353
+ private carets = new Map<string, readonly RemoteCaretAt[]>();
354
+ private focused = false;
355
+ private preedit: string | null = null;
356
+ private goalX: number | null = null;
357
+ private keepGoal = false;
358
+ private drag: Drag | null = null;
359
+ private dragScroll: TimerId = null;
360
+ private pointer: { x: number; y: number } | null = null;
361
+ /** A press on the selection, until the pointer either comes up there —
362
+ * a click, which places the caret — or core starts a drag from it. */
363
+ private dragCandidate: { pos: number; collapse: boolean } | null = null;
364
+ /** What is being dragged out of this editor — prosemirror-view's
365
+ * `dragging` — the range it came from, and its clipboard form once
366
+ * something asked for it. */
367
+ private draggingNow: {
368
+ slice: Slice;
369
+ move: boolean;
370
+ from: number;
371
+ to: number;
372
+ content: ClipboardContent | null;
373
+ /** Whether the copy modifier was down at the drag's last motion: a
374
+ * drop in this app carries no modifiers of its own. */
375
+ copy: boolean;
376
+ } | null = null;
377
+ /** The block the drop caret is drawn in. */
378
+ private dropKey: string | null = null;
379
+ private tabEscapes = false;
380
+ /** Above zero while user input is being handled — a read-only editor
381
+ * drops the document changes that input would make, and only those. */
382
+ private userInput = 0;
383
+ private pending: Transaction[] = [];
384
+ private caretKey: string | null = null;
385
+ private bandKeys = new Set<string>();
386
+ private selectedKey: string | null = null;
387
+ private blinkTimer: TimerId = null;
388
+ private scrollTick: LayoutTick = null;
389
+ private lastCopy: { text: string; slice: Slice } | null = null;
390
+ private a11yCache: { state: EditorState; value: A11yTextState } | null = null;
391
+ private destroyedFlag = false;
392
+ private primary: PrimaryModifier = 'ctrl';
393
+
394
+ constructor(state: EditorState, props: ViewProps, config: ViewConfig) {
395
+ this.state = state;
396
+ this.direct = props;
397
+ this.config = config;
398
+ this.keys = new BlockKeys(state.doc);
399
+ this.recomputeDecorations();
400
+ // Plugin views wait for `mountPluginViews`: a browser's EditorView has
401
+ // its DOM from its constructor, and plugin views are written to find
402
+ // `view.dom` there — y-prosemirror's cursor plugin listens on it at once.
403
+ // Here the root element does not exist until React has created it.
404
+ }
405
+
406
+ // --- the EditorView surface ----------------------------------------------
407
+
408
+ /** This object, typed as what a plugin's callbacks are handed. */
409
+ get asEditorView(): EditorView {
410
+ return this as unknown as EditorView;
411
+ }
412
+
413
+ get props(): DirectEditorProps {
414
+ return { ...this.direct, state: this.state } as DirectEditorProps;
415
+ }
416
+
417
+ get editable(): boolean {
418
+ return !this.someProp('editable', (f) => f(this.state) === false);
419
+ }
420
+
421
+ get composing(): boolean {
422
+ return this.preedit !== null;
423
+ }
424
+
425
+ get isDestroyed(): boolean {
426
+ return this.destroyedFlag;
427
+ }
428
+
429
+ /** The root element — not a DOM node, which is the one thing a plugin
430
+ * written for a browser may not be given here. */
431
+ get dom(): DrawnNode | null {
432
+ return this.root as unknown as DrawnNode | null;
433
+ }
434
+
435
+ /** prosemirror-view's `dragging`: the slice a drag out of this editor
436
+ * carries, and whether a drop back in here moves it — while there is
437
+ * one. */
438
+ get dragging(): { slice: Slice; move: boolean } | null {
439
+ const d = this.draggingNow;
440
+ return d && { slice: d.slice, move: d.move };
441
+ }
442
+
443
+ /** prosemirror-view's own view description, internal there, which a
444
+ * plugin reads as "is the view drawn?": y-prosemirror's cursor plugin
445
+ * publishes no collaborator's move without it. The root element,
446
+ * while there is one. */
447
+ get docView(): unknown {
448
+ return this.root;
449
+ }
450
+
451
+ /** The backend's shortcut modifier — Cmd on macOS, Ctrl on X11. */
452
+ get primaryModifier(): PrimaryModifier {
453
+ return this.primary;
454
+ }
455
+
456
+ hasFocus(): boolean {
457
+ return this.focused;
458
+ }
459
+
460
+ focus(): void {
461
+ this.root?.focus();
462
+ }
463
+
464
+ setProps(props: Partial<ViewProps>): void {
465
+ this.update({ ...this.direct, ...props });
466
+ }
467
+
468
+ update(props: ViewProps): void {
469
+ const pluginsChanged = props.plugins !== this.direct.plugins;
470
+ this.direct = props;
471
+ if (pluginsChanged) this.resetPluginViews();
472
+ this.recomputeDecorations();
473
+ this.syncSelection();
474
+ }
475
+
476
+ /** The component's half: callbacks, the clipboard, the colours. Called on
477
+ * every render, so it only acts on what changed. */
478
+ configure(config: ViewConfig): void {
479
+ const prev = this.config;
480
+ this.config = config;
481
+ const c = config.colors;
482
+ const p = prev.colors;
483
+ const redraw =
484
+ config.placeholder !== prev.placeholder ||
485
+ config.decorationClasses !== prev.decorationClasses ||
486
+ c.placeholder !== p.placeholder ||
487
+ c.preedit !== p.preedit;
488
+ if (redraw) this.recomputeDecorations();
489
+ if (
490
+ redraw ||
491
+ c.selection !== p.selection ||
492
+ c.selectionBlurred !== p.selectionBlurred ||
493
+ c.caret !== p.caret
494
+ ) {
495
+ this.syncSelection();
496
+ }
497
+ }
498
+
499
+ /**
500
+ * ProseMirror's prop lookup, in its order: the view's own props, then its
501
+ * direct plugins, then the state's plugins — the first to give a truthy
502
+ * answer wins.
503
+ */
504
+ someProp<K extends keyof EditorProps, R>(
505
+ name: K,
506
+ f: (value: NonNullable<EditorProps[K]>) => R,
507
+ ): R | undefined {
508
+ const own = this.direct[name];
509
+ if (own != null) {
510
+ const r = f(own as NonNullable<EditorProps[K]>);
511
+ if (r) return r;
512
+ }
513
+ for (const plugin of this.direct.plugins ?? []) {
514
+ const prop = plugin.props[name];
515
+ if (prop != null) {
516
+ const r = f(prop as NonNullable<EditorProps[K]>);
517
+ if (r) return r;
518
+ }
519
+ }
520
+ for (const plugin of this.state.plugins) {
521
+ const prop = plugin.props[name];
522
+ if (prop != null) {
523
+ const r = f(prop as NonNullable<EditorProps[K]>);
524
+ if (r) return r;
525
+ }
526
+ }
527
+ return undefined;
528
+ }
529
+
530
+ readonly dispatch = (tr: Transaction): void => {
531
+ if (this.destroyedFlag) return;
532
+ if (this.userInput > 0 && tr.docChanged && !this.editable) return;
533
+ const own = this.direct.dispatchTransaction;
534
+ if (own) {
535
+ // the app applies it, and hands the state back through updateState
536
+ this.pending.push(tr);
537
+ own.call(this.asEditorView, tr);
538
+ return;
539
+ }
540
+ const { state, transactions } = this.state.applyTransaction(tr);
541
+ this.apply(state, transactions);
542
+ };
543
+
544
+ /** Replace the state — what an app owning it calls with the result of a
545
+ * transaction this view dispatched. */
546
+ updateState(state: EditorState): void {
547
+ const trs = this.pending;
548
+ this.pending = [];
549
+ this.apply(state, trs);
550
+ }
551
+
552
+ /** Run a command against the current state. */
553
+ run(command: Command): boolean {
554
+ return command(this.state, this.dispatch, this.asEditorView);
555
+ }
556
+
557
+ private apply(state: EditorState, trs: readonly Transaction[]): void {
558
+ const prev = this.state;
559
+ if (state === prev) return;
560
+ this.state = state;
561
+ const docChanged = prev.doc !== state.doc;
562
+ if (docChanged) this.keys.update(state.doc, mappingOf(trs, prev.doc));
563
+ if (prev.plugins !== state.plugins) this.resetPluginViews();
564
+ if (!this.keepGoal) this.goalX = null;
565
+ this.keepGoal = false;
566
+ this.recomputeDecorations();
567
+ if (docChanged) this.config.onRender();
568
+ this.syncSelection();
569
+ for (const view of this.pluginViews) view.update?.(this.asEditorView, prev);
570
+ if (
571
+ docChanged &&
572
+ this.root &&
573
+ editMenuOpen(this.root as unknown as DrawnNode)
574
+ ) {
575
+ closeEditMenu(this.root as unknown as DrawnNode);
576
+ }
577
+ this.a11yCache = null;
578
+ this.root?.notifyA11yTextChanged();
579
+ this.config.onUpdate?.(prev, trs);
580
+ if (trs.some((tr) => tr.scrolledIntoView)) this.scheduleScroll();
581
+ }
582
+
583
+ /**
584
+ * Make the plugins' views, once the root element exists: the component
585
+ * calls this after its first commit. A browser's EditorView makes them
586
+ * in its constructor, where it already has its DOM, and a plugin view
587
+ * is written to find `view.dom` there; here that is the root element.
588
+ */
589
+ mountPluginViews(): void {
590
+ this.pluginViewsWanted = true;
591
+ if (this.root) this.startPluginViews();
592
+ }
593
+
594
+ private startPluginViews(): void {
595
+ if (this.pluginViewsLive || this.destroyedFlag) return;
596
+ this.pluginViewsLive = true;
597
+ this.createPluginViews();
598
+ }
599
+
600
+ private stopPluginViews(): void {
601
+ for (const view of this.pluginViews) view.destroy?.();
602
+ this.pluginViews = [];
603
+ this.pluginViewsLive = false;
604
+ }
605
+
606
+ private createPluginViews(): void {
607
+ for (const plugin of [
608
+ ...(this.direct.plugins ?? []),
609
+ ...this.state.plugins,
610
+ ]) {
611
+ const make = plugin.spec.view;
612
+ if (make) this.pluginViews.push(make(this.asEditorView));
613
+ }
614
+ }
615
+
616
+ private resetPluginViews(): void {
617
+ const live = this.pluginViewsLive;
618
+ this.stopPluginViews();
619
+ if (live) this.startPluginViews();
620
+ }
621
+
622
+ destroy(): void {
623
+ if (this.destroyedFlag) return;
624
+ this.destroyedFlag = true;
625
+ for (const view of this.pluginViews) view.destroy?.();
626
+ this.pluginViews = [];
627
+ this.stopBlink();
628
+ stopInterval(this.dragScroll);
629
+ this.dragScroll = null;
630
+ cancelAfterLayout(this.scrollTick);
631
+ }
632
+
633
+ // --- the elements ----------------------------------------------------------
634
+
635
+ attachRoot(node: RichEditorNode): void {
636
+ this.root = node;
637
+ this.primary = primaryModifierOf(node.app);
638
+ if (this.pluginViewsWanted) this.startPluginViews();
639
+ }
640
+
641
+ detachRoot(node: RichEditorNode): void {
642
+ if (this.root !== node) return;
643
+ // the plugin views were made for this element, so they go before it
644
+ // does: a plugin view's `destroy` reads `view.dom` too
645
+ this.stopPluginViews();
646
+ this.root = null;
647
+ this.stopBlink();
648
+ }
649
+
650
+ attachText(key: string, node: EditorTextNode): void {
651
+ this.texts.set(key, node);
652
+ this.syncBlock(key);
653
+ }
654
+
655
+ detachText(key: string, node: EditorTextNode): void {
656
+ if (this.texts.get(key) !== node) return;
657
+ this.texts.delete(key);
658
+ this.bandKeys.delete(key);
659
+ if (this.caretKey === key) {
660
+ this.caretKey = null;
661
+ this.stopBlink();
662
+ }
663
+ }
664
+
665
+ textUpdated(key: string, node: EditorTextNode): void {
666
+ if (this.texts.get(key) === node) this.syncBlock(key);
667
+ }
668
+
669
+ /** A block atom's box, or null when it unmounts. */
670
+ attachBox(key: string, node: DrawnNode | null): void {
671
+ if (node) this.boxes.set(key, node);
672
+ else this.boxes.delete(key);
673
+ }
674
+
675
+ /** The scrolling viewport the document is in. */
676
+ attachScroller(node: ScrollableNode | null): void {
677
+ this.scroller = node;
678
+ }
679
+
680
+ /** A long document's window, while it draws only some blocks. */
681
+ attachWindow(window: BlockWindow | null): void {
682
+ this.blockWindow = window;
683
+ }
684
+
685
+ // --- selection, painted ------------------------------------------------------
686
+
687
+ private selectionColor(): string {
688
+ const menu = this.root && editMenuOpen(this.root as unknown as DrawnNode);
689
+ return this.focused || menu
690
+ ? this.config.colors.selection
691
+ : this.config.colors.selectionBlurred;
692
+ }
693
+
694
+ /** The block the caret is drawn in, if there is one to draw. */
695
+ private caretBlock(): string | null {
696
+ const sel = this.state.selection;
697
+ if (!this.focused || !sel.empty || !(sel instanceof TextSelection))
698
+ return null;
699
+ const $head = sel.$head;
700
+ if (!$head.parent.isTextblock || $head.depth === 0) return null;
701
+ return this.keys.keyAt($head.before()) ?? null;
702
+ }
703
+
704
+ /**
705
+ * Place whatever of the selection falls in one block: its band, and the
706
+ * caret if the caret is there. A block whose element still shows an older
707
+ * node is skipped — the render bringing the new one calls back.
708
+ */
709
+ private syncBlock(key: string): void {
710
+ const text = this.texts.get(key);
711
+ const node = this.keys.nodeOf(key);
712
+ const pos = this.keys.posOf(key);
713
+ if (!text || !node || pos === undefined) return;
714
+ const shown = text.props.node;
715
+ if (shown !== undefined && shown !== node) return;
716
+ const map = text.map;
717
+ if (!map) return;
718
+ const sel = this.state.selection;
719
+ const start = pos + 1;
720
+ const end = start + node.content.size;
721
+
722
+ let band: { start: number; end: number } | null = null;
723
+ const blockSelected = sel instanceof NodeSelection && !sel.node.isInline;
724
+ if (!sel.empty && !blockSelected && sel.from <= end && sel.to >= start) {
725
+ const a = map.toDisplay(Math.max(sel.from, start) - start);
726
+ const b = map.toDisplay(Math.min(sel.to, end) - start);
727
+ if (b > a) band = { start: a, end: b };
728
+ else if (sel.from < start && sel.to > end)
729
+ band = { start: 0, end: map.length }; // an empty block the selection runs through
730
+ }
731
+ text.setBand(band, this.selectionColor());
732
+ if (band) this.bandKeys.add(key);
733
+ else this.bandKeys.delete(key);
734
+
735
+ if (this.caretBlock() === key) {
736
+ text.setCaret(map.toDisplay(sel.head - start), this.config.colors.caret);
737
+ this.caretKey = key;
738
+ this.restartBlink();
739
+ } else {
740
+ text.setCaret(null, '');
741
+ if (this.caretKey === key) {
742
+ this.caretKey = null;
743
+ this.stopBlink();
744
+ }
745
+ }
746
+
747
+ const carets = this.carets.get(key);
748
+ text.setRemoteCarets(
749
+ carets
750
+ ? carets.map((c) => ({ index: map.toDisplay(c.pos), color: c.color }))
751
+ : NO_CARETS,
752
+ );
753
+ }
754
+
755
+ /** Place the whole selection: every block it touches, every block it
756
+ * touched before, the caret, and a selected block's highlight. */
757
+ syncSelection(): void {
758
+ const keys = new Set<string>(this.bandKeys);
759
+ if (this.caretKey) keys.add(this.caretKey);
760
+ const sel = this.state.selection;
761
+ if (!sel.empty) {
762
+ this.state.doc.nodesBetween(sel.from, sel.to, (node, pos) => {
763
+ if (!node.isTextblock) return true;
764
+ const key = this.keys.keyAt(pos);
765
+ if (key) keys.add(key);
766
+ return false;
767
+ });
768
+ }
769
+ const caret = this.caretBlock();
770
+ if (caret) keys.add(caret);
771
+ for (const key of keys) this.syncBlock(key);
772
+ if (!caret && this.caretKey) {
773
+ this.texts.get(this.caretKey)?.setCaret(null, '');
774
+ this.caretKey = null;
775
+ this.stopBlink();
776
+ }
777
+ const selected =
778
+ sel instanceof NodeSelection && !sel.node.isInline
779
+ ? (this.keys.keyAt(sel.from) ?? null)
780
+ : null;
781
+ if (selected !== this.selectedKey) {
782
+ if (this.selectedKey)
783
+ this.selectedBlocks.set(this.selectedKey, undefined);
784
+ if (selected) this.selectedBlocks.set(selected, true);
785
+ this.selectedKey = selected;
786
+ }
787
+ }
788
+
789
+ private restartBlink(): void {
790
+ stopInterval(this.blinkTimer);
791
+ this.blinkTimer = null;
792
+ if (!this.caretKey || !this.focused || this.destroyedFlag) return;
793
+ let on = true;
794
+ this.blinkTimer = startInterval(() => {
795
+ on = !on;
796
+ const key = this.caretKey;
797
+ if (key) this.texts.get(key)?.blink(on);
798
+ }, CARET_BLINK_MS);
799
+ }
800
+
801
+ private stopBlink(): void {
802
+ stopInterval(this.blinkTimer);
803
+ this.blinkTimer = null;
804
+ }
805
+
806
+ // --- decorations -------------------------------------------------------------
807
+
808
+ private decorationStyle(deco: Decoration): RunStyle | null {
809
+ const attrs =
810
+ (deco as unknown as { type?: { attrs?: Record<string, string> } }).type
811
+ ?.attrs ?? {};
812
+ const spec = deco.spec as { run?: RunStyle } | undefined;
813
+ let style: RunStyle = {};
814
+ if (attrs.class) {
815
+ for (const cls of attrs.class.split(/\s+/)) {
816
+ const s = this.config.decorationClasses?.[cls];
817
+ if (s) style = { ...style, ...s };
818
+ }
819
+ }
820
+ if (attrs.style) style = { ...style, ...styleFromCSS(attrs.style, {}) };
821
+ if (spec?.run) style = { ...style, ...spec.run };
822
+ return Object.keys(style).length ? style : null;
823
+ }
824
+
825
+ /**
826
+ * Every plugin's decorations, bucketed by the block they fall in, plus the
827
+ * view's own two widgets — the placeholder and a composition's preedit.
828
+ * Only a block whose bucket changed is told; returns whether any was.
829
+ *
830
+ * What a DOM view would draw that this one cannot: a widget whose only
831
+ * description is a `toDOM` function. A widget draws here when its spec
832
+ * carries `text` (and, optionally, a `run` style) — see the docs page.
833
+ */
834
+ private recomputeDecorations(): boolean {
835
+ const { doc } = this.state;
836
+ type Bucket = {
837
+ inline: InlineDecoration[];
838
+ widgets: InlineWidget[];
839
+ box: BlockDecorations['box'];
840
+ };
841
+ const buckets = new Map<string, Bucket>();
842
+ const bucket = (key: string): Bucket => {
843
+ let b = buckets.get(key);
844
+ if (!b) buckets.set(key, (b = { inline: [], widgets: [], box: null }));
845
+ return b;
846
+ };
847
+ const found: Decoration[] = [];
848
+ const carets = new Map<string, RemoteCaretAt[]>();
849
+ this.someProp('decorations', (f) => {
850
+ const source = f(this.state);
851
+ if (source) collectDecorations(source, found);
852
+ return undefined;
853
+ });
854
+
855
+ for (const deco of found) {
856
+ const typed = deco as unknown as {
857
+ widget?: boolean;
858
+ inline?: boolean;
859
+ type?: { side?: number; attrs?: Record<string, string> };
860
+ };
861
+ if (typed.widget) {
862
+ const spec = deco.spec as
863
+ { text?: unknown; run?: RunStyle; side?: number } | undefined;
864
+ if (typeof spec?.text !== 'string' || spec.text === '') {
865
+ // no text to draw it with — unless it is a collaborator's caret
866
+ const caret = caretOf(deco, this.asEditorView);
867
+ const $at = caret ? doc.resolve(deco.from) : null;
868
+ const key =
869
+ $at && $at.parent.isTextblock && $at.depth > 0
870
+ ? this.keys.keyAt($at.before())
871
+ : undefined;
872
+ if (caret && $at && key) {
873
+ let list = carets.get(key);
874
+ if (!list) carets.set(key, (list = []));
875
+ list.push({ pos: $at.parentOffset, color: caret.color });
876
+ }
877
+ continue;
878
+ }
879
+ const $pos = doc.resolve(deco.from);
880
+ if (!$pos.parent.isTextblock || $pos.depth === 0) continue;
881
+ const key = this.keys.keyAt($pos.before());
882
+ if (!key) continue;
883
+ bucket(key).widgets.push({
884
+ pos: $pos.parentOffset,
885
+ side: typed.type?.side ?? spec.side ?? 0,
886
+ text: spec.text,
887
+ ...(spec.run ? { style: spec.run } : null),
888
+ });
889
+ } else if (typed.inline) {
890
+ const style = this.decorationStyle(deco);
891
+ if (!style) continue;
892
+ doc.nodesBetween(deco.from, deco.to, (node, pos) => {
893
+ if (!node.isTextblock) return true;
894
+ const key = this.keys.keyAt(pos);
895
+ if (key) {
896
+ const start = pos + 1;
897
+ bucket(key).inline.push({
898
+ from: Math.max(0, deco.from - start),
899
+ to: Math.min(node.content.size, deco.to - start),
900
+ style,
901
+ });
902
+ }
903
+ return false;
904
+ });
905
+ } else {
906
+ const key = this.keys.keyAt(deco.from);
907
+ if (!key) continue;
908
+ const style = this.decorationStyle(deco);
909
+ if (style?.bg) bucket(key).box = { background: style.bg };
910
+ }
911
+ }
912
+
913
+ const placeholder = this.config.placeholder;
914
+ if (placeholder && isEmptyDoc(doc)) {
915
+ const key = this.keys.keyAt(0);
916
+ if (key) {
917
+ bucket(key).widgets.push({
918
+ pos: 0,
919
+ side: 0,
920
+ text: placeholder,
921
+ style: { color: this.config.colors.placeholder },
922
+ });
923
+ }
924
+ }
925
+ if (this.preedit) {
926
+ const $head = this.state.selection.$head;
927
+ if ($head.parent.isTextblock && $head.depth > 0) {
928
+ const key = this.keys.keyAt($head.before());
929
+ if (key) {
930
+ bucket(key).widgets.push({
931
+ pos: $head.parentOffset,
932
+ side: -1,
933
+ text: this.preedit,
934
+ style: { underline: this.config.colors.preedit },
935
+ });
936
+ }
937
+ }
938
+ }
939
+
940
+ let changed = false;
941
+ for (const [key, b] of buckets) {
942
+ const sig = JSON.stringify([b.inline, b.widgets, b.box]);
943
+ if (this.decorations.get(key)?.sig === sig) continue;
944
+ this.decorations.set(key, { ...b, sig });
945
+ changed = true;
946
+ }
947
+ for (const key of [...this.decorations.keys()]) {
948
+ if (!buckets.has(key)) {
949
+ this.decorations.set(key, undefined);
950
+ changed = true;
951
+ }
952
+ }
953
+
954
+ // collaborators' carets go to their blocks' elements the way the
955
+ // editor's own caret does: set on the node, nothing re-rendered
956
+ const moved: string[] = [];
957
+ for (const key of new Set([...this.carets.keys(), ...carets.keys()])) {
958
+ if (
959
+ JSON.stringify(this.carets.get(key) ?? []) !==
960
+ JSON.stringify(carets.get(key) ?? [])
961
+ )
962
+ moved.push(key);
963
+ }
964
+ this.carets = carets;
965
+ for (const key of moved) this.syncBlock(key);
966
+ return changed;
967
+ }
968
+
969
+ // --- geometry ----------------------------------------------------------------
970
+
971
+ /** The logical rect of a node — `abs`, already divided (AGENTS.md: ask for
972
+ * the rect, and the arithmetic stays in one unit). */
973
+ private rectOf(
974
+ node: DrawnNode | EditorTextNode,
975
+ ): { x: number; y: number; width: number; height: number } | null {
976
+ const r = (node as DrawnNode).getClientRects()[0];
977
+ return r && (r.width > 0 || r.height > 0) ? r : null;
978
+ }
979
+
980
+ /** The top-level block `pos` is in, by index — what a long
981
+ * document's window draws or not. */
982
+ private topIndexOf(pos: number): number {
983
+ const { doc } = this.state;
984
+ const $pos = doc.resolve(Math.max(0, Math.min(pos, doc.content.size)));
985
+ return Math.max(0, Math.min($pos.index(0), doc.childCount - 1));
986
+ }
987
+
988
+ /** Whether the block that draws `pos` is laid out: always, but in a
989
+ * long document scrolled away from it (./virtual.ts). */
990
+ private isDrawnAt(pos: number): boolean {
991
+ return !this.blockWindow || this.blockWindow.drawn(this.topIndexOf(pos));
992
+ }
993
+
994
+ /** The nearest block to a point: the one it is in, or the one closest
995
+ * vertically, then horizontally. Text blocks win ties with atoms. */
996
+ private hit(x: number, y: number): Hit | null {
997
+ let best: {
998
+ key: string;
999
+ node: DrawnNode | EditorTextNode;
1000
+ text: boolean;
1001
+ r: { x: number; y: number; width: number; height: number };
1002
+ } | null = null;
1003
+ let bestScore = Infinity;
1004
+ const consider = (
1005
+ key: string,
1006
+ node: DrawnNode | EditorTextNode,
1007
+ text: boolean,
1008
+ ): void => {
1009
+ const r = this.rectOf(node);
1010
+ if (!r) return;
1011
+ const dy =
1012
+ y < r.y
1013
+ ? r.y - y
1014
+ : y >= r.y + r.height
1015
+ ? y - (r.y + r.height) + 0.001
1016
+ : 0;
1017
+ const dx =
1018
+ x < r.x ? r.x - x : x >= r.x + r.width ? x - (r.x + r.width) : 0;
1019
+ const score = dy * 1e6 + dx;
1020
+ if (score < bestScore || (score === bestScore && text && !best?.text)) {
1021
+ bestScore = score;
1022
+ best = { key, node, text, r };
1023
+ }
1024
+ };
1025
+ for (const [key, node] of this.texts) consider(key, node, true);
1026
+ for (const [key, node] of this.boxes) consider(key, node, false);
1027
+ return best ? this.hitIn(best, x, y) : null;
1028
+ }
1029
+
1030
+ private hitIn(
1031
+ best: {
1032
+ key: string;
1033
+ node: DrawnNode | EditorTextNode;
1034
+ text: boolean;
1035
+ r: { x: number; y: number; width: number; height: number };
1036
+ },
1037
+ x: number,
1038
+ y: number,
1039
+ ): Hit | null {
1040
+ const pos = this.keys.posOf(best.key);
1041
+ if (pos === undefined) return null;
1042
+ if (!best.text) return { pos, inside: pos, atom: true };
1043
+ const text = best.node as EditorTextNode;
1044
+ const map = text.map;
1045
+ if (!map) return null;
1046
+ const s = scaleOf(text);
1047
+ const { r } = best;
1048
+ const cx = Math.min(Math.max(x, r.x), r.x + r.width - 0.5);
1049
+ const cy = Math.min(Math.max(y, r.y), r.y + r.height - 0.5);
1050
+ const cp = text.textIndexAt(cx * s, cy * s);
1051
+ return { pos: pos + 1 + map.toDoc(cp), inside: pos, atom: false };
1052
+ }
1053
+
1054
+ /** ProseMirror's `posAtCoords`, in window logical pixels — the space a
1055
+ * pointer event's `x`/`y` are in. */
1056
+ posAtCoords(coords: {
1057
+ left: number;
1058
+ top: number;
1059
+ }): { pos: number; inside: number } | null {
1060
+ const hit = this.hit(coords.left, coords.top);
1061
+ return hit && { pos: hit.pos, inside: hit.inside };
1062
+ }
1063
+
1064
+ /** ProseMirror's `coordsAtPos`: where a caret at `pos` is drawn, in window
1065
+ * logical pixels. */
1066
+ coordsAtPos(pos: number, _side = 1): Coords {
1067
+ const { doc } = this.state;
1068
+ let $pos = doc.resolve(Math.max(0, Math.min(pos, doc.content.size)));
1069
+ if (!$pos.parent.isTextblock) {
1070
+ const near = Selection.near($pos);
1071
+ if (near instanceof NodeSelection && !near.node.isInline) {
1072
+ const key = this.keys.keyAt(near.from);
1073
+ const box = key ? this.boxes.get(key) : undefined;
1074
+ const r = box && this.rectOf(box);
1075
+ if (r)
1076
+ return {
1077
+ left: r.x,
1078
+ right: r.x + r.width,
1079
+ top: r.y,
1080
+ bottom: r.y + r.height,
1081
+ };
1082
+ }
1083
+ $pos = near.$head;
1084
+ }
1085
+ if ($pos.parent.isTextblock && $pos.depth > 0) {
1086
+ const key = this.keys.keyAt($pos.before());
1087
+ const text = key ? this.texts.get(key) : undefined;
1088
+ if (text?.map) {
1089
+ const r = text.textCaretRect(text.map.toDisplay($pos.parentOffset));
1090
+ if (r) {
1091
+ const s = scaleOf(text);
1092
+ return {
1093
+ left: r.x / s,
1094
+ right: r.x / s,
1095
+ top: r.y / s,
1096
+ bottom: (r.y + r.height) / s,
1097
+ };
1098
+ }
1099
+ }
1100
+ }
1101
+ const r = this.root?.getClientRects()[0];
1102
+ return r
1103
+ ? { left: r.x, right: r.x, top: r.y, bottom: r.y }
1104
+ : { left: 0, right: 0, top: 0, bottom: 0 };
1105
+ }
1106
+
1107
+ /**
1108
+ * The textblock element that draws `pos`, and where a caret there is in
1109
+ * that element's own logical coordinates — what a `<popup anchor>` hangs
1110
+ * off, so it moves with the text when the document scrolls and unmaps
1111
+ * when the text scrolls out of view. Null when no mounted block draws it.
1112
+ */
1113
+ anchorAt(pos: number): {
1114
+ node: DrawnNode;
1115
+ at: { x: number; y: number; width: number; height: number };
1116
+ } | null {
1117
+ const { doc } = this.state;
1118
+ const $pos = doc.resolve(Math.max(0, Math.min(pos, doc.content.size)));
1119
+ if (!$pos.parent.isTextblock || $pos.depth === 0) return null;
1120
+ const key = this.keys.keyAt($pos.before());
1121
+ const text = key ? this.texts.get(key) : undefined;
1122
+ const box = text?.map ? this.rectOf(text) : null;
1123
+ if (!text || !box) return null;
1124
+ const caret = this.coordsAtPos(pos);
1125
+ return {
1126
+ node: text as unknown as DrawnNode,
1127
+ at: {
1128
+ x: caret.left - box.x,
1129
+ y: caret.top - box.y,
1130
+ width: 1,
1131
+ height: Math.max(1, caret.bottom - caret.top),
1132
+ },
1133
+ };
1134
+ }
1135
+
1136
+ /** ProseMirror's `endOfTextblock`: would moving `dir` from the selection
1137
+ * leave its textblock? Up and down ask the layout which line it is on. */
1138
+ endOfTextblock(
1139
+ dir: 'up' | 'down' | 'left' | 'right' | 'forward' | 'backward',
1140
+ state: EditorState = this.state,
1141
+ ): boolean {
1142
+ const $head = state.selection.$head;
1143
+ if (!$head.parent.isTextblock) return true;
1144
+ const atStart = $head.parentOffset === 0;
1145
+ const atEnd = $head.parentOffset === $head.parent.content.size;
1146
+ if (dir === 'backward' || dir === 'left') return atStart;
1147
+ if (dir === 'forward' || dir === 'right') return atEnd;
1148
+ const key =
1149
+ state === this.state && $head.depth > 0
1150
+ ? this.keys.keyAt($head.before())
1151
+ : undefined;
1152
+ const text = key ? this.texts.get(key) : undefined;
1153
+ const line = text?.map
1154
+ ? text.lineRange(text.map.toDisplay($head.parentOffset))
1155
+ : null;
1156
+ if (!line) return dir === 'up' ? atStart : atEnd;
1157
+ return dir === 'up' ? line.start === 0 : line.last;
1158
+ }
1159
+
1160
+ /** The nearest line that way: blocks whose band holds `y` first, then the
1161
+ * first block past the caret's line. For line motion and paging. */
1162
+ private near(x: number, y: number, dir: -1 | 1, from: Coords): Hit | null {
1163
+ type Cand = {
1164
+ key: string;
1165
+ node: DrawnNode | EditorTextNode;
1166
+ text: boolean;
1167
+ r: { x: number; y: number; width: number; height: number };
1168
+ };
1169
+ let inBand: Cand | null = null;
1170
+ let inBandDx = Infinity;
1171
+ let beyond: Cand | null = null;
1172
+ let beyondScore = Infinity;
1173
+ const consider = (
1174
+ key: string,
1175
+ node: DrawnNode | EditorTextNode,
1176
+ text: boolean,
1177
+ ): void => {
1178
+ const r = this.rectOf(node);
1179
+ if (!r) return;
1180
+ const dx =
1181
+ x < r.x ? r.x - x : x >= r.x + r.width ? x - (r.x + r.width) : 0;
1182
+ if (y >= r.y && y < r.y + r.height) {
1183
+ if (dx < inBandDx) {
1184
+ inBandDx = dx;
1185
+ inBand = { key, node, text, r };
1186
+ }
1187
+ return;
1188
+ }
1189
+ const gap = dir < 0 ? from.top - (r.y + r.height) : r.y - from.bottom;
1190
+ if (gap < -0.5) return;
1191
+ const reach = dir < 0 ? y - (r.y + r.height) : r.y - y;
1192
+ const score = Math.abs(reach) * 1e6 + dx;
1193
+ if (score < beyondScore) {
1194
+ beyondScore = score;
1195
+ beyond = { key, node, text, r };
1196
+ }
1197
+ };
1198
+ for (const [key, node] of this.texts) consider(key, node, true);
1199
+ for (const [key, node] of this.boxes) consider(key, node, false);
1200
+ const pick = (inBand ?? beyond) as Cand | null;
1201
+ if (!pick) return null;
1202
+ const ty = inBand
1203
+ ? y
1204
+ : dir < 0
1205
+ ? pick.r.y + pick.r.height - 1
1206
+ : pick.r.y + 1;
1207
+ return this.hitIn(pick, x, ty);
1208
+ }
1209
+
1210
+ private viewportHeight(): number {
1211
+ return this.scroller?.getClientRects()[0]?.height ?? 400;
1212
+ }
1213
+
1214
+ // --- motion ----------------------------------------------------------------------
1215
+
1216
+ /**
1217
+ * Move the caret — or, with `extend`, the head of the selection — by a
1218
+ * unit. Always answers true: a motion key that could not move (Up on the
1219
+ * first line of the document) is still the editor's key.
1220
+ */
1221
+ move(unit: MoveUnit, extend: boolean): boolean {
1222
+ const { doc, selection: sel } = this.state;
1223
+ // the caret's block scrolled out of a long document's window: it is
1224
+ // drawn again first, and the motion made from it once it is laid out
1225
+ if (LAID_OUT.has(unit) && !this.isDrawnAt(sel.head)) {
1226
+ const head = sel.head;
1227
+ this.blockWindow?.reveal(this.topIndexOf(head), () => {
1228
+ if (this.state.selection.head === head) this.move(unit, extend);
1229
+ });
1230
+ return true;
1231
+ }
1232
+ let target: Selection | null = null;
1233
+ let head: number | null = null;
1234
+ let atom = false;
1235
+ switch (unit) {
1236
+ case 'left':
1237
+ case 'right':
1238
+ target = this.horizontal(unit === 'left' ? -1 : 1, extend);
1239
+ break;
1240
+ case 'wordBackward':
1241
+ case 'wordForward':
1242
+ head = this.wordTarget(unit === 'wordBackward' ? -1 : 1);
1243
+ break;
1244
+ case 'up':
1245
+ case 'down':
1246
+ case 'pageUp':
1247
+ case 'pageDown': {
1248
+ const dir = unit === 'up' || unit === 'pageUp' ? -1 : 1;
1249
+ const page =
1250
+ unit === 'pageUp' || unit === 'pageDown'
1251
+ ? this.viewportHeight() * 0.9
1252
+ : 0;
1253
+ const coords = this.coordsAtPos(sel.head);
1254
+ if (this.goalX === null) this.goalX = coords.left;
1255
+ const lineHeight = Math.max(1, coords.bottom - coords.top);
1256
+ const y = page
1257
+ ? dir < 0
1258
+ ? coords.top - page
1259
+ : coords.bottom + page
1260
+ : dir < 0
1261
+ ? coords.top - lineHeight / 2
1262
+ : coords.bottom + lineHeight / 2;
1263
+ const hit = this.near(this.goalX, y, dir, coords);
1264
+ if (hit) {
1265
+ head = hit.pos;
1266
+ atom = hit.atom;
1267
+ } else {
1268
+ // no line that way: the start (or the end) of the document
1269
+ head =
1270
+ dir < 0 ? Selection.atStart(doc).head : Selection.atEnd(doc).head;
1271
+ }
1272
+ this.keepGoal = true;
1273
+ break;
1274
+ }
1275
+ case 'lineStart':
1276
+ case 'lineEnd':
1277
+ head = this.lineEdge(sel.head, unit === 'lineStart' ? -1 : 1);
1278
+ break;
1279
+ case 'docStart':
1280
+ head = Selection.atStart(doc).head;
1281
+ break;
1282
+ case 'docEnd':
1283
+ head = Selection.atEnd(doc).head;
1284
+ break;
1285
+ }
1286
+ if (head !== null) {
1287
+ if (
1288
+ atom &&
1289
+ !extend &&
1290
+ NodeSelection.isSelectable(doc.nodeAt(head) ?? doc)
1291
+ ) {
1292
+ target = NodeSelection.create(doc, head);
1293
+ } else if (extend) {
1294
+ target = TextSelection.between(sel.$anchor, doc.resolve(head));
1295
+ } else {
1296
+ target = Selection.near(doc.resolve(head));
1297
+ }
1298
+ }
1299
+ if (target && !target.eq(sel))
1300
+ this.dispatch(this.state.tr.setSelection(target).scrollIntoView());
1301
+ else if (target) this.scheduleScroll();
1302
+ return true;
1303
+ }
1304
+
1305
+ private horizontal(dir: -1 | 1, extend: boolean): Selection | null {
1306
+ const { doc, selection: sel } = this.state;
1307
+ if (!extend && !sel.empty) {
1308
+ if (sel instanceof NodeSelection) {
1309
+ return (
1310
+ Selection.findFrom(doc.resolve(dir < 0 ? sel.from : sel.to), dir) ??
1311
+ sel
1312
+ );
1313
+ }
1314
+ return TextSelection.create(doc, dir < 0 ? sel.from : sel.to);
1315
+ }
1316
+ const $head = sel.$head;
1317
+ if ($head.parent.isTextblock) {
1318
+ const offset = $head.parentOffset;
1319
+ if (dir < 0 ? offset > 0 : offset < $head.parent.content.size) {
1320
+ const pos =
1321
+ $head.start() + graphemeStep(blockText($head.parent), offset, dir);
1322
+ return extend
1323
+ ? TextSelection.create(doc, sel.anchor, pos)
1324
+ : TextSelection.create(doc, pos);
1325
+ }
1326
+ if ($head.depth === 0) return null;
1327
+ // out of the block: to the next place a caret can be — or, when not
1328
+ // extending, onto a block atom beside it (a rule), selected whole
1329
+ const next = Selection.findFrom(
1330
+ doc.resolve(dir < 0 ? $head.before() : $head.after()),
1331
+ dir,
1332
+ extend,
1333
+ );
1334
+ if (!next) return null;
1335
+ return extend ? TextSelection.create(doc, sel.anchor, next.head) : next;
1336
+ }
1337
+ const next = Selection.findFrom($head, dir, extend);
1338
+ if (!next) return null;
1339
+ return extend ? TextSelection.create(doc, sel.anchor, next.head) : next;
1340
+ }
1341
+
1342
+ private wordTarget(dir: -1 | 1): number {
1343
+ const { doc, selection: sel } = this.state;
1344
+ const $head = sel.$head;
1345
+ if (!$head.parent.isTextblock || $head.depth === 0) return sel.head;
1346
+ const offset = $head.parentOffset;
1347
+ if (dir < 0 ? offset > 0 : offset < $head.parent.content.size) {
1348
+ return $head.start() + wordStep(blockText($head.parent), offset, dir);
1349
+ }
1350
+ const next = Selection.findFrom(
1351
+ doc.resolve(dir < 0 ? $head.before() : $head.after()),
1352
+ dir,
1353
+ true,
1354
+ );
1355
+ return next ? next.head : sel.head;
1356
+ }
1357
+
1358
+ /** Where Home or End goes from `pos`: the visual line's edge, read off the
1359
+ * layout — so a wrapped paragraph's End stops where the line does. */
1360
+ private lineEdge(pos: number, dir: -1 | 1): number {
1361
+ const $pos = this.state.doc.resolve(pos);
1362
+ if (!$pos.parent.isTextblock || $pos.depth === 0) return pos;
1363
+ const key = this.keys.keyAt($pos.before());
1364
+ const text = key ? this.texts.get(key) : undefined;
1365
+ const map = text?.map;
1366
+ const line =
1367
+ text && map ? text.lineRange(map.toDisplay($pos.parentOffset)) : null;
1368
+ if (!map || !line) return dir < 0 ? $pos.start() : $pos.end();
1369
+ return $pos.start() + map.toDoc(dir < 0 ? line.start : line.end);
1370
+ }
1371
+
1372
+ private deleteBy(dir: -1 | 1, unit: 'char' | 'word' | 'line'): boolean {
1373
+ const { state } = this;
1374
+ const sel = state.selection;
1375
+ if (!sel.empty) {
1376
+ this.dispatch(state.tr.deleteSelection().scrollIntoView());
1377
+ return true;
1378
+ }
1379
+ const $head = sel.$head;
1380
+ if (!$head.parent.isTextblock) return true;
1381
+ const offset = $head.parentOffset;
1382
+ // at the block's edge a join is the keymap's (joinBackward), and it
1383
+ // declined — nothing is left to delete here
1384
+ if (dir < 0 ? offset === 0 : offset === $head.parent.content.size)
1385
+ return true;
1386
+ let target: number;
1387
+ if (unit === 'line') target = this.lineEdge($head.pos, dir);
1388
+ else {
1389
+ const text = blockText($head.parent);
1390
+ target =
1391
+ $head.start() +
1392
+ (unit === 'word'
1393
+ ? wordStep(text, offset, dir)
1394
+ : graphemeStep(text, offset, dir));
1395
+ }
1396
+ const from = Math.min(target, $head.pos);
1397
+ const to = Math.max(target, $head.pos);
1398
+ if (from !== to) this.dispatch(state.tr.delete(from, to).scrollIntoView());
1399
+ return true;
1400
+ }
1401
+
1402
+ /** Typed text, as ProseMirror's DOM view inserts it: `handleTextInput`
1403
+ * first (input rules live there), then the insertion. */
1404
+ insertText(text: string): void {
1405
+ const { from, to } = this.state.selection;
1406
+ const insert = (): Transaction => this.state.tr.insertText(text, from, to);
1407
+ if (
1408
+ this.someProp('handleTextInput', (f) =>
1409
+ f(this.asEditorView, from, to, text, insert),
1410
+ )
1411
+ )
1412
+ return;
1413
+ this.dispatch(insert().scrollIntoView());
1414
+ }
1415
+
1416
+ // --- keys ----------------------------------------------------------------------
1417
+
1418
+ keyDown(ev: KeyEvent): void {
1419
+ if (ev.composing || this.destroyedFlag) return;
1420
+ const k = ev.keysym;
1421
+ // Escape arms one Tab that leaves: an editor that keeps Tab for lists,
1422
+ // tables and code owes the keyboard user a way out (react-x11
1423
+ // docs/extending.md, "Behaviour of your own")
1424
+ if ((k === XK_TAB || k === XK_ISO_LEFT_TAB) && this.tabEscapes) {
1425
+ this.tabEscapes = false;
1426
+ return;
1427
+ }
1428
+ this.tabEscapes = false;
1429
+ const dom = toDomKeyEvent(ev, this.primary);
1430
+ this.userInput++;
1431
+ try {
1432
+ if (
1433
+ this.someProp('handleKeyDown', (f) =>
1434
+ f(this.asEditorView, dom as unknown as KeyboardEvent),
1435
+ )
1436
+ ) {
1437
+ ev.preventDefault();
1438
+ return;
1439
+ }
1440
+ // only an Escape nothing claimed: one a plugin took — a suggestion
1441
+ // list closing — was spent, and the next Escape arms the Tab instead
1442
+ this.tabEscapes = k === XK_ESCAPE;
1443
+ if (this.fallbackKey(ev, dom)) ev.preventDefault();
1444
+ } finally {
1445
+ this.userInput--;
1446
+ }
1447
+ }
1448
+
1449
+ /** What a browser's contenteditable does with a key no plugin claimed. */
1450
+ private fallbackKey(ev: KeyEvent, dom: DomKeyEvent): boolean {
1451
+ const mac = this.primary === 'meta';
1452
+ const shift = ev.shiftKey;
1453
+ const word = mac ? ev.altKey : ev.ctrlKey;
1454
+ const line = mac && ev.metaKey;
1455
+ const primaryDown = mac ? ev.metaKey : ev.ctrlKey;
1456
+ switch (dom.key) {
1457
+ case 'ArrowLeft':
1458
+ return this.move(
1459
+ line ? 'lineStart' : word ? 'wordBackward' : 'left',
1460
+ shift,
1461
+ );
1462
+ case 'ArrowRight':
1463
+ return this.move(
1464
+ line ? 'lineEnd' : word ? 'wordForward' : 'right',
1465
+ shift,
1466
+ );
1467
+ case 'ArrowUp':
1468
+ return this.move(line ? 'docStart' : 'up', shift);
1469
+ case 'ArrowDown':
1470
+ return this.move(line ? 'docEnd' : 'down', shift);
1471
+ case 'Home':
1472
+ return this.move(primaryDown ? 'docStart' : 'lineStart', shift);
1473
+ case 'End':
1474
+ return this.move(primaryDown ? 'docEnd' : 'lineEnd', shift);
1475
+ case 'PageUp':
1476
+ return this.move('pageUp', shift);
1477
+ case 'PageDown':
1478
+ return this.move('pageDown', shift);
1479
+ case 'Backspace':
1480
+ return this.deleteBy(-1, line ? 'line' : word ? 'word' : 'char');
1481
+ case 'Delete':
1482
+ if (shift && !mac) {
1483
+ this.copy(true);
1484
+ return true;
1485
+ }
1486
+ return this.deleteBy(1, line ? 'line' : word ? 'word' : 'char');
1487
+ case 'Insert':
1488
+ if (shift) {
1489
+ void this.paste(false);
1490
+ return true;
1491
+ }
1492
+ if (ev.ctrlKey) {
1493
+ this.copy(false);
1494
+ return true;
1495
+ }
1496
+ return false;
1497
+ case 'Enter':
1498
+ // nothing claimed Enter: a state without the base keymap still splits
1499
+ return this.run(splitBlock);
1500
+ }
1501
+ if (primaryDown && !ev.altKey) {
1502
+ const letter =
1503
+ ev.keysym !== undefined && ev.keysym < 0x80
1504
+ ? String.fromCharCode(ev.keysym).toLowerCase()
1505
+ : '';
1506
+ switch (letter) {
1507
+ case 'c':
1508
+ this.copy(false);
1509
+ return true;
1510
+ case 'x':
1511
+ this.copy(true);
1512
+ return true;
1513
+ case 'v':
1514
+ void this.paste(shift);
1515
+ return true;
1516
+ case 'a':
1517
+ this.dispatch(
1518
+ this.state.tr.setSelection(new AllSelection(this.state.doc)),
1519
+ );
1520
+ return true;
1521
+ }
1522
+ return false;
1523
+ }
1524
+ const text = ev.key;
1525
+ const cp = ev.codepoint;
1526
+ if (
1527
+ text &&
1528
+ cp !== undefined &&
1529
+ cp >= 0x20 &&
1530
+ cp !== 0x7f &&
1531
+ !ev.ctrlKey &&
1532
+ !ev.metaKey &&
1533
+ (mac || !ev.altKey)
1534
+ ) {
1535
+ if (
1536
+ this.someProp('handleKeyPress', (f) =>
1537
+ f(this.asEditorView, dom as unknown as KeyboardEvent),
1538
+ )
1539
+ ) {
1540
+ return true;
1541
+ }
1542
+ this.insertText(text);
1543
+ return true;
1544
+ }
1545
+ return false;
1546
+ }
1547
+
1548
+ composition(ev: ComposeEvent): void {
1549
+ if (ev.type === 'compositionEnd') {
1550
+ this.setPreedit(null);
1551
+ if (ev.data) {
1552
+ this.userInput++;
1553
+ try {
1554
+ this.insertText(ev.data);
1555
+ } finally {
1556
+ this.userInput--;
1557
+ }
1558
+ }
1559
+ return;
1560
+ }
1561
+ this.setPreedit(ev.data ?? '');
1562
+ }
1563
+
1564
+ private setPreedit(text: string | null): void {
1565
+ if (text === this.preedit) return;
1566
+ this.preedit = text;
1567
+ this.recomputeDecorations();
1568
+ this.syncSelection();
1569
+ }
1570
+
1571
+ focusChanged(focused: boolean): void {
1572
+ if (focused === this.focused) return;
1573
+ this.focused = focused;
1574
+ if (!focused) {
1575
+ this.drag = null;
1576
+ this.tabEscapes = false;
1577
+ if (this.preedit !== null) this.setPreedit(null);
1578
+ }
1579
+ this.syncSelection();
1580
+ if (!focused) this.stopBlink();
1581
+ this.config.onFocusChange?.(focused);
1582
+ }
1583
+
1584
+ // --- the pointer -----------------------------------------------------------------
1585
+
1586
+ /** A press, from the root's `onMouseDown`. Returns whether the editor
1587
+ * took it — the component then prevents core's default action. */
1588
+ mouseDown(ev: X11MouseEvent): boolean {
1589
+ this.goalX = null;
1590
+ this.dragCandidate = null;
1591
+ const hit = this.hit(ev.x, ev.y);
1592
+ if (!hit) return false;
1593
+ const { doc } = this.state;
1594
+ const sel = this.state.selection;
1595
+ if (ev.button === 3) {
1596
+ // a right press outside the selection moves the caret there first, so
1597
+ // the menu it opens acts on what is under the pointer
1598
+ if (hit.pos < sel.from || hit.pos > sel.to) {
1599
+ this.dispatch(
1600
+ this.state.tr.setSelection(Selection.near(doc.resolve(hit.pos))),
1601
+ );
1602
+ }
1603
+ return false;
1604
+ }
1605
+ if (ev.button === 2) {
1606
+ this.dispatch(
1607
+ this.state.tr.setSelection(Selection.near(doc.resolve(hit.pos))),
1608
+ );
1609
+ void this.paste(false, 'PRIMARY');
1610
+ return true;
1611
+ }
1612
+ if (ev.button !== 1) return false;
1613
+ const detail = ev.detail || 1;
1614
+ if (this.clickHandlers(hit, ev, detail)) return true;
1615
+ // a press on the selection may be the start of dragging it: it is
1616
+ // left to core, which arms a drag on the root, and settled when the
1617
+ // pointer comes up — a click — or when core starts the drag
1618
+ if (detail === 1 && !ev.shiftKey && this.onSelection(hit)) {
1619
+ this.dragCandidate = {
1620
+ pos: hit.pos,
1621
+ collapse: !(sel instanceof NodeSelection),
1622
+ };
1623
+ return false;
1624
+ }
1625
+ if (hit.atom) {
1626
+ const node = doc.nodeAt(hit.pos);
1627
+ if (node && NodeSelection.isSelectable(node)) {
1628
+ this.dispatch(
1629
+ this.state.tr.setSelection(NodeSelection.create(doc, hit.pos)),
1630
+ );
1631
+ }
1632
+ return true;
1633
+ }
1634
+ let anchor = hit.pos;
1635
+ let head = hit.pos;
1636
+ let unit: Drag['unit'] = 'char';
1637
+ if (detail >= 3) {
1638
+ unit = 'block';
1639
+ [anchor, head] = this.blockAround(hit.pos);
1640
+ } else if (detail === 2) {
1641
+ unit = 'word';
1642
+ [anchor, head] = this.wordAround(hit.pos);
1643
+ } else if (ev.shiftKey) {
1644
+ anchor = sel.anchor;
1645
+ }
1646
+ this.drag = { anchor, unit, origin: { from: anchor, to: head } };
1647
+ this.dispatch(
1648
+ this.state.tr.setSelection(
1649
+ TextSelection.between(doc.resolve(anchor), doc.resolve(head)),
1650
+ ),
1651
+ );
1652
+ ev.capturePointer?.();
1653
+ return true;
1654
+ }
1655
+
1656
+ /** ProseMirror's click props, innermost node first: `handleClickOn` for
1657
+ * each node around the position, then `handleClick`. */
1658
+ private clickHandlers(hit: Hit, ev: X11MouseEvent, detail: number): boolean {
1659
+ const [on, plain] =
1660
+ detail >= 3
1661
+ ? (['handleTripleClickOn', 'handleTripleClick'] as const)
1662
+ : detail === 2
1663
+ ? (['handleDoubleClickOn', 'handleDoubleClick'] as const)
1664
+ : (['handleClickOn', 'handleClick'] as const);
1665
+ const event = {
1666
+ clientX: ev.x,
1667
+ clientY: ev.y,
1668
+ button: ev.button - 1,
1669
+ detail,
1670
+ shiftKey: ev.shiftKey,
1671
+ ctrlKey: ev.ctrlKey,
1672
+ altKey: ev.altKey,
1673
+ metaKey: ev.metaKey,
1674
+ preventDefault() {},
1675
+ stopPropagation() {},
1676
+ } as unknown as MouseEvent;
1677
+ const $pos = this.state.doc.resolve(hit.pos);
1678
+ for (let d = $pos.depth; d >= 0; d--) {
1679
+ const node = $pos.node(d);
1680
+ const nodePos = d > 0 ? $pos.before(d) : -1;
1681
+ const direct = d === $pos.depth;
1682
+ const handled = this.someProp(on, (f) =>
1683
+ (
1684
+ f as (
1685
+ v: EditorView,
1686
+ p: number,
1687
+ n: PMNode,
1688
+ np: number,
1689
+ e: MouseEvent,
1690
+ dr: boolean,
1691
+ ) => boolean | void
1692
+ )(this.asEditorView, hit.pos, node, nodePos, event, direct),
1693
+ );
1694
+ if (handled) return true;
1695
+ }
1696
+ return !!this.someProp(plain, (f) =>
1697
+ (f as (v: EditorView, p: number, e: MouseEvent) => boolean | void)(
1698
+ this.asEditorView,
1699
+ hit.pos,
1700
+ event,
1701
+ ),
1702
+ );
1703
+ }
1704
+
1705
+ /** Motion while pressed, from the root's `onMouseMove`. */
1706
+ mouseMove(ev: X11MouseEvent): boolean {
1707
+ if (!this.drag) return false;
1708
+ this.pointer = { x: ev.x, y: ev.y };
1709
+ this.extendDrag(ev.x, ev.y);
1710
+ this.autoScroll();
1711
+ return true;
1712
+ }
1713
+
1714
+ mouseUp(): void {
1715
+ // a press on the selection that never became a drag was a click: the
1716
+ // caret goes where it was — a selected block stays selected
1717
+ const press = this.dragCandidate;
1718
+ this.dragCandidate = null;
1719
+ if (press?.collapse) {
1720
+ const { doc } = this.state;
1721
+ this.dispatch(
1722
+ this.state.tr.setSelection(
1723
+ Selection.near(doc.resolve(Math.min(press.pos, doc.content.size))),
1724
+ ),
1725
+ );
1726
+ }
1727
+ if (!this.drag) return;
1728
+ this.drag = null;
1729
+ stopInterval(this.dragScroll);
1730
+ this.dragScroll = null;
1731
+ this.ownPrimary();
1732
+ }
1733
+
1734
+ private extendDrag(x: number, y: number): void {
1735
+ const drag = this.drag;
1736
+ const hit = drag && this.hit(x, y);
1737
+ if (!drag || !hit || hit.atom) return;
1738
+ const { doc } = this.state;
1739
+ let anchor = drag.anchor;
1740
+ let head = hit.pos;
1741
+ if (drag.unit !== 'char') {
1742
+ const [a, b] =
1743
+ drag.unit === 'word'
1744
+ ? this.wordAround(hit.pos)
1745
+ : this.blockAround(hit.pos);
1746
+ if (hit.pos < drag.origin.from) {
1747
+ anchor = drag.origin.to;
1748
+ head = a;
1749
+ } else {
1750
+ anchor = drag.origin.from;
1751
+ head = b;
1752
+ }
1753
+ }
1754
+ const next = TextSelection.between(doc.resolve(anchor), doc.resolve(head));
1755
+ if (!next.eq(this.state.selection))
1756
+ this.dispatch(this.state.tr.setSelection(next));
1757
+ }
1758
+
1759
+ /** A drag held past the viewport's edge keeps scrolling, and keeps
1760
+ * extending, until the pointer comes back or the button comes up. */
1761
+ private autoScroll(): void {
1762
+ const outside = (): number => {
1763
+ const p = this.pointer;
1764
+ const box = this.scroller?.getClientRects()[0];
1765
+ if (!p || !box) return 0;
1766
+ if (p.y < box.y) return p.y - box.y;
1767
+ if (p.y > box.y + box.height) return p.y - (box.y + box.height);
1768
+ return 0;
1769
+ };
1770
+ if (!outside()) {
1771
+ stopInterval(this.dragScroll);
1772
+ this.dragScroll = null;
1773
+ return;
1774
+ }
1775
+ if (this.dragScroll !== null) return;
1776
+ this.dragScroll = startInterval(() => {
1777
+ const o = outside();
1778
+ if (!o || !this.drag || !this.scroller) return;
1779
+ this.scroller.scrollBy({
1780
+ y: Math.sign(o) * Math.min(40, Math.max(4, Math.abs(o))),
1781
+ });
1782
+ const p = this.pointer;
1783
+ if (p) this.extendDrag(p.x, p.y);
1784
+ }, 50);
1785
+ }
1786
+
1787
+ private wordAround(pos: number): [number, number] {
1788
+ const $pos = this.state.doc.resolve(pos);
1789
+ if (!$pos.parent.isTextblock) return [pos, pos];
1790
+ const [a, b] = wordRange(blockText($pos.parent), $pos.parentOffset);
1791
+ return [$pos.start() + a, $pos.start() + b];
1792
+ }
1793
+
1794
+ private blockAround(pos: number): [number, number] {
1795
+ const $pos = this.state.doc.resolve(pos);
1796
+ if (!$pos.parent.isTextblock) return [pos, pos];
1797
+ return [$pos.start(), $pos.end()];
1798
+ }
1799
+
1800
+ // --- drag and drop --------------------------------------------------------------
1801
+ //
1802
+ // The gesture is core's; these answer its events on the root, and
1803
+ // ./drag.ts has the document's half. A drag starts only from a press on the
1804
+ // selection — `mouseDown` leaves that press to core, which arms a drag on
1805
+ // the draggable root — and a drop is read the way a paste is.
1806
+
1807
+ /** Whether a press lands on what is selected: inside a text selection, or
1808
+ * on the block a node selection holds. */
1809
+ private onSelection(hit: Hit): boolean {
1810
+ const sel = this.state.selection;
1811
+ if (sel.empty) return false;
1812
+ if (sel instanceof NodeSelection) return hit.pos === sel.from;
1813
+ return hit.pos > sel.from && hit.pos < sel.to;
1814
+ }
1815
+
1816
+ /** Core is starting a drag from the root. It is the selection's when the
1817
+ * press was on the selection; false has the drag cancelled, and the
1818
+ * gesture goes on as mouse events. */
1819
+ dragStart(): boolean {
1820
+ const press = this.dragCandidate;
1821
+ this.dragCandidate = null;
1822
+ const sel = this.state.selection;
1823
+ if (!press || sel.empty) return false;
1824
+ this.draggingNow = {
1825
+ slice: sel.content(),
1826
+ move: this.editable,
1827
+ from: sel.from,
1828
+ to: sel.to,
1829
+ content: null,
1830
+ copy: false,
1831
+ };
1832
+ return true;
1833
+ }
1834
+
1835
+ private dragContent(): ClipboardContent | null {
1836
+ const d = this.draggingNow;
1837
+ return d ? (d.content ??= serializeForClipboard(this, d.slice)) : null;
1838
+ }
1839
+
1840
+ /** What a drag out of the editor offers another application: what a copy
1841
+ * would — its text, and its HTML. */
1842
+ dragText(): string {
1843
+ return this.dragContent()?.text ?? '';
1844
+ }
1845
+
1846
+ dragHTML(): string {
1847
+ return this.dragContent()?.html ?? '';
1848
+ }
1849
+
1850
+ /** …and a drop in this app, the slice itself. */
1851
+ dragPayload(): DragPayload | null {
1852
+ const d = this.draggingNow;
1853
+ return d ? { slice: d.slice, view: this, copy: d.copy } : null;
1854
+ }
1855
+
1856
+ /** Each motion of a drag out of this editor: whether the copy
1857
+ * modifier — Ctrl, or Option on the Mac backend — is down. Core's drop
1858
+ * in this app arrives with no modifiers, so the drag carries them. */
1859
+ dragMoved(ctrlKey: boolean, altKey: boolean): void {
1860
+ if (this.draggingNow)
1861
+ this.draggingNow.copy = this.primary === 'meta' ? altKey : ctrlKey;
1862
+ }
1863
+
1864
+ /** The drag out of this editor ended. A move that another editor or
1865
+ * application took takes the content out of here; a drop back in here
1866
+ * has done that already. */
1867
+ dragEnd(action: string | null, dropped: boolean): void {
1868
+ const d = this.draggingNow;
1869
+ this.draggingNow = null;
1870
+ this.setDropAt(null);
1871
+ if (!d || !dropped || action !== 'move' || !this.editable) return;
1872
+ const sel = this.state.selection;
1873
+ // only what was dragged: a document that has moved on keeps it
1874
+ if (sel.from !== d.from || sel.to !== d.to) return;
1875
+ this.dispatch(
1876
+ this.state.tr
1877
+ .deleteSelection()
1878
+ .scrollIntoView()
1879
+ .setMeta('uiEvent', 'drop'),
1880
+ );
1881
+ }
1882
+
1883
+ /** A drag over the editor at logical window `x`, `y`: a caret drawn where
1884
+ * it would go in. False when nothing is taken here. */
1885
+ dragOver(x: number, y: number): boolean {
1886
+ if (!this.editable) {
1887
+ this.setDropAt(null);
1888
+ return false;
1889
+ }
1890
+ const hit = this.hit(x, y);
1891
+ this.setDropAt(hit && !hit.atom ? hit.pos : null);
1892
+ return true;
1893
+ }
1894
+
1895
+ dragLeave(): void {
1896
+ this.setDropAt(null);
1897
+ }
1898
+
1899
+ /** The drop caret, on the block a drop would go into: its text element
1900
+ * draws it the way it draws the caret, re-rendering nothing. */
1901
+ private setDropAt(pos: number | null): void {
1902
+ let key: string | null = null;
1903
+ let index = 0;
1904
+ if (pos !== null) {
1905
+ const { doc } = this.state;
1906
+ const $pos = doc.resolve(Math.max(0, Math.min(pos, doc.content.size)));
1907
+ const at =
1908
+ $pos.parent.isTextblock && $pos.depth > 0
1909
+ ? this.keys.keyAt($pos.before())
1910
+ : undefined;
1911
+ const map = at ? this.texts.get(at)?.map : null;
1912
+ if (at && map) {
1913
+ key = at;
1914
+ index = map.toDisplay($pos.parentOffset);
1915
+ }
1916
+ }
1917
+ if (this.dropKey && this.dropKey !== key)
1918
+ this.texts.get(this.dropKey)?.setDropCaret(null, '');
1919
+ this.dropKey = key;
1920
+ if (key) this.texts.get(key)?.setDropCaret(index, this.config.colors.caret);
1921
+ }
1922
+
1923
+ /**
1924
+ * A drop on the editor: read, offered to `handleDrop`, and put in where it
1925
+ * fits — prosemirror-view's drop. Answers the action to report to the
1926
+ * source, or null to refuse it: at once for a drag from this app, which
1927
+ * core settles before the source's `onDragEnd` runs, and after a read for
1928
+ * HTML from another application.
1929
+ */
1930
+ drop(ev: DropLike): DropAnswer | Promise<DropAnswer> {
1931
+ this.setDropAt(null);
1932
+ if (!this.editable || this.destroyedFlag) return null;
1933
+ const payload = ev.items?.[SLICE_TYPE] as DragPayload | null | undefined;
1934
+ if (payload?.slice) return this.dropSlice(ev, payload);
1935
+ if (!ev.files.length && ev.types.includes('text/html')) {
1936
+ return ev.getData('text/html').then(
1937
+ (got) => this.dropRead(ev, textOf(got)),
1938
+ () => this.dropRead(ev, null),
1939
+ );
1940
+ }
1941
+ return this.dropRead(ev, null);
1942
+ }
1943
+
1944
+ /** A slice out of an editor in this app — this one, or another. */
1945
+ private dropSlice(ev: DropLike, payload: DragPayload): DropAnswer {
1946
+ const hit = this.hit(ev.x, ev.y);
1947
+ if (!hit) return null;
1948
+ const copy =
1949
+ payload.copy ?? (this.primary === 'meta' ? ev.altKey : ev.ctrlKey);
1950
+ const own = payload.view === this;
1951
+ const moved = own && !copy && !!this.draggingNow?.move;
1952
+ let slice = payload.slice;
1953
+ this.someProp('transformPasted', (f) => {
1954
+ slice = f(slice, this.asEditorView, false);
1955
+ });
1956
+ // a drop back in here is the whole of the move: the drag's end has
1957
+ // nothing left to take out
1958
+ if (own) this.draggingNow = null;
1959
+ const answer: DropAnswer = copy ? 'copy' : 'move';
1960
+ if (
1961
+ this.someProp('handleDrop', (f) =>
1962
+ f(this.asEditorView, domDrop(ev, null, null), slice, moved),
1963
+ )
1964
+ )
1965
+ return answer;
1966
+ this.insertDrop(slice, hit.pos, moved);
1967
+ return answer;
1968
+ }
1969
+
1970
+ /** Text, HTML, links or files from another application — or from an
1971
+ * element of this one that is not an editor. */
1972
+ private dropRead(ev: DropLike, html: string | null): DropAnswer {
1973
+ if (this.destroyedFlag || !this.editable) return null;
1974
+ const hit = this.hit(ev.x, ev.y);
1975
+ if (!hit) return null;
1976
+ const $at = this.state.doc.resolve(hit.pos);
1977
+ const files = ev.files;
1978
+ // with files on offer, `text` is the URI list itself
1979
+ const text = files.length ? null : (ev.text ?? null);
1980
+ const slice =
1981
+ files.length && !html
1982
+ ? filesSlice(this.state.schema, files, $at)
1983
+ : parseFromClipboard(this, text, html, false, $at);
1984
+ if (
1985
+ this.someProp('handleDrop', (f) =>
1986
+ f(
1987
+ this.asEditorView,
1988
+ domDrop(ev, html, text),
1989
+ slice ?? Slice.empty,
1990
+ false,
1991
+ ),
1992
+ )
1993
+ )
1994
+ return 'copy';
1995
+ if (!slice) return null;
1996
+ return this.insertDrop(slice, hit.pos, false) ? 'copy' : null;
1997
+ }
1998
+
1999
+ private insertDrop(slice: Slice, at: number, moved: boolean): boolean {
2000
+ const tr = dropTransaction(this.state, slice, at, moved);
2001
+ if (!tr) return false;
2002
+ this.userInput++;
2003
+ try {
2004
+ this.dispatch(tr.scrollIntoView());
2005
+ } finally {
2006
+ this.userInput--;
2007
+ }
2008
+ this.focus();
2009
+ return true;
2010
+ }
2011
+
2012
+ /** Right-click: core's standard edit menu, with the verbs this editor has.
2013
+ * A read-only editor offers Copy and Select All and nothing else — a verb
2014
+ * left out is a row that is not there (react-x11 docs/extending.md). */
2015
+ contextMenu(ev: X11MouseEvent): void {
2016
+ const root = this.root as unknown as DrawnNode | null;
2017
+ if (!root) return;
2018
+ const sel = this.state.selection;
2019
+ const editable = this.editable;
2020
+ openEditMenu(
2021
+ root,
2022
+ { x: ev.x, y: ev.y },
2023
+ {
2024
+ hasSelection: !sel.empty,
2025
+ ...(editable
2026
+ ? {
2027
+ canUndo: undoDepth(this.state) > 0,
2028
+ undo: () => this.run(undo),
2029
+ canRedo: redoDepth(this.state) > 0,
2030
+ redo: () => this.run(redo),
2031
+ cut: () => this.copy(true),
2032
+ paste: () => void this.paste(false),
2033
+ }
2034
+ : null),
2035
+ copy: () => this.copy(false),
2036
+ canSelectAll: !(sel instanceof AllSelection),
2037
+ selectAll: () =>
2038
+ this.dispatch(
2039
+ this.state.tr.setSelection(new AllSelection(this.state.doc)),
2040
+ ),
2041
+ },
2042
+ );
2043
+ }
2044
+
2045
+ // --- the clipboard -----------------------------------------------------------------
2046
+
2047
+ /** Copy (or cut) the selection: HTML, plain text, and the slice kept in
2048
+ * this process for a paste back — see `./clipboard.ts`. */
2049
+ copy(cut: boolean): void {
2050
+ const sel = this.state.selection;
2051
+ if (sel.empty) return;
2052
+ const { html, text, slice } = serializeForClipboard(this, sel.content());
2053
+ this.lastCopy = { text, slice };
2054
+ this.config.clipboard
2055
+ ?.write({
2056
+ 'text/html': html,
2057
+ 'text/plain;charset=utf-8': text,
2058
+ 'text/plain': text,
2059
+ UTF8_STRING: text,
2060
+ STRING: text,
2061
+ })
2062
+ .catch(() => {});
2063
+ if (cut && this.editable) {
2064
+ this.dispatch(
2065
+ this.state.tr
2066
+ .deleteSelection()
2067
+ .scrollIntoView()
2068
+ .setMeta('uiEvent', 'cut'),
2069
+ );
2070
+ }
2071
+ }
2072
+
2073
+ /** Paste from the clipboard (or from X11's PRIMARY): its HTML when it has
2074
+ * some and the paste is not plain, its text otherwise. */
2075
+ async paste(plain: boolean, selection?: 'PRIMARY'): Promise<void> {
2076
+ const clipboard = this.config.clipboard;
2077
+ if (!clipboard || !this.editable) return;
2078
+ const options = selection ? { selection } : undefined;
2079
+ const [html, text] = await Promise.all([
2080
+ plain ? null : clipboard.read('text/html', options).catch(() => null),
2081
+ clipboard.read('text', options).catch(() => null),
2082
+ ]);
2083
+ if (this.destroyedFlag) return;
2084
+ this.pasteContent(
2085
+ typeof text === 'string' ? text : null,
2086
+ typeof html === 'string' && html ? html : null,
2087
+ plain,
2088
+ );
2089
+ }
2090
+
2091
+ /** ProseMirror's `pasteText`. */
2092
+ pasteText(text: string): boolean {
2093
+ return this.pasteContent(text, null, true);
2094
+ }
2095
+
2096
+ /** ProseMirror's `pasteHTML`. */
2097
+ pasteHTML(html: string): boolean {
2098
+ return this.pasteContent(null, html, false);
2099
+ }
2100
+
2101
+ private pasteContent(
2102
+ text: string | null,
2103
+ html: string | null,
2104
+ plain: boolean,
2105
+ ): boolean {
2106
+ const own = this.lastCopy;
2107
+ const slice =
2108
+ own && text !== null && text === own.text && !plain
2109
+ ? own.slice
2110
+ : parseFromClipboard(
2111
+ this,
2112
+ text,
2113
+ html,
2114
+ plain,
2115
+ this.state.selection.$from,
2116
+ );
2117
+ if (!slice) return false;
2118
+ const event = {
2119
+ clipboardData: {
2120
+ getData: (type: string) =>
2121
+ type === 'text/html'
2122
+ ? (html ?? '')
2123
+ : type === 'text/plain' || type === 'text'
2124
+ ? (text ?? '')
2125
+ : '',
2126
+ types: [
2127
+ ...(html ? ['text/html'] : []),
2128
+ ...(text !== null ? ['text/plain'] : []),
2129
+ ],
2130
+ },
2131
+ preventDefault() {},
2132
+ } as unknown as ClipboardEvent;
2133
+ this.userInput++;
2134
+ try {
2135
+ if (
2136
+ this.someProp('handlePaste', (f) => f(this.asEditorView, event, slice))
2137
+ )
2138
+ return true;
2139
+ const single = sliceSingleNode(slice);
2140
+ const tr = single
2141
+ ? this.state.tr.replaceSelectionWith(single, plain)
2142
+ : this.state.tr.replaceSelection(slice);
2143
+ this.dispatch(
2144
+ tr.scrollIntoView().setMeta('paste', true).setMeta('uiEvent', 'paste'),
2145
+ );
2146
+ } finally {
2147
+ this.userInput--;
2148
+ }
2149
+ return true;
2150
+ }
2151
+
2152
+ /** X11's PRIMARY: whatever is selected is pasteable with the middle
2153
+ * button, anywhere. */
2154
+ private ownPrimary(): void {
2155
+ const sel = this.state.selection;
2156
+ if (sel.empty || !this.config.clipboard) return;
2157
+ const { text } = serializeForClipboard(this, sel.content());
2158
+ this.config.clipboard.write(text, { selection: 'PRIMARY' }).catch(() => {});
2159
+ }
2160
+
2161
+ // --- scrolling -------------------------------------------------------------------
2162
+
2163
+ private scheduleScroll(): void {
2164
+ cancelAfterLayout(this.scrollTick);
2165
+ this.scrollTick = afterLayout(() => {
2166
+ this.scrollTick = null;
2167
+ this.scrollToSelection();
2168
+ });
2169
+ }
2170
+
2171
+ /** Bring the selection's head into the viewport — after the layout the
2172
+ * change caused, which is the first moment it has a place. */
2173
+ scrollToSelection(): void {
2174
+ if (this.destroyedFlag) return;
2175
+ if (this.someProp('handleScrollToSelection', (f) => f(this.asEditorView)))
2176
+ return;
2177
+ const box = this.scroller;
2178
+ const viewport = box?.getClientRects()[0];
2179
+ if (!box || !viewport) return;
2180
+ const head = this.state.selection.head;
2181
+ if (!this.isDrawnAt(head)) {
2182
+ // scrolled out of a long document's window: its block is drawn
2183
+ // first, and the caret brought into view once it is laid out
2184
+ this.blockWindow?.reveal(this.topIndexOf(head), () =>
2185
+ this.scrollToSelection(),
2186
+ );
2187
+ return;
2188
+ }
2189
+ const r = this.coordsAtPos(head);
2190
+ const margin = 8;
2191
+ let dy = 0;
2192
+ if (r.top < viewport.y + margin) dy = r.top - viewport.y - margin;
2193
+ else if (r.bottom > viewport.y + viewport.height - margin) {
2194
+ dy = r.bottom - (viewport.y + viewport.height) + margin;
2195
+ }
2196
+ if (dy) box.scrollBy({ y: dy });
2197
+ }
2198
+
2199
+ // --- accessibility -----------------------------------------------------------------
2200
+
2201
+ private textOffset(doc: PMNode, pos: number): number {
2202
+ return [
2203
+ ...doc.textBetween(
2204
+ 0,
2205
+ Math.max(0, Math.min(pos, doc.content.size)),
2206
+ '\n',
2207
+ leafText,
2208
+ ),
2209
+ ].length;
2210
+ }
2211
+
2212
+ /** The document as a screen reader reads it: one text, a line per block,
2213
+ * the caret and selection in code points of it. */
2214
+ a11yText(): A11yTextState | null {
2215
+ const { state } = this;
2216
+ if (this.a11yCache?.state === state) return this.a11yCache.value;
2217
+ const { doc, selection } = state;
2218
+ const value: A11yTextState = {
2219
+ value: doc.textBetween(0, doc.content.size, '\n', leafText),
2220
+ caret: this.textOffset(doc, selection.head),
2221
+ selectionStart: this.textOffset(doc, selection.from),
2222
+ selectionEnd: this.textOffset(doc, selection.to),
2223
+ editable: this.editable,
2224
+ multiline: true,
2225
+ };
2226
+ this.a11yCache = { state, value };
2227
+ return value;
2228
+ }
2229
+
2230
+ /** The document position at a code-point offset of `a11yText().value`. */
2231
+ private posAtTextOffset(offset: number): number {
2232
+ const { doc } = this.state;
2233
+ let remaining = offset;
2234
+ let result: number | null = null;
2235
+ let first = true;
2236
+ doc.descendants((node, pos) => {
2237
+ if (result !== null) return false;
2238
+ if (!node.isTextblock) return true;
2239
+ if (!first) {
2240
+ if (remaining === 0) {
2241
+ result = pos - 1 >= 0 ? pos - 1 : pos;
2242
+ return false;
2243
+ }
2244
+ remaining -= 1; // the line break between blocks
2245
+ }
2246
+ first = false;
2247
+ let inner = pos + 1;
2248
+ node.forEach((child) => {
2249
+ if (result !== null) return;
2250
+ const text = child.isText ? (child.text ?? '') : leafText(child);
2251
+ const cps = [...text];
2252
+ if (remaining <= cps.length) {
2253
+ result = child.isText
2254
+ ? inner + cps.slice(0, remaining).join('').length
2255
+ : inner + (remaining > 0 ? child.nodeSize : 0);
2256
+ return;
2257
+ }
2258
+ remaining -= cps.length;
2259
+ inner += child.nodeSize;
2260
+ });
2261
+ if (result === null && remaining === 0)
2262
+ result = pos + 1 + node.content.size;
2263
+ return false;
2264
+ });
2265
+ return result ?? doc.content.size;
2266
+ }
2267
+
2268
+ a11ySelect(start: number, end: number): boolean {
2269
+ const { doc } = this.state;
2270
+ const from = this.posAtTextOffset(start);
2271
+ const to = this.posAtTextOffset(end);
2272
+ this.dispatch(
2273
+ this.state.tr.setSelection(
2274
+ TextSelection.between(doc.resolve(from), doc.resolve(to)),
2275
+ ),
2276
+ );
2277
+ return true;
2278
+ }
2279
+
2280
+ a11yReplace(start: number, end: number, text: string): boolean {
2281
+ if (!this.editable) return false;
2282
+ const from = this.posAtTextOffset(start);
2283
+ const to = this.posAtTextOffset(end);
2284
+ this.dispatch(this.state.tr.insertText(text, from, to).scrollIntoView());
2285
+ return true;
2286
+ }
2287
+ }