@fusion.dev/helix-editor 0.220.0 → 0.221.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 +133 -32
- package/dist/block-ids/block-ids.d.ts +36 -0
- package/dist/block-ids/block-ids.js +66 -0
- package/dist/connect-editor/connect-editor.d.ts +65 -0
- package/dist/connect-editor/connect-editor.js +142 -0
- package/dist/find/find.d.ts +74 -0
- package/dist/find/find.js +143 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +7 -1
- package/dist/plugins/plugins.d.ts +33 -0
- package/dist/plugins/plugins.js +102 -0
- package/dist/schema/schema.d.ts +53 -0
- package/dist/schema/schema.js +292 -0
- package/dist/sheet/editor-sheet.d.ts +17 -0
- package/dist/sheet/editor-sheet.js +123 -0
- package/dist/tools/tools.d.ts +53 -0
- package/dist/tools/tools.js +242 -0
- package/package.json +25 -23
- package/dist/codemirror/codemirror-engine.d.ts +0 -25
- package/dist/codemirror/codemirror-engine.js +0 -370
- package/dist/codemirror/index.d.ts +0 -1
- package/dist/codemirror/index.js +0 -1
package/README.md
CHANGED
|
@@ -1,50 +1,151 @@
|
|
|
1
1
|
# @fusion.dev/helix-editor
|
|
2
2
|
|
|
3
|
-
Connects Helix
|
|
4
|
-
|
|
5
|
-
matching and history, diagnostics underlined in their tone with their line
|
|
6
|
-
numbers drawn in it, and the editor's own find bar finding and replacing in
|
|
7
|
-
CodeMirror.
|
|
3
|
+
Connects the Helix `Editor` frame to ProseMirror: a schema whose blocks each
|
|
4
|
+
keep an id, the editing plugins, and a document drawn in your tokens.
|
|
8
5
|
|
|
9
6
|
```sh
|
|
10
|
-
bun add @fusion.dev/helix @fusion.dev/helix-editor
|
|
7
|
+
bun add @fusion.dev/helix @fusion.dev/helix-editor prosemirror-model prosemirror-state prosemirror-view
|
|
11
8
|
```
|
|
12
9
|
|
|
13
|
-
The element is on the [
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
The element is on the [Editor reference](https://fusion.dev/helix/components/editor),
|
|
11
|
+
with its toolbar, its keys, and its parts.
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<helix-editor label="Release notes" placeholder="Write the release notes">
|
|
15
|
+
<div data-prose><h2>Release notes</h2><p>Write documents in your theme.</p></div>
|
|
16
|
+
</helix-editor>
|
|
17
|
+
```
|
|
16
18
|
|
|
17
19
|
```js
|
|
18
|
-
import '@fusion.dev/helix/
|
|
19
|
-
import { connectEditor } from '@fusion.dev/helix-editor'
|
|
20
|
+
import '@fusion.dev/helix/editor'
|
|
21
|
+
import { connectEditor, editorPlugins, editorSchema } from '@fusion.dev/helix-editor'
|
|
22
|
+
import { DOMParser } from 'prosemirror-model'
|
|
23
|
+
import { EditorState } from 'prosemirror-state'
|
|
24
|
+
import { EditorView } from 'prosemirror-view'
|
|
25
|
+
|
|
26
|
+
const frame = document.querySelector('helix-editor')
|
|
27
|
+
const content = frame.querySelector('[data-prose]')
|
|
28
|
+
const schema = editorSchema()
|
|
20
29
|
|
|
21
|
-
const
|
|
22
|
-
|
|
30
|
+
const view = new EditorView(
|
|
31
|
+
{ mount: content },
|
|
32
|
+
{
|
|
33
|
+
state: EditorState.create({
|
|
34
|
+
doc: DOMParser.fromSchema(schema).parse(content),
|
|
35
|
+
plugins: editorPlugins(schema),
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
const connection = connectEditor(frame, view)
|
|
23
40
|
|
|
24
|
-
editor
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
// Later, when the editor goes away:
|
|
42
|
+
connection.disconnect()
|
|
43
|
+
view.destroy()
|
|
27
44
|
```
|
|
28
45
|
|
|
29
|
-
The
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
**The document is your element, inside the frame.** ProseMirror draws in it,
|
|
47
|
+
so the browser's input methods, spellcheck, and right-to-left text work as they
|
|
48
|
+
do in any field, and a server can send the document's HTML there to be read
|
|
49
|
+
before any script loads. `data-prose` on it has the base layer draw its blocks
|
|
50
|
+
from the first paint. `connectEditor` adds the rules ProseMirror needs to
|
|
51
|
+
the page, so you load no stylesheet of ProseMirror's.
|
|
52
|
+
|
|
53
|
+
**ProseMirror is a peer dependency.** ProseMirror's packages check each
|
|
54
|
+
other's classes, so a page has one copy of `prosemirror-model`,
|
|
55
|
+
`prosemirror-state`, `prosemirror-transform`, and `prosemirror-view`, and this
|
|
56
|
+
package uses yours.
|
|
57
|
+
|
|
58
|
+
## What it exports
|
|
59
|
+
|
|
60
|
+
| Export | What |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `connectEditor(frame, view, options)` | Connects a frame to a view, and returns a connection with `disconnect()` |
|
|
63
|
+
| `editorSchema(options)` | The schema: Helix's blocks and marks, with your own added through `nodes` and `marks` |
|
|
64
|
+
| `editorPlugins(schema, options)` | The keys, Markdown typed at the start of a line, history, the drop and gap cursors, `blockIds`, `editorFind`, and `editorFrame` |
|
|
65
|
+
| `blockIds(options)` | Keeps every block's id its own |
|
|
66
|
+
| `findBlock(doc, id)` | Where a block stands, by its id |
|
|
67
|
+
| `withBlockId(spec)` | A block of your own that keeps an id as Helix's do |
|
|
68
|
+
| `toolCommands(schema, overrides)` | The command each toolbar tool runs |
|
|
69
|
+
| `formatsOf(state, commands)` | What the toolbar shows for a state |
|
|
70
|
+
| `setLink(schema, href)` | Links the selection, or with an empty address takes its link off |
|
|
71
|
+
| `safeHref(href)` | The address a link may have, or an empty string for one that could run something |
|
|
72
|
+
| `editorFrame()` | The plugin the frame speaks to the view through, which `editorPlugins` includes |
|
|
73
|
+
| `editorFind()` | The find bar's search in the document, which `editorPlugins` includes |
|
|
74
|
+
| `findIn(view, query, direction)`, `replaceIn(view, query, all)`, `endFind(view)` | What the frame's find bar runs: find and select a match, replace, and take the marks away |
|
|
75
|
+
| `matchesIn(doc, query)`, `findResultOf(state)` | Where a query matches in a document, and where an open search stands |
|
|
76
|
+
| `editorNodes`, `editorMarks` | Helix's node and mark specs |
|
|
77
|
+
| `EDITOR_CSS`, `adoptEditorSheet(root)` | The rules `connectEditor` adds to the page |
|
|
78
|
+
|
|
79
|
+
| Option | Of | What it does |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `commands` | `connectEditor`, `editorPlugins` | Runs your commands in place of Helix's, by tool, such as undo and redo from y-prosemirror |
|
|
82
|
+
| `history` | `editorPlugins` | `false` leaves the undo history to another plugin |
|
|
83
|
+
| `markdown` | `editorPlugins` | `false` turns off `# `, `- `, `1. `, `> `, and three backticks at the start of a line |
|
|
84
|
+
| `marks`, `nodes` | `editorSchema` | Marks and nodes of your own, or one of Helix's replaced by its name |
|
|
85
|
+
| `generate` | `blockIds` | Makes a new id. Twelve letters and digits by default |
|
|
86
|
+
|
|
87
|
+
## Blocks and their ids
|
|
88
|
+
|
|
89
|
+
The schema keeps ProseMirror's own names: `paragraph`, `heading`,
|
|
90
|
+
`blockquote`, `code_block`, `horizontal_rule`, `bullet_list`, `ordered_list`,
|
|
91
|
+
`list_item`, and `hard_break`, with the marks `link`, `em`, `strong`, `strike`,
|
|
92
|
+
and `code`. Markdown reads into it and writes out of it through
|
|
93
|
+
`prosemirror-markdown` as it is.
|
|
94
|
+
|
|
95
|
+
Every block carries an `id`, written to `data-block-id` in HTML. `blockIds`
|
|
96
|
+
gives an id to a block that has none, and the second of two blocks sharing one
|
|
97
|
+
a new one, as a split or a paste leaves them. `findBlock(doc, id)` finds a
|
|
98
|
+
block again, for a comment anchored to it or an edit an agent makes to it.
|
|
99
|
+
|
|
100
|
+
A paragraph and a heading are drawn with `dir="auto"`, so each runs in the
|
|
101
|
+
direction of its own first letters, and a code block left to right.
|
|
32
102
|
|
|
33
|
-
##
|
|
103
|
+
## Find
|
|
34
104
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
105
|
+
The frame's find bar searches the document through `connectEditor`: every
|
|
106
|
+
match is marked, the selected one is scrolled into view, and Replace and
|
|
107
|
+
Replace all change the document in one step of the undo history. A match never
|
|
108
|
+
runs from one block into the next. While the bar is open, a change to the
|
|
109
|
+
document finds again, and the bar says the new count. A read-only document is
|
|
110
|
+
searched, and nothing in it is replaced.
|
|
39
111
|
|
|
40
|
-
##
|
|
112
|
+
## Collaboration
|
|
41
113
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
114
|
+
The page makes the editor's state, so a binding such as y-prosemirror is
|
|
115
|
+
plugins the page adds. Its own history replaces ProseMirror's:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
import { redoCommand, undoCommand, yCursorPlugin, ySyncPlugin, yUndoPlugin } from 'y-prosemirror'
|
|
119
|
+
|
|
120
|
+
const commands = { redo: redoCommand, undo: undoCommand }
|
|
121
|
+
const state = EditorState.create({
|
|
122
|
+
schema,
|
|
123
|
+
plugins: [
|
|
124
|
+
ySyncPlugin(fragment),
|
|
125
|
+
yCursorPlugin(awareness),
|
|
126
|
+
yUndoPlugin(),
|
|
127
|
+
...editorPlugins(schema, { commands, history: false }),
|
|
128
|
+
],
|
|
129
|
+
})
|
|
130
|
+
connectEditor(frame, new EditorView({ mount: content }, { state }), { commands })
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**A command is also asked without `dispatch`**, after every change, to know
|
|
134
|
+
whether its button can be pressed, so it has to change nothing then. That is
|
|
135
|
+
why the example takes y-prosemirror's `undoCommand` and `redoCommand`: its
|
|
136
|
+
`undo` and `redo` undo whenever they are called.
|
|
137
|
+
|
|
138
|
+
Block ids are attributes of the blocks, so the binding merges them with the
|
|
139
|
+
rest of the document.
|
|
140
|
+
|
|
141
|
+
## Working on it
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
bun run --filter @fusion.dev/helix-editor build
|
|
145
|
+
bun run --filter @fusion.dev/helix-editor test
|
|
146
|
+
```
|
|
46
147
|
|
|
47
|
-
|
|
148
|
+
The specs run ProseMirror's own view, under `bun test` and in a browser with
|
|
149
|
+
`bun run specs:browser editor`, where an input method's composition reaches it.
|
|
48
150
|
|
|
49
|
-
|
|
50
|
-
Windows and Linux, opens the editor's find bar, and Command+Z undoes.
|
|
151
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Node } from 'prosemirror-model';
|
|
2
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
3
|
+
/**
|
|
4
|
+
* A new block id: twelve letters and digits, from the platform's random
|
|
5
|
+
* source.
|
|
6
|
+
*
|
|
7
|
+
* **`getRandomValues` and not `randomUUID`**, which a page served over plain
|
|
8
|
+
* HTTP from anywhere but `localhost` does not have.
|
|
9
|
+
*/
|
|
10
|
+
export declare const newBlockId: () => string;
|
|
11
|
+
/** The key the block id plugin is found under. */
|
|
12
|
+
export declare const blockIdsKey: PluginKey<any>;
|
|
13
|
+
/** What `blockIds` takes. */
|
|
14
|
+
export interface BlockIdsOptions {
|
|
15
|
+
/** Makes a new id. `newBlockId` by default. */
|
|
16
|
+
generate?: (() => string) | undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Keeps every block's id its own: a block with none is given one, and where
|
|
20
|
+
* a split or a paste leaves two blocks sharing an id, the second is given a
|
|
21
|
+
* new one, so the first, the block that was there, keeps it.
|
|
22
|
+
*
|
|
23
|
+
* Ids are given in the same step as the edit that called for them, so undo
|
|
24
|
+
* takes both back together. A document loaded without ids is given them as
|
|
25
|
+
* the view mounts, outside history, so undo never takes them away.
|
|
26
|
+
*
|
|
27
|
+
* **Under collaboration each writer gives ids for its own edits.** Ids are
|
|
28
|
+
* attributes of the blocks, which a binding such as y-prosemirror merges as it
|
|
29
|
+
* merges the rest, so every copy settles on the same id for a block.
|
|
30
|
+
*/
|
|
31
|
+
export declare const blockIds: (options?: BlockIdsOptions) => Plugin;
|
|
32
|
+
/** Where a block stands in the document, by its id, or nothing where no block has it. */
|
|
33
|
+
export declare const findBlock: (doc: Node, id: string) => {
|
|
34
|
+
node: Node;
|
|
35
|
+
pos: number;
|
|
36
|
+
} | undefined;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
2
|
+
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
3
|
+
const LENGTH = 12;
|
|
4
|
+
export const newBlockId = () => {
|
|
5
|
+
const bytes = crypto.getRandomValues(new Uint8Array(LENGTH));
|
|
6
|
+
let id = '';
|
|
7
|
+
for (const byte of bytes) {
|
|
8
|
+
id += ALPHABET[byte % ALPHABET.length];
|
|
9
|
+
}
|
|
10
|
+
return id;
|
|
11
|
+
};
|
|
12
|
+
const keepsId = (node) => 'id' in (node.type.spec.attrs ?? {});
|
|
13
|
+
const fixIds = (state, generate) => {
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
let tr = null;
|
|
16
|
+
state.doc.descendants((node, pos) => {
|
|
17
|
+
if (keepsId(node)) {
|
|
18
|
+
const id = node.attrs.id;
|
|
19
|
+
if (typeof id !== 'string' || id === '' || seen.has(id)) {
|
|
20
|
+
let next = generate();
|
|
21
|
+
while (seen.has(next)) {
|
|
22
|
+
next = generate();
|
|
23
|
+
}
|
|
24
|
+
seen.add(next);
|
|
25
|
+
tr ??= state.tr;
|
|
26
|
+
tr.setNodeAttribute(pos, 'id', next);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
seen.add(id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return !node.isTextblock;
|
|
33
|
+
});
|
|
34
|
+
return tr;
|
|
35
|
+
};
|
|
36
|
+
export const blockIdsKey = new PluginKey('helix-block-ids');
|
|
37
|
+
export const blockIds = (options = {}) => {
|
|
38
|
+
const generate = options.generate ?? newBlockId;
|
|
39
|
+
return new Plugin({
|
|
40
|
+
appendTransaction: (transactions, _before, state) => transactions.some((tr) => tr.docChanged) ? fixIds(state, generate) : null,
|
|
41
|
+
key: blockIdsKey,
|
|
42
|
+
view: (view) => {
|
|
43
|
+
queueMicrotask(() => {
|
|
44
|
+
const tr = view.isDestroyed ? null : fixIds(view.state, generate);
|
|
45
|
+
if (tr) {
|
|
46
|
+
view.dispatch(tr.setMeta('addToHistory', false));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return {};
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
export const findBlock = (doc, id) => {
|
|
54
|
+
let found;
|
|
55
|
+
doc.descendants((node, pos) => {
|
|
56
|
+
if (found) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (keepsId(node) && node.attrs.id === id) {
|
|
60
|
+
found = { node, pos };
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return !node.isTextblock;
|
|
64
|
+
});
|
|
65
|
+
return found;
|
|
66
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Command, Plugin, PluginKey } from 'prosemirror-state';
|
|
2
|
+
import { type EditorView } from 'prosemirror-view';
|
|
3
|
+
import { type EditorTool } from '../tools/tools.js';
|
|
4
|
+
/** What the frame tells a renderer, as the frame's settings stand. */
|
|
5
|
+
interface Settings {
|
|
6
|
+
label: string;
|
|
7
|
+
placeholder: string;
|
|
8
|
+
readOnly: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** What the bridge holds: the frame's settings, and whether a frame is connected. */
|
|
11
|
+
interface Bridge extends Settings {
|
|
12
|
+
connected: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** The key the frame's plugin is found under. */
|
|
15
|
+
export declare const editorFrameKey: PluginKey<Bridge>;
|
|
16
|
+
/**
|
|
17
|
+
* The plugin the frame speaks to the view through: it names the document for
|
|
18
|
+
* a screen reader, makes it read-only, shows the placeholder, and hears every
|
|
19
|
+
* update. `editorPlugins` includes it, so connecting changes nothing about
|
|
20
|
+
* the view's plugins.
|
|
21
|
+
*
|
|
22
|
+
* **Included from the start where it can be**, because adding a plugin later
|
|
23
|
+
* remakes every plugin's view, and a collaboration binding draws the whole
|
|
24
|
+
* document again when its view is remade. `connectEditor` adds it only to a
|
|
25
|
+
* view whose plugins were chosen without it.
|
|
26
|
+
*
|
|
27
|
+
* Every document carries `data-prose`, which the base layer draws its blocks
|
|
28
|
+
* by. A document is a `textbox` that takes several lines while a frame is
|
|
29
|
+
* connected, named by the frame's `label`.
|
|
30
|
+
*/
|
|
31
|
+
export declare const editorFrame: () => Plugin<Bridge>;
|
|
32
|
+
/** What a connection takes beyond the frame and the view. */
|
|
33
|
+
export interface ConnectEditorOptions {
|
|
34
|
+
/**
|
|
35
|
+
* The commands to run in place of Helix's, by tool, such as undo and redo
|
|
36
|
+
* where y-prosemirror keeps the history. Pass the same to `editorPlugins`,
|
|
37
|
+
* so the keys run them too.
|
|
38
|
+
*
|
|
39
|
+
* Each is also asked with no `dispatch` after every change, to know whether
|
|
40
|
+
* its button can be pressed, and has to change nothing then: y-prosemirror's
|
|
41
|
+
* `undoCommand` and `redoCommand`, and never its `undo` and `redo`, which
|
|
42
|
+
* undo whenever they are called.
|
|
43
|
+
*/
|
|
44
|
+
commands?: Partial<Record<EditorTool, Command>> | undefined;
|
|
45
|
+
}
|
|
46
|
+
/** A connection, which is ended rather than left to leak. */
|
|
47
|
+
export interface EditorConnection {
|
|
48
|
+
/** Stops answering the frame, detaches the view from it, and puts back what connecting changed. */
|
|
49
|
+
disconnect(): void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Connects an Editor frame to a ProseMirror view.
|
|
53
|
+
*
|
|
54
|
+
* The frame's toolbar runs its tools in the view and shows what holds at the
|
|
55
|
+
* selection; the frame's find bar searches the document, and replaces in it
|
|
56
|
+
* unless it is read-only; the frame's `label`, `placeholder`, and `readonly`
|
|
57
|
+
* reach the document; and the editor's rules are added to the page, so the
|
|
58
|
+
* document is drawn in your tokens.
|
|
59
|
+
*
|
|
60
|
+
* The frame is `helix-editor` or `helix-headless-editor`, and the view's
|
|
61
|
+
* schema is `editorSchema()` or one of your own: a tool whose node or mark
|
|
62
|
+
* the schema lacks is left off the toolbar.
|
|
63
|
+
*/
|
|
64
|
+
export declare const connectEditor: (frame: HTMLElement, view: EditorView, options?: ConnectEditorOptions) => EditorConnection;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
2
|
+
import { Decoration, DecorationSet } from 'prosemirror-view';
|
|
3
|
+
import { editorFind, editorFindKey, endFind, findIn, findResultOf, replaceIn, } from '../find/find.js';
|
|
4
|
+
import { adoptEditorSheet } from '../sheet/editor-sheet.js';
|
|
5
|
+
import { EDITOR_TOOLS, formatsOf, setLink, toolCommands, } from '../tools/tools.js';
|
|
6
|
+
const isFrame = (element) => typeof element.attach === 'function';
|
|
7
|
+
const UNCONNECTED = { connected: false, label: '', placeholder: '', readOnly: false };
|
|
8
|
+
export const editorFrameKey = new PluginKey('helix-editor-frame');
|
|
9
|
+
const listeners = new WeakMap();
|
|
10
|
+
const placeholderOf = (state, text) => {
|
|
11
|
+
const { doc } = state;
|
|
12
|
+
const first = doc.firstChild;
|
|
13
|
+
if (text === '' || doc.childCount !== 1 || !first?.isTextblock || first.content.size > 0) {
|
|
14
|
+
return DecorationSet.empty;
|
|
15
|
+
}
|
|
16
|
+
return DecorationSet.create(doc, [
|
|
17
|
+
Decoration.node(0, first.nodeSize, {
|
|
18
|
+
class: 'helix-editor-placeholder',
|
|
19
|
+
'data-placeholder': text,
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
};
|
|
23
|
+
export const editorFrame = () => new Plugin({
|
|
24
|
+
key: editorFrameKey,
|
|
25
|
+
props: {
|
|
26
|
+
attributes: (state) => {
|
|
27
|
+
const bridge = editorFrameKey.getState(state) ?? UNCONNECTED;
|
|
28
|
+
if (!bridge.connected) {
|
|
29
|
+
return { 'data-prose': '' };
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
...(bridge.label ? { 'aria-label': bridge.label } : {}),
|
|
33
|
+
'aria-multiline': 'true',
|
|
34
|
+
...(bridge.placeholder ? { 'aria-placeholder': bridge.placeholder } : {}),
|
|
35
|
+
...(bridge.readOnly ? { 'aria-readonly': 'true' } : {}),
|
|
36
|
+
'data-prose': '',
|
|
37
|
+
role: 'textbox',
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
decorations: (state) => {
|
|
41
|
+
const bridge = editorFrameKey.getState(state);
|
|
42
|
+
return bridge?.connected ? placeholderOf(state, bridge.placeholder) : DecorationSet.empty;
|
|
43
|
+
},
|
|
44
|
+
editable: (state) => !editorFrameKey.getState(state)?.readOnly,
|
|
45
|
+
},
|
|
46
|
+
state: {
|
|
47
|
+
apply: (tr, bridge) => tr.getMeta(editorFrameKey) ?? bridge,
|
|
48
|
+
init: () => UNCONNECTED,
|
|
49
|
+
},
|
|
50
|
+
view: () => ({
|
|
51
|
+
update: (view) => listeners.get(view)?.(view.state),
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
export const connectEditor = (frame, view, options = {}) => {
|
|
55
|
+
if (!isFrame(frame)) {
|
|
56
|
+
throw new Error(`connectEditor: <${frame.localName}> is not an editor frame. Import @fusion.dev/helix/editor, and connect once the element is defined.`);
|
|
57
|
+
}
|
|
58
|
+
const { schema } = view.state;
|
|
59
|
+
const commands = toolCommands(schema, options.commands);
|
|
60
|
+
const runs = EDITOR_TOOLS.filter((tool) => tool === 'link' ? schema.marks.link !== undefined : commands[tool] !== undefined);
|
|
61
|
+
const added = [
|
|
62
|
+
...(editorFrameKey.get(view.state) ? [] : [editorFrame()]),
|
|
63
|
+
...(editorFindKey.get(view.state) ? [] : [editorFind()]),
|
|
64
|
+
];
|
|
65
|
+
if (added.length > 0) {
|
|
66
|
+
view.updateState(view.state.reconfigure({ plugins: [...view.state.plugins, ...added] }));
|
|
67
|
+
}
|
|
68
|
+
adoptEditorSheet(view.root);
|
|
69
|
+
let sink;
|
|
70
|
+
let told = '';
|
|
71
|
+
let toldFound = '';
|
|
72
|
+
let answering = false;
|
|
73
|
+
const tell = (state) => {
|
|
74
|
+
const formats = formatsOf(state, commands);
|
|
75
|
+
const said = JSON.stringify(formats);
|
|
76
|
+
if (sink && said !== told) {
|
|
77
|
+
told = said;
|
|
78
|
+
sink.formats(formats);
|
|
79
|
+
}
|
|
80
|
+
const found = findResultOf(state);
|
|
81
|
+
const where = found ? `${found.count} ${found.index}` : '';
|
|
82
|
+
if (sink && found && where !== toldFound && !answering) {
|
|
83
|
+
sink.found(found);
|
|
84
|
+
}
|
|
85
|
+
toldFound = where;
|
|
86
|
+
};
|
|
87
|
+
const answer = (search) => {
|
|
88
|
+
answering = true;
|
|
89
|
+
try {
|
|
90
|
+
return search();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
answering = false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
listeners.set(view, tell);
|
|
97
|
+
sink = frame.attach({
|
|
98
|
+
find: (query, direction) => answer(() => findIn(view, query, direction)),
|
|
99
|
+
findEnd: () => {
|
|
100
|
+
if (!view.isDestroyed) {
|
|
101
|
+
endFind(view);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
focus: () => view.focus(),
|
|
105
|
+
replace: (query, all) => answer(() => replaceIn(view, query, all)),
|
|
106
|
+
run: (tool, value) => {
|
|
107
|
+
if (view.isDestroyed) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const command = tool === 'link' ? setLink(schema, value ?? '') : commands[tool];
|
|
111
|
+
return command ? command(view.state, view.dispatch, view) : false;
|
|
112
|
+
},
|
|
113
|
+
settings: (settings) => {
|
|
114
|
+
if (!view.isDestroyed) {
|
|
115
|
+
view.dispatch(view.state.tr
|
|
116
|
+
.setMeta(editorFrameKey, { connected: true, ...settings })
|
|
117
|
+
.setMeta('addToHistory', false));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
tools: () => runs,
|
|
121
|
+
});
|
|
122
|
+
tell(view.state);
|
|
123
|
+
return {
|
|
124
|
+
disconnect: () => {
|
|
125
|
+
sink?.detach();
|
|
126
|
+
sink = undefined;
|
|
127
|
+
listeners.delete(view);
|
|
128
|
+
if (view.isDestroyed) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
endFind(view);
|
|
132
|
+
if (added.length > 0) {
|
|
133
|
+
view.updateState(view.state.reconfigure({
|
|
134
|
+
plugins: view.state.plugins.filter((plugin) => !added.includes(plugin)),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
if (editorFrameKey.get(view.state)) {
|
|
138
|
+
view.dispatch(view.state.tr.setMeta(editorFrameKey, UNCONNECTED).setMeta('addToHistory', false));
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Node } from 'prosemirror-model';
|
|
2
|
+
import { type EditorState, Plugin, PluginKey } from 'prosemirror-state';
|
|
3
|
+
import { type EditorView } from 'prosemirror-view';
|
|
4
|
+
/**
|
|
5
|
+
* What the frame's find bar asks for. The headless tier's `FindQuery`, held
|
|
6
|
+
* to this by a spec, so this package depends on no tier.
|
|
7
|
+
*/
|
|
8
|
+
export interface EditorFindQuery {
|
|
9
|
+
/** Whether case has to match, so "Error" does not find "error". */
|
|
10
|
+
caseSensitive: boolean;
|
|
11
|
+
/** Whether `search` is a regular expression, in JavaScript's syntax. */
|
|
12
|
+
regex: boolean;
|
|
13
|
+
/** What Replace puts in place of a match. */
|
|
14
|
+
replace: string;
|
|
15
|
+
/** What the reader typed. */
|
|
16
|
+
search: string;
|
|
17
|
+
/** Whether a match has to be a whole word. */
|
|
18
|
+
wholeWord: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Where a search stands: how many matches, and which is selected, counting from 0, or -1 for none. */
|
|
21
|
+
export interface EditorFindResult {
|
|
22
|
+
count: number;
|
|
23
|
+
index: number;
|
|
24
|
+
}
|
|
25
|
+
/** Which match a search selects: from the selection, the next one, or the previous one. */
|
|
26
|
+
export type EditorFindDirection = 'first' | 'next' | 'previous';
|
|
27
|
+
/** One match, as positions in the document. */
|
|
28
|
+
interface Match {
|
|
29
|
+
from: number;
|
|
30
|
+
to: number;
|
|
31
|
+
}
|
|
32
|
+
/** What the find plugin holds: the search, every match, and the selected one. */
|
|
33
|
+
interface Found {
|
|
34
|
+
current: number;
|
|
35
|
+
matches: readonly Match[];
|
|
36
|
+
query: EditorFindQuery | undefined;
|
|
37
|
+
}
|
|
38
|
+
/** The key the find plugin is found under. */
|
|
39
|
+
export declare const editorFindKey: PluginKey<Found>;
|
|
40
|
+
/**
|
|
41
|
+
* Every match of `query` in the document, block by block, so a match never
|
|
42
|
+
* runs from one block into the next, nor across a hard break.
|
|
43
|
+
*/
|
|
44
|
+
export declare const matchesIn: (doc: Node, query: EditorFindQuery) => Match[];
|
|
45
|
+
/**
|
|
46
|
+
* The find bar's search in the document: every match marked, as
|
|
47
|
+
* `helix-find-match`, and the selected one as `helix-find-current`, which the
|
|
48
|
+
* connector's sheet paints as the base layer paints Find's highlights.
|
|
49
|
+
* `editorPlugins` includes it.
|
|
50
|
+
*
|
|
51
|
+
* While a search is open, a change to the document finds again, staying on
|
|
52
|
+
* the match the selected one became.
|
|
53
|
+
*/
|
|
54
|
+
export declare const editorFind: () => Plugin<Found>;
|
|
55
|
+
/** Where the search in a state stands, or nothing while none is open. */
|
|
56
|
+
export declare const findResultOf: (state: EditorState) => EditorFindResult | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Finds `query` in the view's document, marks every match, and selects one:
|
|
59
|
+
* with `first`, the first from the selection's start, which stays on a
|
|
60
|
+
* selected match while the reader types; with `next`, the first from its
|
|
61
|
+
* end; with `previous`, the last before its start. Each wraps around the
|
|
62
|
+
* ends. The selected match is scrolled into view.
|
|
63
|
+
*/
|
|
64
|
+
export declare const findIn: (view: EditorView, query: EditorFindQuery, direction: EditorFindDirection) => EditorFindResult;
|
|
65
|
+
/** Takes the marks a search drew away, and leaves the selection where it is. */
|
|
66
|
+
export declare const endFind: (view: EditorView) => void;
|
|
67
|
+
/**
|
|
68
|
+
* Replaces the selected match, or every match with `all`, in one step of the
|
|
69
|
+
* undo history, and then selects the next match. Where the selection is no
|
|
70
|
+
* match, it selects the next one and replaces nothing. A regular expression's
|
|
71
|
+
* groups reach the replacement as `$1`, as they do in JavaScript.
|
|
72
|
+
*/
|
|
73
|
+
export declare const replaceIn: (view: EditorView, query: EditorFindQuery, all: boolean) => EditorFindResult;
|
|
74
|
+
export {};
|