@aicayzer/inkkit 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/NOTICE +6 -0
- package/README.md +39 -2
- package/dist/autolink.d.ts +1 -0
- package/dist/autolink.js +18 -0
- package/dist/clipboard.d.ts +11 -0
- package/dist/clipboard.js +140 -0
- package/dist/decorations.d.ts +2 -0
- package/dist/decorations.js +66 -0
- package/dist/dialect.d.ts +9 -0
- package/dist/dialect.js +290 -0
- package/dist/editor.d.ts +106 -0
- package/dist/editor.js +974 -0
- package/dist/highlight.d.ts +1 -0
- package/dist/highlight.js +49 -0
- package/dist/images.d.ts +32 -0
- package/dist/images.js +129 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/literals.d.ts +13 -0
- package/dist/literals.js +127 -0
- package/dist/paste.d.ts +23 -0
- package/dist/paste.js +362 -0
- package/dist/preserve.d.ts +16 -0
- package/dist/preserve.js +177 -0
- package/dist/selection.d.ts +1 -0
- package/dist/selection.js +47 -0
- package/dist/style.css +537 -0
- package/dist/tables.d.ts +7 -0
- package/dist/tables.js +80 -0
- package/dist/tasks.d.ts +3 -0
- package/dist/tasks.js +49 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +8 -0
- package/package.json +71 -4
package/dist/editor.js
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
import { Editor, defaultValueCtx, editorViewCtx, editorViewOptionsCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
|
|
2
|
+
import { visibleClipboard, portableClipboard, clipboardText } from './clipboard.js';
|
|
3
|
+
import { clipboard } from '@milkdown/kit/plugin/clipboard';
|
|
4
|
+
import { history } from '@milkdown/kit/plugin/history';
|
|
5
|
+
import { cursor } from '@milkdown/kit/plugin/cursor';
|
|
6
|
+
import { blockquoteKeymap, blockquoteSchema, bulletListKeymap, codeBlockKeymap, createCodeBlockCommand, emphasisKeymap, headingKeymap, inlineCodeKeymap, inlineCodeSchema, linkSchema, listItemKeymap, orderedListKeymap, paragraphKeymap, strongKeymap, toggleEmphasisCommand, toggleInlineCodeCommand, toggleLinkCommand, toggleStrongCommand, turnIntoTextCommand, wrapInBulletListCommand, wrapInHeadingCommand, wrapInOrderedListCommand, } from '@milkdown/kit/preset/commonmark';
|
|
7
|
+
import { strikethroughKeymap, toggleStrikethroughCommand, } from '@milkdown/kit/preset/gfm';
|
|
8
|
+
import { NodeRange, Fragment, Slice, } from '@milkdown/kit/prose/model';
|
|
9
|
+
import { keydownHandler, keymap } from '@milkdown/kit/prose/keymap';
|
|
10
|
+
import { liftListItem } from '@milkdown/kit/prose/schema-list';
|
|
11
|
+
import { findWrapping, liftTarget } from '@milkdown/kit/prose/transform';
|
|
12
|
+
import { AllSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
|
|
13
|
+
import { $prose, callCommand, replaceAll, } from '@milkdown/kit/utils';
|
|
14
|
+
import { codeCopyPlugin, placeholderPlugin } from './decorations.js';
|
|
15
|
+
import { createDialect, serialize, stringifyOptions } from './dialect.js';
|
|
16
|
+
import { Preservation } from './preserve.js';
|
|
17
|
+
import { imageView } from './images.js';
|
|
18
|
+
import { tablePlugins, tableCommand } from './tables.js';
|
|
19
|
+
import { InkKitError, } from './types.js';
|
|
20
|
+
import { highlightPlugin } from './highlight.js';
|
|
21
|
+
import { PasteController } from './paste.js';
|
|
22
|
+
import { selectionPlugin } from './selection.js';
|
|
23
|
+
import { search, SearchQuery, getSearchState, setSearchState, } from 'prosemirror-search';
|
|
24
|
+
import { taskListPlugin, toggleTaskList } from './tasks.js';
|
|
25
|
+
const shortcutCommands = {
|
|
26
|
+
heading1: ['heading', 1],
|
|
27
|
+
heading2: ['heading', 2],
|
|
28
|
+
heading3: ['heading', 3],
|
|
29
|
+
paragraph: ['paragraph'],
|
|
30
|
+
bold: ['bold'],
|
|
31
|
+
italic: ['italic'],
|
|
32
|
+
strikethrough: ['strikethrough'],
|
|
33
|
+
code: ['code'],
|
|
34
|
+
codeBlock: ['codeBlock'],
|
|
35
|
+
quote: ['quote'],
|
|
36
|
+
bulletList: ['bulletList'],
|
|
37
|
+
orderedList: ['orderedList'],
|
|
38
|
+
taskList: ['taskList'],
|
|
39
|
+
};
|
|
40
|
+
const markNames = {
|
|
41
|
+
strong: 'bold',
|
|
42
|
+
emphasis: 'italic',
|
|
43
|
+
strike_through: 'strikethrough',
|
|
44
|
+
inlineCode: 'code',
|
|
45
|
+
link: 'link',
|
|
46
|
+
};
|
|
47
|
+
// Select All resolves at the document level, where no block can be read.
|
|
48
|
+
function textBounds(state) {
|
|
49
|
+
const { selection, doc } = state;
|
|
50
|
+
if (selection instanceof AllSelection)
|
|
51
|
+
return TextSelection.between(doc.resolve(0), doc.resolve(doc.content.size));
|
|
52
|
+
return selection;
|
|
53
|
+
}
|
|
54
|
+
function caretState(state) {
|
|
55
|
+
const { empty } = state.selection;
|
|
56
|
+
const { $from, $to } = textBounds(state);
|
|
57
|
+
const active = new Set();
|
|
58
|
+
const marks = empty ? (state.storedMarks ?? $from.marks()) : [];
|
|
59
|
+
for (const mark of marks) {
|
|
60
|
+
const name = markNames[mark.type.name];
|
|
61
|
+
if (name)
|
|
62
|
+
active.add(name);
|
|
63
|
+
}
|
|
64
|
+
if (!empty) {
|
|
65
|
+
for (const [name, mark] of Object.entries(markNames)) {
|
|
66
|
+
const type = state.schema.marks[name];
|
|
67
|
+
if (type && state.doc.rangeHasMark($from.pos, $to.pos, type))
|
|
68
|
+
active.add(mark);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
marks: [...active],
|
|
73
|
+
block: blockAt($from.parent, $from),
|
|
74
|
+
quoted: quoted($from, $to),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// True only when one quote holds the whole selection, so the state matches what the command can lift.
|
|
78
|
+
function quoted($from, $to) {
|
|
79
|
+
return ($from.blockRange($to, (node) => node.type.name === 'blockquote') != null);
|
|
80
|
+
}
|
|
81
|
+
function blockAt(parent, $from) {
|
|
82
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
83
|
+
const node = $from.node(depth);
|
|
84
|
+
switch (node.type.name) {
|
|
85
|
+
case 'heading':
|
|
86
|
+
return { type: 'heading', level: node.attrs.level };
|
|
87
|
+
case 'code_block':
|
|
88
|
+
return { type: 'codeBlock' };
|
|
89
|
+
case 'list_item':
|
|
90
|
+
return node.attrs.checked == null
|
|
91
|
+
? {
|
|
92
|
+
type: $from.node(depth - 1).type.name === 'ordered_list'
|
|
93
|
+
? 'orderedList'
|
|
94
|
+
: 'bulletList',
|
|
95
|
+
}
|
|
96
|
+
: { type: 'taskList' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (parent.type.name === 'heading')
|
|
100
|
+
return { type: 'heading', level: parent.attrs.level };
|
|
101
|
+
return { type: 'paragraph' };
|
|
102
|
+
}
|
|
103
|
+
// Backspace at the start of a quote's first block is left alone by the preset;
|
|
104
|
+
// leaving the quote is what a Backspace there means.
|
|
105
|
+
const quoteBackspace = $prose(() => keymap({
|
|
106
|
+
Backspace: (state, dispatch) => {
|
|
107
|
+
const { $from, empty } = state.selection;
|
|
108
|
+
if (!empty || $from.parentOffset > 0 || $from.depth < 2)
|
|
109
|
+
return false;
|
|
110
|
+
if ($from.node($from.depth - 1).type.name !== 'blockquote' ||
|
|
111
|
+
$from.index($from.depth - 1) > 0)
|
|
112
|
+
return false;
|
|
113
|
+
const range = $from.blockRange($from, (node) => node.type.name === 'blockquote');
|
|
114
|
+
const target = range && liftTarget(range);
|
|
115
|
+
if (!range || target == null)
|
|
116
|
+
return false;
|
|
117
|
+
dispatch?.(state.tr.lift(range, target).scrollIntoView());
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
}));
|
|
121
|
+
// The preset lifts only an item's first line, which cannot leave on its own while the item holds
|
|
122
|
+
// more, such as a nested list: at the top of a document Backspace did nothing, and below another block
|
|
123
|
+
// it merged the line upward and left an empty bullet. The whole item is lifted instead, and a nested
|
|
124
|
+
// list it held joins the items that followed, since both now sit at one level.
|
|
125
|
+
const listItemBackspace = $prose(() => keymap({
|
|
126
|
+
Backspace: (state, dispatch) => {
|
|
127
|
+
const { $from, empty } = state.selection;
|
|
128
|
+
if (!empty || $from.parentOffset > 0 || $from.depth < 3)
|
|
129
|
+
return false;
|
|
130
|
+
const item = $from.node(-1);
|
|
131
|
+
if (item.type.name !== 'list_item' ||
|
|
132
|
+
item.childCount < 2 ||
|
|
133
|
+
$from.index(-1) > 0 ||
|
|
134
|
+
$from.index(-2) > 0)
|
|
135
|
+
return false;
|
|
136
|
+
const { doc } = state;
|
|
137
|
+
const range = new NodeRange(doc.resolve($from.start(-1)), doc.resolve($from.end(-1)), $from.depth - 1);
|
|
138
|
+
const target = liftTarget(range);
|
|
139
|
+
if (target == null)
|
|
140
|
+
return false;
|
|
141
|
+
const tr = state.tr.lift(range, target);
|
|
142
|
+
const $line = tr.doc.resolve(tr.mapping.map($from.pos));
|
|
143
|
+
joinLists(tr, $line.start(-1), $line.index(-1) + item.childCount);
|
|
144
|
+
dispatch?.(tr.scrollIntoView());
|
|
145
|
+
return true;
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
// Lifting an item out of a list that no item holds, the preset leaves a nested list the item held
|
|
149
|
+
// beside the items that followed. Out of a nested list it joins the two itself.
|
|
150
|
+
const liftItem = (state, dispatch) => {
|
|
151
|
+
const itemType = state.schema.nodes.list_item;
|
|
152
|
+
const { $from, $to } = state.selection;
|
|
153
|
+
const range = $from.blockRange($to, (node) => node.firstChild?.type === itemType);
|
|
154
|
+
const lift = liftListItem(itemType);
|
|
155
|
+
if (!range || !dispatch || $from.node(range.depth - 1).type === itemType)
|
|
156
|
+
return lift(state, dispatch);
|
|
157
|
+
let lifted;
|
|
158
|
+
if (!lift(state, (tr) => (lifted = tr)) || !lifted)
|
|
159
|
+
return false;
|
|
160
|
+
let count = 0;
|
|
161
|
+
for (let index = range.startIndex; index < range.endIndex; index++)
|
|
162
|
+
count += range.parent.child(index).childCount;
|
|
163
|
+
const start = range.$from.start(range.depth - 1);
|
|
164
|
+
const first = range.$from.index(range.depth - 1) + (range.startIndex > 0 ? 1 : 0);
|
|
165
|
+
joinLists(lifted, start, first + count);
|
|
166
|
+
joinLists(lifted, start, first);
|
|
167
|
+
dispatch(lifted);
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
const listItemShiftTab = $prose(() => keymap({ 'Shift-Tab': liftItem }));
|
|
171
|
+
// A control chord that nothing handles reaches the page as its ASCII control character
|
|
172
|
+
// (Control-N as U+000E), which the web view would insert as text.
|
|
173
|
+
const controlCharacters = /^[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]+$/;
|
|
174
|
+
const dropControlCharacters = $prose(() => new Plugin({
|
|
175
|
+
key: new PluginKey('dropControlCharacters'),
|
|
176
|
+
props: {
|
|
177
|
+
handleTextInput: (_view, _from, _to, text) => controlCharacters.test(text),
|
|
178
|
+
},
|
|
179
|
+
}));
|
|
180
|
+
// The listener plugin reports selection changes from inside state.apply, before
|
|
181
|
+
// the view holds the new state, so the caret state is read from the view instead.
|
|
182
|
+
function caretStatePlugin(events) {
|
|
183
|
+
return $prose(() => new Plugin({
|
|
184
|
+
key: new PluginKey('caretState'),
|
|
185
|
+
view: () => ({
|
|
186
|
+
update(view, previous) {
|
|
187
|
+
const { state } = view;
|
|
188
|
+
if (state.selection.eq(previous.selection) &&
|
|
189
|
+
state.doc.eq(previous.doc) &&
|
|
190
|
+
state.storedMarks === previous.storedMarks)
|
|
191
|
+
return;
|
|
192
|
+
events.stateChanged(caretState(state));
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
function outermostListDepth($pos) {
|
|
198
|
+
for (let depth = 1; depth <= $pos.depth; depth++) {
|
|
199
|
+
if (isList($pos.node(depth)))
|
|
200
|
+
return depth;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
function innermostListDepth($pos) {
|
|
205
|
+
for (let depth = $pos.depth; depth >= 1; depth--) {
|
|
206
|
+
if (isList($pos.node(depth)))
|
|
207
|
+
return depth;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function isList(node) {
|
|
212
|
+
return node.type.name === 'bullet_list' || node.type.name === 'ordered_list';
|
|
213
|
+
}
|
|
214
|
+
// Two lists of one kind side by side are written with a changed marker to keep them apart, and the
|
|
215
|
+
// document shows a gap between them, so an edit that leaves them so joins them.
|
|
216
|
+
function joinLists(tr, start, index) {
|
|
217
|
+
const $start = tr.doc.resolve(start);
|
|
218
|
+
const before = $start.parent.maybeChild(index - 1);
|
|
219
|
+
const after = $start.parent.maybeChild(index);
|
|
220
|
+
if (before && after && isList(before) && after.type === before.type)
|
|
221
|
+
tr.join($start.posAtIndex(index));
|
|
222
|
+
}
|
|
223
|
+
export class InkKitEditor {
|
|
224
|
+
events;
|
|
225
|
+
options;
|
|
226
|
+
editor;
|
|
227
|
+
lastMarkdown = '';
|
|
228
|
+
baseline = '';
|
|
229
|
+
documentId = '';
|
|
230
|
+
formatType = 'md';
|
|
231
|
+
revision = 0;
|
|
232
|
+
documentEpoch = 0;
|
|
233
|
+
operationSequence = 0;
|
|
234
|
+
plainComposing = false;
|
|
235
|
+
plainSearch = '';
|
|
236
|
+
ready = false;
|
|
237
|
+
destroyed = false;
|
|
238
|
+
preservation;
|
|
239
|
+
pasteController;
|
|
240
|
+
plain;
|
|
241
|
+
root;
|
|
242
|
+
clickHandler;
|
|
243
|
+
copyHandler;
|
|
244
|
+
originalSource = '';
|
|
245
|
+
generation = 0;
|
|
246
|
+
loading = false;
|
|
247
|
+
changePlugin = $prose(() => new Plugin({
|
|
248
|
+
key: new PluginKey('documentChanges'),
|
|
249
|
+
view: () => ({
|
|
250
|
+
update: (view, previous) => {
|
|
251
|
+
if (this.loading || view.state.doc.eq(previous.doc))
|
|
252
|
+
return;
|
|
253
|
+
this.revision += 1;
|
|
254
|
+
let markdown;
|
|
255
|
+
try {
|
|
256
|
+
markdown =
|
|
257
|
+
this.preservation?.serialize(view.state.doc) ??
|
|
258
|
+
serialize(this.editor.ctx, view.state.doc);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.events.error?.(error instanceof Error ? error : new Error(String(error)));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (markdown === this.lastMarkdown)
|
|
265
|
+
return;
|
|
266
|
+
this.lastMarkdown = markdown;
|
|
267
|
+
this.events.changed(markdown === this.baseline ? this.originalSource : markdown, this.generation);
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
}));
|
|
271
|
+
keys = () => false;
|
|
272
|
+
constructor(events, options) {
|
|
273
|
+
this.events = events;
|
|
274
|
+
this.options = options;
|
|
275
|
+
}
|
|
276
|
+
context() {
|
|
277
|
+
return {
|
|
278
|
+
documentId: this.documentId,
|
|
279
|
+
generation: this.generation,
|
|
280
|
+
operationId: `${this.documentEpoch}:${this.generation}:${++this.operationSequence}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
assertAlive() {
|
|
284
|
+
if (this.destroyed)
|
|
285
|
+
throw new InkKitError('destroyed', 'The editor has been destroyed');
|
|
286
|
+
}
|
|
287
|
+
assertCurrent(generation = this.generation) {
|
|
288
|
+
this.assertAlive();
|
|
289
|
+
if (!this.ready)
|
|
290
|
+
throw new InkKitError('not-ready', 'No document has been loaded');
|
|
291
|
+
if (generation !== this.generation)
|
|
292
|
+
throw new InkKitError('stale-document', 'Document changed');
|
|
293
|
+
if (this.formatType === 'txt'
|
|
294
|
+
? this.plainComposing
|
|
295
|
+
: this.editor.ctx.get(editorViewCtx).composing)
|
|
296
|
+
throw new InkKitError('composition', 'Text composition is in progress');
|
|
297
|
+
if (this.pasteController.pending)
|
|
298
|
+
throw new InkKitError('operation-pending', 'An image import is in progress');
|
|
299
|
+
}
|
|
300
|
+
plainSource() {
|
|
301
|
+
const normalized = this.originalSource.replace(/\r\n?/g, '\n');
|
|
302
|
+
if (this.plain.value === normalized)
|
|
303
|
+
return this.originalSource;
|
|
304
|
+
const crlf = this.originalSource.includes('\r\n') &&
|
|
305
|
+
!this.originalSource.replaceAll('\r\n', '').includes('\n');
|
|
306
|
+
return crlf ? this.plain.value.replaceAll('\n', '\r\n') : this.plain.value;
|
|
307
|
+
}
|
|
308
|
+
snapshot(expectedGeneration) {
|
|
309
|
+
this.assertCurrent(expectedGeneration);
|
|
310
|
+
let text;
|
|
311
|
+
try {
|
|
312
|
+
text =
|
|
313
|
+
this.formatType === 'txt'
|
|
314
|
+
? this.plainSource()
|
|
315
|
+
: this.preservation.serialize(this.editor.ctx.get(editorViewCtx).state.doc);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot preserve Markdown');
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
documentId: this.documentId,
|
|
322
|
+
generation: this.generation,
|
|
323
|
+
revision: this.revision,
|
|
324
|
+
format: this.formatType,
|
|
325
|
+
text,
|
|
326
|
+
dirty: text !== this.originalSource,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async clipboardSnapshot(all = true) {
|
|
330
|
+
const snapshot = this.snapshot();
|
|
331
|
+
const epoch = this.documentEpoch;
|
|
332
|
+
if (this.formatType === 'txt') {
|
|
333
|
+
const text = all
|
|
334
|
+
? snapshot.text
|
|
335
|
+
: this.plain.value.slice(this.plain.selectionStart, this.plain.selectionEnd);
|
|
336
|
+
const pre = document.createElement('pre');
|
|
337
|
+
pre.textContent = text;
|
|
338
|
+
return { text, html: pre.outerHTML, markdown: text, images: [] };
|
|
339
|
+
}
|
|
340
|
+
const { doc, schema, selection } = this.editor.ctx.get(editorViewCtx).state;
|
|
341
|
+
const content = all ? doc.content : selection.content().content;
|
|
342
|
+
const valid = content.firstChild?.isInline
|
|
343
|
+
? Fragment.from(schema.nodes.paragraph.create(null, content))
|
|
344
|
+
: content;
|
|
345
|
+
let markdown;
|
|
346
|
+
try {
|
|
347
|
+
markdown = all
|
|
348
|
+
? snapshot.text
|
|
349
|
+
: serialize(this.editor.ctx, schema.topNodeType.create(null, valid));
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
throw new InkKitError('preservation', error instanceof Error
|
|
353
|
+
? error.message
|
|
354
|
+
: 'Cannot copy the selected Markdown');
|
|
355
|
+
}
|
|
356
|
+
const result = await portableClipboard(content, schema, markdown, this.options.images, this.context());
|
|
357
|
+
this.assertCurrent(snapshot.generation);
|
|
358
|
+
if (snapshot.documentId !== this.documentId || epoch !== this.documentEpoch)
|
|
359
|
+
throw new InkKitError('stale-document', 'Document changed');
|
|
360
|
+
if (result.images.some((image) => image.error))
|
|
361
|
+
this.events.error?.(new InkKitError('image-unavailable', 'Some copied images were unavailable; their descriptions were retained'));
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
async paste(input) {
|
|
365
|
+
this.assertCurrent();
|
|
366
|
+
if (this.formatType === 'txt' || input.plainText) {
|
|
367
|
+
this.pasteAsPlainText(input.text);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
await this.pasteController.paste(input);
|
|
371
|
+
}
|
|
372
|
+
pasteAsPlainText(text) {
|
|
373
|
+
this.assertCurrent();
|
|
374
|
+
if (this.formatType === 'txt') {
|
|
375
|
+
this.plain.setRangeText(text, this.plain.selectionStart, this.plain.selectionEnd, 'end');
|
|
376
|
+
this.plain.dispatchEvent(new Event('input'));
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
380
|
+
if (view.state.selection.$from.parent.type.spec.code) {
|
|
381
|
+
view.dispatch(view.state.tr
|
|
382
|
+
.insertText(text.replace(/\r\n?/g, '\n'))
|
|
383
|
+
.scrollIntoView());
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
const { schema } = view.state;
|
|
387
|
+
const children = [];
|
|
388
|
+
text
|
|
389
|
+
.replace(/\r\n?/g, '\n')
|
|
390
|
+
.split('\n')
|
|
391
|
+
.forEach((line, index) => {
|
|
392
|
+
if (index)
|
|
393
|
+
children.push(schema.nodes.hardbreak.create({
|
|
394
|
+
isHTML: index ===
|
|
395
|
+
text.replace(/\r\n?/g, '\n').split('\n').length - 1 &&
|
|
396
|
+
line === '',
|
|
397
|
+
}));
|
|
398
|
+
if (line)
|
|
399
|
+
children.push(schema.text(line));
|
|
400
|
+
});
|
|
401
|
+
if (!children.length) {
|
|
402
|
+
view.dispatch(view.state.tr.deleteSelection());
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const paragraph = schema.nodes.paragraph.create(null, children);
|
|
406
|
+
view.dispatch(view.state.tr
|
|
407
|
+
.replaceSelection(new Slice(Fragment.from(paragraph), 1, 1))
|
|
408
|
+
.scrollIntoView());
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
table(command, options) {
|
|
413
|
+
this.assertCurrent();
|
|
414
|
+
if (this.formatType === 'txt')
|
|
415
|
+
return false;
|
|
416
|
+
return tableCommand(this.editor.ctx, command, options);
|
|
417
|
+
}
|
|
418
|
+
async destroy() {
|
|
419
|
+
if (this.destroyed)
|
|
420
|
+
return;
|
|
421
|
+
this.destroyed = true;
|
|
422
|
+
this.ready = false;
|
|
423
|
+
this.pasteController.destroy();
|
|
424
|
+
if (this.clickHandler)
|
|
425
|
+
this.root.removeEventListener('click', this.clickHandler);
|
|
426
|
+
if (this.copyHandler) {
|
|
427
|
+
this.root.removeEventListener('copy', this.copyHandler, true);
|
|
428
|
+
this.root.removeEventListener('cut', this.copyHandler, true);
|
|
429
|
+
}
|
|
430
|
+
this.plain.remove();
|
|
431
|
+
await this.editor.destroy();
|
|
432
|
+
}
|
|
433
|
+
// The app's bindings, replaced whole whenever they change; the plugin stays.
|
|
434
|
+
keymapPlugin = $prose(() => new Plugin({
|
|
435
|
+
key: new PluginKey('appKeymap'),
|
|
436
|
+
props: { handleKeyDown: (view, event) => this.keys(view, event) },
|
|
437
|
+
}));
|
|
438
|
+
static async mount(root, events, options = {}) {
|
|
439
|
+
const instance = new InkKitEditor(events, options);
|
|
440
|
+
instance.root = root;
|
|
441
|
+
instance.pasteController = new PasteController({
|
|
442
|
+
ctx: () => instance.editor.ctx,
|
|
443
|
+
context: () => instance.context(),
|
|
444
|
+
adapter: options.images,
|
|
445
|
+
onError: (error) => events.error?.(error instanceof Error ? error : new Error(String(error))),
|
|
446
|
+
literalText: () => instance.formatType === 'txt',
|
|
447
|
+
});
|
|
448
|
+
instance.editor = await Editor.make()
|
|
449
|
+
.config((ctx) => {
|
|
450
|
+
ctx.set(rootCtx, root);
|
|
451
|
+
ctx.set(defaultValueCtx, '');
|
|
452
|
+
ctx.set(remarkStringifyOptionsCtx, stringifyOptions);
|
|
453
|
+
// The preset also binds Mod-[ and Mod-] here; the app uses those for back and forward.
|
|
454
|
+
// Shift-Tab is bound to liftItem instead.
|
|
455
|
+
ctx.update(listItemKeymap.key, (keys) => ({
|
|
456
|
+
...keys,
|
|
457
|
+
SinkListItem: { shortcuts: 'Tab' },
|
|
458
|
+
LiftListItem: { shortcuts: [] },
|
|
459
|
+
}));
|
|
460
|
+
// Formatting keys are the app's to set, through setKeymap; the presets' own go.
|
|
461
|
+
const unbind = (keymap, keep = []) => ctx.update(keymap.key, (keys) => {
|
|
462
|
+
const cleared = { ...keys };
|
|
463
|
+
for (const name of Object.keys(cleared)) {
|
|
464
|
+
if (!keep.includes(name))
|
|
465
|
+
cleared[name] = { shortcuts: [] };
|
|
466
|
+
}
|
|
467
|
+
return cleared;
|
|
468
|
+
});
|
|
469
|
+
unbind(strongKeymap);
|
|
470
|
+
unbind(emphasisKeymap);
|
|
471
|
+
unbind(inlineCodeKeymap);
|
|
472
|
+
unbind(strikethroughKeymap);
|
|
473
|
+
unbind(headingKeymap, ['DowngradeHeading']);
|
|
474
|
+
unbind(paragraphKeymap);
|
|
475
|
+
unbind(blockquoteKeymap);
|
|
476
|
+
unbind(codeBlockKeymap);
|
|
477
|
+
unbind(bulletListKeymap);
|
|
478
|
+
unbind(orderedListKeymap);
|
|
479
|
+
// The caret is kept above the fade under the formatting bar.
|
|
480
|
+
ctx.update(editorViewOptionsCtx, (options) => ({
|
|
481
|
+
...options,
|
|
482
|
+
scrollThreshold: { top: 8, right: 0, bottom: 24, left: 0 },
|
|
483
|
+
scrollMargin: { top: 8, right: 0, bottom: 24, left: 0 },
|
|
484
|
+
}));
|
|
485
|
+
})
|
|
486
|
+
.use(caretStatePlugin(events))
|
|
487
|
+
.use(instance.pasteController.plugin)
|
|
488
|
+
.use(visibleClipboard)
|
|
489
|
+
.use(instance.keymapPlugin)
|
|
490
|
+
.use(createDialect(Boolean(options.images)))
|
|
491
|
+
.use(tablePlugins)
|
|
492
|
+
.use(instance.changePlugin)
|
|
493
|
+
.use(history)
|
|
494
|
+
.use(clipboard)
|
|
495
|
+
.use(cursor)
|
|
496
|
+
.use(taskListPlugin)
|
|
497
|
+
.use(quoteBackspace)
|
|
498
|
+
.use(listItemBackspace)
|
|
499
|
+
.use(listItemShiftTab)
|
|
500
|
+
.use(dropControlCharacters)
|
|
501
|
+
.use(codeCopyPlugin((text) => events.copy(text)))
|
|
502
|
+
.use(placeholderPlugin)
|
|
503
|
+
.use(highlightPlugin)
|
|
504
|
+
.use(selectionPlugin)
|
|
505
|
+
.use($prose(() => search()))
|
|
506
|
+
.use(options.images ? imageView(options.images) : [])
|
|
507
|
+
.create();
|
|
508
|
+
instance.plain = document.createElement('textarea');
|
|
509
|
+
instance.plain.className = 'inkkit-plain';
|
|
510
|
+
instance.plain.setAttribute('aria-label', 'Plain text editor');
|
|
511
|
+
instance.plain.hidden = true;
|
|
512
|
+
root.append(instance.plain);
|
|
513
|
+
instance.plain.addEventListener('input', () => {
|
|
514
|
+
instance.revision += 1;
|
|
515
|
+
events.changed(instance.plainSource(), instance.generation);
|
|
516
|
+
});
|
|
517
|
+
instance.plain.addEventListener('compositionstart', () => {
|
|
518
|
+
instance.plainComposing = true;
|
|
519
|
+
});
|
|
520
|
+
instance.plain.addEventListener('compositionend', () => {
|
|
521
|
+
instance.plainComposing = false;
|
|
522
|
+
});
|
|
523
|
+
instance.clickHandler = (event) => {
|
|
524
|
+
const anchor = event.target.closest('a[href]');
|
|
525
|
+
if (anchor && event.metaKey) {
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
events.openLink(anchor.getAttribute('href') ?? '');
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
root.addEventListener('click', instance.clickHandler);
|
|
531
|
+
instance.copyHandler = (event) => {
|
|
532
|
+
if (instance.formatType === 'txt')
|
|
533
|
+
return;
|
|
534
|
+
const view = instance.editor.ctx.get(editorViewCtx);
|
|
535
|
+
const fragment = view.state.selection.content().content;
|
|
536
|
+
let hasImages = false;
|
|
537
|
+
fragment.descendants((node) => {
|
|
538
|
+
if (node.type.name === 'image')
|
|
539
|
+
hasImages = true;
|
|
540
|
+
});
|
|
541
|
+
if (!hasImages)
|
|
542
|
+
return;
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
event.stopPropagation();
|
|
545
|
+
if (events.clipboard) {
|
|
546
|
+
const epoch = instance.documentEpoch, revision = instance.revision;
|
|
547
|
+
const { from, to } = view.state.selection;
|
|
548
|
+
const cut = event.type === 'cut';
|
|
549
|
+
void instance
|
|
550
|
+
.clipboardSnapshot(false)
|
|
551
|
+
.then(async (content) => {
|
|
552
|
+
if (cut && content.images.some((image) => image.error))
|
|
553
|
+
throw new InkKitError('image-unavailable', 'The selected images could not be cut safely');
|
|
554
|
+
await events.clipboard(content);
|
|
555
|
+
if (cut) {
|
|
556
|
+
instance.assertCurrent();
|
|
557
|
+
if (epoch !== instance.documentEpoch ||
|
|
558
|
+
revision !== instance.revision)
|
|
559
|
+
throw new InkKitError('stale-document', 'Document changed before cutting');
|
|
560
|
+
view.dispatch(view.state.tr.deleteRange(from, to).scrollIntoView());
|
|
561
|
+
}
|
|
562
|
+
})
|
|
563
|
+
.catch((error) => events.error?.(error instanceof Error ? error : new Error(String(error))));
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const container = document.createElement('div');
|
|
567
|
+
container.textContent = clipboardText(fragment);
|
|
568
|
+
event.clipboardData?.setData('text/plain', container.textContent ?? '');
|
|
569
|
+
event.clipboardData?.setData('text/html', container.outerHTML);
|
|
570
|
+
events.error?.(new InkKitError('image-unavailable', 'The host must provide a clipboard handler to copy images'));
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
root.addEventListener('copy', instance.copyHandler, true);
|
|
574
|
+
root.addEventListener('cut', instance.copyHandler, true);
|
|
575
|
+
return instance;
|
|
576
|
+
}
|
|
577
|
+
loadDocument(input) {
|
|
578
|
+
this.assertAlive();
|
|
579
|
+
this.pasteController.cancelPending();
|
|
580
|
+
this.documentEpoch += 1;
|
|
581
|
+
this.plainComposing = false;
|
|
582
|
+
this.plainSearch = '';
|
|
583
|
+
this.documentId = input.documentId;
|
|
584
|
+
this.formatType = input.format;
|
|
585
|
+
this.revision = 0;
|
|
586
|
+
this.plain.hidden = input.format !== 'txt';
|
|
587
|
+
this.editor.ctx.get(editorViewCtx).dom.parentElement.hidden =
|
|
588
|
+
input.format === 'txt';
|
|
589
|
+
this.plain.value = input.text;
|
|
590
|
+
this.load(input.format === 'txt' ? '' : input.text, input.generation);
|
|
591
|
+
this.originalSource = input.text;
|
|
592
|
+
this.ready = true;
|
|
593
|
+
}
|
|
594
|
+
reloadDocument(input) {
|
|
595
|
+
this.assertAlive();
|
|
596
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
597
|
+
const at = this.formatType === 'txt'
|
|
598
|
+
? this.plain.selectionStart
|
|
599
|
+
: view.state.selection.from;
|
|
600
|
+
const top = this.root.scrollTop;
|
|
601
|
+
this.loadDocument(input);
|
|
602
|
+
if (input.format === 'txt')
|
|
603
|
+
this.plain.setSelectionRange(Math.min(at, input.text.length), Math.min(at, input.text.length));
|
|
604
|
+
else
|
|
605
|
+
view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
|
|
606
|
+
this.root.scrollTop = top;
|
|
607
|
+
}
|
|
608
|
+
load(markdown, generation) {
|
|
609
|
+
// A loaded document only counts as changed once it is edited, so its
|
|
610
|
+
// canonical form is the baseline, not the text as stored.
|
|
611
|
+
this.generation = generation;
|
|
612
|
+
this.originalSource = markdown;
|
|
613
|
+
this.loading = true;
|
|
614
|
+
try {
|
|
615
|
+
this.editor.action(replaceAll(markdown, true));
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
this.loading = false;
|
|
619
|
+
}
|
|
620
|
+
this.preservation = new Preservation(this.editor.ctx, markdown);
|
|
621
|
+
this.baseline = serialize(this.editor.ctx);
|
|
622
|
+
this.lastMarkdown = this.baseline;
|
|
623
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
624
|
+
// A caret at the end, where writing carries on; at the start it would sit in a first-line heading and
|
|
625
|
+
// show its marks. The last place text can go, so a document ending in a rule takes a caret above it
|
|
626
|
+
// rather than selecting the rule itself.
|
|
627
|
+
const { doc } = view.state;
|
|
628
|
+
const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
|
|
629
|
+
Selection.atEnd(doc);
|
|
630
|
+
view.dispatch(setSearchState(view.state.tr.setSelection(end).scrollIntoView(), new SearchQuery({ search: '' })));
|
|
631
|
+
this.events.stateChanged(caretState(view.state));
|
|
632
|
+
}
|
|
633
|
+
find(text) {
|
|
634
|
+
this.assertCurrent();
|
|
635
|
+
if (this.formatType === 'txt') {
|
|
636
|
+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
637
|
+
const matches = text
|
|
638
|
+
? [...this.plain.value.matchAll(new RegExp(escaped, 'giu'))]
|
|
639
|
+
: [];
|
|
640
|
+
const from = this.plainSearch === text
|
|
641
|
+
? this.plain.selectionEnd
|
|
642
|
+
: this.plain.selectionStart;
|
|
643
|
+
const match = matches.find((item) => item.index >= from) ?? matches[0];
|
|
644
|
+
if (match)
|
|
645
|
+
this.plain.setSelectionRange(match.index, match.index + match[0].length);
|
|
646
|
+
else if (this.plainSearch)
|
|
647
|
+
this.plain.setSelectionRange(this.plain.selectionEnd, this.plain.selectionEnd);
|
|
648
|
+
this.plainSearch = text;
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
652
|
+
const previous = getSearchState(view.state)?.query.search;
|
|
653
|
+
const query = new SearchQuery({ search: text, literal: true });
|
|
654
|
+
let tr = setSearchState(view.state.tr, query);
|
|
655
|
+
if (text) {
|
|
656
|
+
const from = previous === text ? view.state.selection.to : view.state.selection.from;
|
|
657
|
+
const match = query.findNext(view.state, from) ?? query.findNext(view.state, 0);
|
|
658
|
+
if (match)
|
|
659
|
+
tr = tr
|
|
660
|
+
.setSelection(TextSelection.create(tr.doc, match.from, match.to))
|
|
661
|
+
.scrollIntoView();
|
|
662
|
+
else
|
|
663
|
+
tr = tr.setSelection(Selection.near(view.state.selection.$to));
|
|
664
|
+
}
|
|
665
|
+
else if (previous) {
|
|
666
|
+
tr = tr.setSelection(Selection.near(view.state.selection.$to));
|
|
667
|
+
}
|
|
668
|
+
view.dispatch(tr);
|
|
669
|
+
}
|
|
670
|
+
/** Buffered native typing follows the same input rules as direct typing. */
|
|
671
|
+
insertText(text, generation) {
|
|
672
|
+
this.assertAlive();
|
|
673
|
+
if ((this.formatType === 'txt'
|
|
674
|
+
? this.plainComposing
|
|
675
|
+
: this.editor.ctx.get(editorViewCtx).composing) &&
|
|
676
|
+
generation === this.generation)
|
|
677
|
+
return false;
|
|
678
|
+
this.assertCurrent(generation);
|
|
679
|
+
if (this.formatType === 'txt') {
|
|
680
|
+
this.pasteAsPlainText(text);
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
684
|
+
if (view.composing)
|
|
685
|
+
return false;
|
|
686
|
+
const { from, to } = view.state.selection;
|
|
687
|
+
const transaction = () => view.state.tr.insertText(text, from, to);
|
|
688
|
+
if (!view.someProp('handleTextInput', (handler) => handler(view, from, to, text, transaction))) {
|
|
689
|
+
view.dispatch(transaction().scrollIntoView());
|
|
690
|
+
}
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
/** Let existing keymaps handle buffered editing commands before native fallback. */
|
|
694
|
+
keyDown(key, code, metaKey, ctrlKey, altKey, shiftKey, generation) {
|
|
695
|
+
this.assertAlive();
|
|
696
|
+
if ((this.formatType === 'txt'
|
|
697
|
+
? this.plainComposing
|
|
698
|
+
: this.editor.ctx.get(editorViewCtx).composing) &&
|
|
699
|
+
generation === this.generation)
|
|
700
|
+
return false;
|
|
701
|
+
this.assertCurrent(generation);
|
|
702
|
+
if (this.formatType === 'txt')
|
|
703
|
+
return false;
|
|
704
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
705
|
+
if (view.composing)
|
|
706
|
+
return false;
|
|
707
|
+
const event = new KeyboardEvent('keydown', {
|
|
708
|
+
key,
|
|
709
|
+
code,
|
|
710
|
+
metaKey,
|
|
711
|
+
ctrlKey,
|
|
712
|
+
altKey,
|
|
713
|
+
shiftKey,
|
|
714
|
+
bubbles: true,
|
|
715
|
+
cancelable: true,
|
|
716
|
+
});
|
|
717
|
+
return Boolean(view.someProp('handleKeyDown', (handler) => handler(view, event)));
|
|
718
|
+
}
|
|
719
|
+
focus() {
|
|
720
|
+
this.assertAlive();
|
|
721
|
+
if (!this.ready)
|
|
722
|
+
throw new InkKitError('not-ready', 'No document has been loaded');
|
|
723
|
+
if (this.formatType === 'txt')
|
|
724
|
+
this.plain.focus();
|
|
725
|
+
else
|
|
726
|
+
this.editor.ctx.get(editorViewCtx).focus();
|
|
727
|
+
}
|
|
728
|
+
/** Binds keys, in ProseMirror's names, to the formatting each shortcut runs. Walked in the table's
|
|
729
|
+
* order, so a key given to two shortcuts lands the same way every time. */
|
|
730
|
+
setKeymap(keymap) {
|
|
731
|
+
this.assertAlive();
|
|
732
|
+
const bindings = {};
|
|
733
|
+
for (const [name, command] of Object.entries(shortcutCommands)) {
|
|
734
|
+
for (const key of keymap[name] ?? []) {
|
|
735
|
+
bindings[key] = () => {
|
|
736
|
+
this.format(...command);
|
|
737
|
+
return true;
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
this.keys = keydownHandler(bindings);
|
|
742
|
+
}
|
|
743
|
+
/** Dropped files land as one paragraph per path at the drop point: in place of an empty block, after
|
|
744
|
+
* the top-level block otherwise, so a list or quote is not opened up by them. */
|
|
745
|
+
insertPaths(paths, x, y) {
|
|
746
|
+
this.assertCurrent();
|
|
747
|
+
if (paths.length === 0)
|
|
748
|
+
return;
|
|
749
|
+
if (this.formatType === 'txt') {
|
|
750
|
+
this.pasteAsPlainText(paths.join('\n\n'));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const { schema } = this.editor.ctx.get(editorViewCtx).state;
|
|
754
|
+
this.insertBlocks(paths.map((path) => schema.nodes.paragraph.create(null, schema.text(path))), x, y);
|
|
755
|
+
}
|
|
756
|
+
insertImages(images, x, y) {
|
|
757
|
+
this.assertCurrent();
|
|
758
|
+
if (!images.length)
|
|
759
|
+
return;
|
|
760
|
+
if (!this.options.images || this.formatType === 'txt')
|
|
761
|
+
throw new InkKitError('image-unavailable', 'This editor has no image adapter');
|
|
762
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
763
|
+
const { schema } = view.state;
|
|
764
|
+
const nodes = images.map((image) => schema.nodes.image.create({
|
|
765
|
+
src: image.path,
|
|
766
|
+
alt: image.alt,
|
|
767
|
+
title: image.title ?? '',
|
|
768
|
+
}));
|
|
769
|
+
if (x != null && y != null)
|
|
770
|
+
this.insertBlocks(nodes.map((node) => schema.nodes.paragraph.create(null, node)), x, y);
|
|
771
|
+
else
|
|
772
|
+
view.dispatch(view.state.tr
|
|
773
|
+
.replaceSelectionWith(schema.nodes.paragraph.create(null, nodes))
|
|
774
|
+
.scrollIntoView());
|
|
775
|
+
}
|
|
776
|
+
insertBlocks(blocks, x, y) {
|
|
777
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
778
|
+
const { state } = view;
|
|
779
|
+
const $pos = state.doc.resolve(view.posAtCoords({ left: x, top: y })?.pos ?? state.selection.from);
|
|
780
|
+
const tr = state.tr;
|
|
781
|
+
let at;
|
|
782
|
+
if ($pos.depth === 1 &&
|
|
783
|
+
$pos.parent.isTextblock &&
|
|
784
|
+
$pos.parent.content.size === 0) {
|
|
785
|
+
at = $pos.before(1);
|
|
786
|
+
tr.replaceWith(at, $pos.after(1), blocks);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
at = $pos.depth > 0 ? $pos.after(1) : $pos.pos;
|
|
790
|
+
tr.insert(at, blocks);
|
|
791
|
+
}
|
|
792
|
+
const end = at + blocks.reduce((size, node) => size + node.nodeSize, 0) - 1;
|
|
793
|
+
view.dispatch(tr.setSelection(TextSelection.create(tr.doc, end)).scrollIntoView());
|
|
794
|
+
this.focus();
|
|
795
|
+
}
|
|
796
|
+
// Removing a mark at a caret only clears the stored mark, so the whole marked
|
|
797
|
+
// run is selected for the command and the caret put back after it.
|
|
798
|
+
withMarkRunSelected(mark, command) {
|
|
799
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
800
|
+
const { selection, doc } = view.state;
|
|
801
|
+
if (!selection.empty)
|
|
802
|
+
return false;
|
|
803
|
+
const $pos = selection.$from;
|
|
804
|
+
const parent = $pos.parent;
|
|
805
|
+
const start = $pos.start();
|
|
806
|
+
let from = $pos.pos;
|
|
807
|
+
let to = $pos.pos;
|
|
808
|
+
parent.forEach((child, offset) => {
|
|
809
|
+
const childFrom = start + offset;
|
|
810
|
+
const childTo = childFrom + child.nodeSize;
|
|
811
|
+
if (!mark.isInSet(child.marks))
|
|
812
|
+
return;
|
|
813
|
+
if (childTo >= $pos.pos && childFrom <= to) {
|
|
814
|
+
from = Math.min(from, childFrom);
|
|
815
|
+
to = Math.max(to, childTo);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
if (from === to)
|
|
819
|
+
return false;
|
|
820
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, from, to)));
|
|
821
|
+
command();
|
|
822
|
+
const caret = Math.min($pos.pos, view.state.doc.content.size);
|
|
823
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, caret)));
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
// Milkdown's inline code command ignores a caret; a stored mark makes the
|
|
827
|
+
// next typed text code, the way bold and italic behave.
|
|
828
|
+
toggleInlineCode() {
|
|
829
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
830
|
+
const type = inlineCodeSchema.type(this.editor.ctx);
|
|
831
|
+
const run = () => {
|
|
832
|
+
this.editor.action(callCommand(toggleInlineCodeCommand.key));
|
|
833
|
+
};
|
|
834
|
+
const { selection, storedMarks } = view.state;
|
|
835
|
+
if (!selection.empty)
|
|
836
|
+
return run();
|
|
837
|
+
// A pending stored mark is only that; the span next to the caret is left alone.
|
|
838
|
+
if (storedMarks) {
|
|
839
|
+
const tr = type.isInSet(storedMarks)
|
|
840
|
+
? view.state.tr.removeStoredMark(type)
|
|
841
|
+
: view.state.tr.addStoredMark(type.create());
|
|
842
|
+
return view.dispatch(tr);
|
|
843
|
+
}
|
|
844
|
+
if (!type.isInSet(selection.$from.marks()))
|
|
845
|
+
view.dispatch(view.state.tr.addStoredMark(type.create()));
|
|
846
|
+
else
|
|
847
|
+
this.withMarkRunSelected(type, run);
|
|
848
|
+
}
|
|
849
|
+
list(kind) {
|
|
850
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
851
|
+
const { state } = view;
|
|
852
|
+
const { $from } = textBounds(state);
|
|
853
|
+
const wanted = kind === 'bulletList' ? 'bullet_list' : 'ordered_list';
|
|
854
|
+
const depth = innermostListDepth($from);
|
|
855
|
+
if (depth == null) {
|
|
856
|
+
const command = kind === 'bulletList'
|
|
857
|
+
? wrapInBulletListCommand
|
|
858
|
+
: wrapInOrderedListCommand;
|
|
859
|
+
this.editor.action(callCommand(command.key));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const list = $from.node(depth);
|
|
863
|
+
if (list.type.name === wanted) {
|
|
864
|
+
liftItem(state, view.dispatch);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
// The items carry the list kind too, and a bullet list whose items say
|
|
868
|
+
// ordered is turned back into one by the preset.
|
|
869
|
+
const pos = $from.before(depth);
|
|
870
|
+
const ordered = wanted === 'ordered_list';
|
|
871
|
+
const attrs = ordered
|
|
872
|
+
? { order: 1, spread: list.attrs.spread }
|
|
873
|
+
: { spread: list.attrs.spread };
|
|
874
|
+
let tr = state.tr.setNodeMarkup(pos, state.schema.nodes[wanted], attrs);
|
|
875
|
+
list.forEach((item, offset) => {
|
|
876
|
+
tr = tr.setNodeMarkup(pos + 1 + offset, undefined, {
|
|
877
|
+
...item.attrs,
|
|
878
|
+
listType: ordered ? 'ordered' : 'bullet',
|
|
879
|
+
label: ordered ? '1.' : '•',
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
view.dispatch(tr);
|
|
883
|
+
}
|
|
884
|
+
quote() {
|
|
885
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
886
|
+
const type = blockquoteSchema.type(this.editor.ctx);
|
|
887
|
+
const { $from, $to } = textBounds(view.state);
|
|
888
|
+
let range = $from.blockRange($to);
|
|
889
|
+
let wrapping = range && findWrapping(range, type);
|
|
890
|
+
if (!wrapping) {
|
|
891
|
+
// A list item cannot hold a quote, so the whole list is quoted instead.
|
|
892
|
+
const depth = outermostListDepth($from);
|
|
893
|
+
if (depth == null)
|
|
894
|
+
return;
|
|
895
|
+
const doc = view.state.doc;
|
|
896
|
+
range = new NodeRange(doc.resolve($from.before(depth)), doc.resolve($from.after(depth)), depth - 1);
|
|
897
|
+
wrapping = findWrapping(range, type);
|
|
898
|
+
}
|
|
899
|
+
if (!range || !wrapping)
|
|
900
|
+
return;
|
|
901
|
+
view.dispatch(view.state.tr.wrap(range, wrapping));
|
|
902
|
+
}
|
|
903
|
+
unquote() {
|
|
904
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
905
|
+
const { $from, $to } = textBounds(view.state);
|
|
906
|
+
const range = $from.blockRange($to, (node) => node.type.name === 'blockquote');
|
|
907
|
+
if (!range)
|
|
908
|
+
return;
|
|
909
|
+
const target = liftTarget(range);
|
|
910
|
+
if (target == null)
|
|
911
|
+
return;
|
|
912
|
+
view.dispatch(view.state.tr.lift(range, target));
|
|
913
|
+
}
|
|
914
|
+
format(command, arg) {
|
|
915
|
+
this.assertCurrent();
|
|
916
|
+
if (this.formatType === 'txt')
|
|
917
|
+
return;
|
|
918
|
+
const run = (cmd, payload) => this.editor.action(callCommand(cmd, payload));
|
|
919
|
+
const state = caretState(this.editor.ctx.get(editorViewCtx).state);
|
|
920
|
+
switch (command) {
|
|
921
|
+
case 'heading': {
|
|
922
|
+
const level = Number(arg ?? 1);
|
|
923
|
+
if (state.block.type === 'heading' && state.block.level === level)
|
|
924
|
+
run(turnIntoTextCommand.key);
|
|
925
|
+
else
|
|
926
|
+
run(wrapInHeadingCommand.key, level);
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
case 'paragraph':
|
|
930
|
+
run(turnIntoTextCommand.key);
|
|
931
|
+
break;
|
|
932
|
+
case 'bold':
|
|
933
|
+
run(toggleStrongCommand.key);
|
|
934
|
+
break;
|
|
935
|
+
case 'italic':
|
|
936
|
+
run(toggleEmphasisCommand.key);
|
|
937
|
+
break;
|
|
938
|
+
case 'strikethrough':
|
|
939
|
+
run(toggleStrikethroughCommand.key);
|
|
940
|
+
break;
|
|
941
|
+
case 'code':
|
|
942
|
+
this.toggleInlineCode();
|
|
943
|
+
break;
|
|
944
|
+
case 'codeBlock':
|
|
945
|
+
if (state.block.type === 'codeBlock')
|
|
946
|
+
run(turnIntoTextCommand.key);
|
|
947
|
+
else
|
|
948
|
+
run(createCodeBlockCommand.key, typeof arg === 'string' ? arg : '');
|
|
949
|
+
break;
|
|
950
|
+
case 'quote':
|
|
951
|
+
if (state.quoted)
|
|
952
|
+
this.unquote();
|
|
953
|
+
else
|
|
954
|
+
this.quote();
|
|
955
|
+
break;
|
|
956
|
+
case 'bulletList':
|
|
957
|
+
case 'orderedList':
|
|
958
|
+
this.list(command);
|
|
959
|
+
break;
|
|
960
|
+
case 'taskList':
|
|
961
|
+
toggleTaskList(this.editor.ctx);
|
|
962
|
+
break;
|
|
963
|
+
case 'link': {
|
|
964
|
+
const payload = typeof arg === 'string' ? { href: arg } : {};
|
|
965
|
+
const toggle = () => run(toggleLinkCommand.key, payload);
|
|
966
|
+
if (!state.marks.includes('link') ||
|
|
967
|
+
!this.withMarkRunSelected(linkSchema.type(this.editor.ctx), toggle))
|
|
968
|
+
toggle();
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
this.focus();
|
|
973
|
+
}
|
|
974
|
+
}
|