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