@aicayzer/inkkit 0.0.16 → 0.0.18
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 +2 -0
- package/dist/callouts.d.ts +0 -1
- package/dist/callouts.js +12 -35
- package/dist/code-fences.js +6 -1
- package/dist/comments.d.ts +21 -0
- package/dist/comments.js +55 -4
- package/dist/core.d.ts +7 -0
- package/dist/core.js +2 -0
- package/dist/cursor.d.ts +2 -0
- package/dist/cursor.js +40 -0
- package/dist/dialect.js +109 -11
- package/dist/drop-placement.d.ts +19 -0
- package/dist/drop-placement.js +47 -0
- package/dist/editor.d.ts +32 -1
- package/dist/editor.js +293 -61
- package/dist/html-import.d.ts +13 -0
- package/dist/html-import.js +477 -0
- package/dist/image-blocks.d.ts +15 -0
- package/dist/image-blocks.js +44 -1
- package/dist/index.d.ts +1 -7
- package/dist/index.js +3 -2
- package/dist/link-safety.d.ts +17 -0
- package/dist/link-safety.js +29 -0
- package/dist/literals.d.ts +1 -0
- package/dist/literals.js +14 -9
- package/dist/mermaid.d.ts +21 -1
- package/dist/mermaid.js +92 -9
- package/dist/paste-html.d.ts +8 -0
- package/dist/paste-html.js +21 -0
- package/dist/paste.d.ts +7 -0
- package/dist/paste.js +152 -43
- package/dist/preserve.d.ts +19 -2
- package/dist/preserve.js +861 -124
- package/dist/print.js +2 -5
- package/dist/references.d.ts +2 -0
- package/dist/references.js +63 -13
- package/dist/scroll.d.ts +15 -0
- package/dist/scroll.js +27 -0
- package/dist/spreadsheet.d.ts +6 -1
- package/dist/spreadsheet.js +29 -15
- package/dist/style.css +11 -19
- package/dist/tables.js +24 -2
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ A Markdown editor for the web and for apps that host a web view. It edits format
|
|
|
15
15
|
npm install @aicayzer/inkkit
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
An app that does not render diagrams can import from `@aicayzer/inkkit/core` instead. That entry leaves Mermaid out of the app's bundle and keeps diagrams as editable source. See [Builds without diagrams](docs/api.md#builds-without-diagrams).
|
|
19
|
+
|
|
18
20
|
## Use
|
|
19
21
|
|
|
20
22
|
```ts
|
package/dist/callouts.d.ts
CHANGED
package/dist/callouts.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $node, $prose, $remark } from '@milkdown/kit/utils';
|
|
2
2
|
import { editorLabels } from './labels.js';
|
|
3
|
+
import { singleLine } from './references.js';
|
|
3
4
|
import { remarkCtx } from '@milkdown/kit/core';
|
|
4
5
|
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
5
6
|
import { defaultHandlers } from 'mdast-util-to-markdown';
|
|
@@ -37,7 +38,7 @@ function remarkCallouts() {
|
|
|
37
38
|
});
|
|
38
39
|
return (tree, file) => {
|
|
39
40
|
const source = String(file.value).replace(/^\uFEFF/, '');
|
|
40
|
-
const visit = (node
|
|
41
|
+
const visit = (node) => {
|
|
41
42
|
if (node.type === 'blockquote') {
|
|
42
43
|
const start = node.position?.start.offset, end = node.position?.end.offset;
|
|
43
44
|
const raw = start == null || end == null ? '' : source.slice(start, end);
|
|
@@ -117,14 +118,13 @@ function remarkCallouts() {
|
|
|
117
118
|
.slice(header.length - (match[4]?.length ?? 0))
|
|
118
119
|
.trimEnd(),
|
|
119
120
|
titleSource: match[4]?.trimEnd() ?? '',
|
|
120
|
-
listPlaceholder: parent?.type === 'listItem' && index === 0,
|
|
121
121
|
children: children.map((child) => visit(child)),
|
|
122
122
|
position: node.position,
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
if ('children' in node)
|
|
127
|
-
node.children = node.children.map((child
|
|
127
|
+
node.children = node.children.map((child) => visit(child));
|
|
128
128
|
return node;
|
|
129
129
|
};
|
|
130
130
|
tree.children = tree.children.map((node) => visit(node));
|
|
@@ -141,7 +141,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
|
|
|
141
141
|
fold: { default: '' },
|
|
142
142
|
title: { default: '' },
|
|
143
143
|
titleSource: { default: null },
|
|
144
|
-
listPlaceholder: { default: false },
|
|
145
144
|
},
|
|
146
145
|
parseDOM: [
|
|
147
146
|
{
|
|
@@ -152,34 +151,25 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
|
|
|
152
151
|
const kind = (dom.getAttribute('data-inkkit-callout') ?? '').toUpperCase();
|
|
153
152
|
const marker = dom.getAttribute('data-inkkit-marker') ?? `[!${kind}]`;
|
|
154
153
|
const fold = dom.getAttribute('data-inkkit-fold') ?? '';
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
154
|
+
// A title read from the visible text is written with its punctuation
|
|
155
|
+
// escaped, so it reads back as the same plain text.
|
|
156
|
+
const visibleTitle = singleLine(dom.querySelector('.inkkit-callout-title')?.textContent ?? '').trim();
|
|
157
|
+
const titleSource = dom.getAttribute('data-inkkit-title') ??
|
|
158
|
+
visibleTitle.replace(/[!-/:-@[-`{-~]/g, '\\$&');
|
|
159
|
+
const titleHeader = plainHeader(ctx.get(remarkCtx), `${marker} ${titleSource}`);
|
|
161
160
|
if (!kinds.has(kind) ||
|
|
162
161
|
!/^\[![A-Za-z]+\]$/.test(marker) ||
|
|
163
162
|
marker.slice(2, -1).toUpperCase() !== kind ||
|
|
164
163
|
!['', '+', '-'].includes(fold) ||
|
|
165
|
-
(titleSource
|
|
166
|
-
|
|
164
|
+
/[\r\n]/.test(titleSource) ||
|
|
165
|
+
titleHeader == null)
|
|
167
166
|
return false;
|
|
168
167
|
return {
|
|
169
168
|
kind,
|
|
170
169
|
marker,
|
|
171
170
|
fold,
|
|
172
|
-
title: titleHeader
|
|
173
|
-
? (dom.querySelector('.inkkit-callout-title')?.textContent ?? '')
|
|
174
|
-
: titleHeader.slice(marker.length + 1).trimEnd(),
|
|
171
|
+
title: titleHeader.slice(marker.length + 1).trimEnd(),
|
|
175
172
|
titleSource,
|
|
176
|
-
listPlaceholder: placeholder == null
|
|
177
|
-
? dom.parentElement?.tagName === 'LI' &&
|
|
178
|
-
(!previous ||
|
|
179
|
-
(previous.tagName === 'P' &&
|
|
180
|
-
!previous.textContent &&
|
|
181
|
-
!previous.childElementCount))
|
|
182
|
-
: placeholder === 'true',
|
|
183
173
|
};
|
|
184
174
|
},
|
|
185
175
|
},
|
|
@@ -192,7 +182,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
|
|
|
192
182
|
'data-inkkit-marker': node.attrs.marker,
|
|
193
183
|
'data-inkkit-fold': node.attrs.fold,
|
|
194
184
|
'data-inkkit-title': node.attrs.titleSource ?? node.attrs.title,
|
|
195
|
-
'data-inkkit-list-placeholder': String(node.attrs.listPlaceholder),
|
|
196
185
|
},
|
|
197
186
|
[
|
|
198
187
|
'strong',
|
|
@@ -213,7 +202,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
|
|
|
213
202
|
fold: node.fold,
|
|
214
203
|
title: node.title,
|
|
215
204
|
titleSource: node.titleSource,
|
|
216
|
-
listPlaceholder: node.listPlaceholder,
|
|
217
205
|
});
|
|
218
206
|
state.next(node.children);
|
|
219
207
|
state.closeNode();
|
|
@@ -222,17 +210,6 @@ export const calloutSchema = $node('inkkit_callout', (ctx) => ({
|
|
|
222
210
|
toMarkdown: {
|
|
223
211
|
match: (node) => node.type.name === 'inkkit_callout',
|
|
224
212
|
runner(state, node) {
|
|
225
|
-
const parent = state.top();
|
|
226
|
-
const first = parent?.children?.[0];
|
|
227
|
-
// Lists require a leading paragraph even when their authored first block is a callout.
|
|
228
|
-
if (node.attrs.listPlaceholder &&
|
|
229
|
-
parent?.type === 'listItem' &&
|
|
230
|
-
parent.children?.length === 1 &&
|
|
231
|
-
first?.type === 'paragraph' &&
|
|
232
|
-
first.children?.length === 1 &&
|
|
233
|
-
first.children[0]?.type === 'html' &&
|
|
234
|
-
first.children[0].value === '<br />')
|
|
235
|
-
parent.children = [];
|
|
236
213
|
state.openNode('inkkitCallout', undefined, { ...node.attrs });
|
|
237
214
|
state.next(node.content);
|
|
238
215
|
state.closeNode();
|
package/dist/code-fences.js
CHANGED
|
@@ -11,6 +11,8 @@ function readFence(value) {
|
|
|
11
11
|
fence.marker.length <= 30000 &&
|
|
12
12
|
typeof fence.info === 'string' &&
|
|
13
13
|
!/[\r\n]/.test(fence.info) &&
|
|
14
|
+
// A backtick in the info string of a backtick fence is not a fence.
|
|
15
|
+
!(fence.marker[0] === '`' && fence.info.includes('`')) &&
|
|
14
16
|
typeof fence.close === 'string' &&
|
|
15
17
|
(!fence.close ||
|
|
16
18
|
new RegExp(`^${fence.marker[0]}{${fence.marker.length},}$`).test(fence.close)) &&
|
|
@@ -61,6 +63,9 @@ export const preservingCodeBlocks = codeBlockSchema.extendSchema((base) => (ctx)
|
|
|
61
63
|
let authoredFence = null;
|
|
62
64
|
try {
|
|
63
65
|
authoredFence = readFence(JSON.parse(dom.getAttribute('data-inkkit-fence') ?? 'null'));
|
|
66
|
+
// An unclosed fence would take in the content that follows it.
|
|
67
|
+
if (!authoredFence?.close)
|
|
68
|
+
authoredFence = null;
|
|
64
69
|
}
|
|
65
70
|
catch {
|
|
66
71
|
/* Ordinary code stays usable without provenance. */
|
|
@@ -108,7 +113,7 @@ export const fencedCodeHandler = (node, parent, state, info) => {
|
|
|
108
113
|
return defaultHandlers.code(node, parent, state, info);
|
|
109
114
|
const character = fence.marker[0];
|
|
110
115
|
let length = fence.marker.length;
|
|
111
|
-
const body = node.value.replace(/\r\n?/g, '\n');
|
|
116
|
+
const body = (node.value ?? '').replace(/\r\n?/g, '\n');
|
|
112
117
|
for (const line of body.split('\n')) {
|
|
113
118
|
const run = new RegExp(`^ {0,3}(${character}+)[ \\t]*$`).exec(line);
|
|
114
119
|
if (run)
|
package/dist/comments.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment, type Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
|
+
import { type Transaction } from '@milkdown/kit/prose/state';
|
|
2
3
|
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
3
4
|
import type { Literal } from 'mdast';
|
|
4
5
|
export type CommentSyntax = 'html' | 'obsidian';
|
|
@@ -49,6 +50,26 @@ export declare const commentBlock: import("@milkdown/kit/utils").$Node;
|
|
|
49
50
|
export declare const commentVisibility: import("@milkdown/kit/utils").$Prose;
|
|
50
51
|
export declare function setCommentVisibility(view: EditorView, visible: boolean): void;
|
|
51
52
|
export declare const comments: (import("@milkdown/kit/utils").$Node | import("@milkdown/kit/utils").$Prose)[];
|
|
53
|
+
/**
|
|
54
|
+
* Whether text inside a comment ends it early for a reader other than InkKit.
|
|
55
|
+
* HTML ends a comment at `-->` or `--!>`, and treats a start of `>` or `->` as
|
|
56
|
+
* an empty comment. A `%%` comment ends at its next `%%`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function commentTextEndsEarly(syntax: string, text: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Refuses an edit that gives a comment text which ends it early. A comment
|
|
61
|
+
* that already held that text before the edit is left alone, so a note that
|
|
62
|
+
* has one still loads and can be edited elsewhere.
|
|
63
|
+
*/
|
|
64
|
+
export declare function assertCommentsEndWhereWritten(before: ProseNode, after: ProseNode): void;
|
|
65
|
+
/** Marks a transaction that replaces the document with source given as written. */
|
|
66
|
+
export declare const sourceReplacement = "inkkit:sourceReplacement";
|
|
67
|
+
/**
|
|
68
|
+
* Whether an edit is typed or inserted by a person in the formatted editor. A
|
|
69
|
+
* history step restores a document the editor already accepted, and a source
|
|
70
|
+
* replacement is the author's own text, so neither is checked.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isWrittenEdit(tr: Transaction): boolean;
|
|
52
73
|
export declare function isComment(node: ProseNode): boolean;
|
|
53
74
|
export declare function shareableFragment(content: Fragment): Fragment;
|
|
54
75
|
export declare function commentSelectionContent(doc: ProseNode, from: number, to: number, content: Fragment): Fragment;
|
package/dist/comments.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $node, $prose, $remark } from '@milkdown/kit/utils';
|
|
2
2
|
import { Fragment } from '@milkdown/kit/prose/model';
|
|
3
|
+
import { isHistoryTransaction } from '@milkdown/kit/prose/history';
|
|
3
4
|
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
4
5
|
import { isImageBlock } from './image-blocks.js';
|
|
5
6
|
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
@@ -322,11 +323,16 @@ function commentNode(inline) {
|
|
|
322
323
|
{
|
|
323
324
|
tag: `${inline ? 'span' : 'div'}[data-inkkit-comment]`,
|
|
324
325
|
preserveWhitespace: 'full',
|
|
325
|
-
getAttrs
|
|
326
|
-
syntax
|
|
326
|
+
getAttrs(dom) {
|
|
327
|
+
const syntax = dom.getAttribute('data-inkkit-comment') === 'obsidian'
|
|
327
328
|
? 'obsidian'
|
|
328
|
-
: 'html'
|
|
329
|
-
|
|
329
|
+
: 'html';
|
|
330
|
+
// Text that would end the comment early would leave the rest as
|
|
331
|
+
// Markdown, so it stays plain text.
|
|
332
|
+
if (commentTextEndsEarly(syntax, dom.textContent ?? ''))
|
|
333
|
+
return false;
|
|
334
|
+
return { syntax };
|
|
335
|
+
},
|
|
330
336
|
},
|
|
331
337
|
],
|
|
332
338
|
toDOM: (node) => [
|
|
@@ -384,6 +390,51 @@ export function setCommentVisibility(view, visible) {
|
|
|
384
390
|
.setMeta('addToHistory', false));
|
|
385
391
|
}
|
|
386
392
|
export const comments = [commentInline, commentBlock, commentVisibility].flat();
|
|
393
|
+
/**
|
|
394
|
+
* Whether text inside a comment ends it early for a reader other than InkKit.
|
|
395
|
+
* HTML ends a comment at `-->` or `--!>`, and treats a start of `>` or `->` as
|
|
396
|
+
* an empty comment. A `%%` comment ends at its next `%%`.
|
|
397
|
+
*/
|
|
398
|
+
export function commentTextEndsEarly(syntax, text) {
|
|
399
|
+
return syntax === 'obsidian'
|
|
400
|
+
? text.includes('%%')
|
|
401
|
+
: /^-?>/.test(text) || text.includes('-->') || text.includes('--!>');
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Refuses an edit that gives a comment text which ends it early. A comment
|
|
405
|
+
* that already held that text before the edit is left alone, so a note that
|
|
406
|
+
* has one still loads and can be edited elsewhere.
|
|
407
|
+
*/
|
|
408
|
+
export function assertCommentsEndWhereWritten(before, after) {
|
|
409
|
+
const unsafe = (doc, visit) => doc.descendants((node) => {
|
|
410
|
+
if (!isComment(node))
|
|
411
|
+
return true;
|
|
412
|
+
if (commentTextEndsEarly(node.attrs.syntax, node.textContent))
|
|
413
|
+
visit(`${node.attrs.syntax}:${node.textContent}`);
|
|
414
|
+
return false;
|
|
415
|
+
});
|
|
416
|
+
let allowed;
|
|
417
|
+
unsafe(after, (key) => {
|
|
418
|
+
if (!allowed) {
|
|
419
|
+
allowed = new Map();
|
|
420
|
+
unsafe(before, (known) => allowed.set(known, (allowed.get(known) ?? 0) + 1));
|
|
421
|
+
}
|
|
422
|
+
const remaining = allowed.get(key) ?? 0;
|
|
423
|
+
if (remaining === 0)
|
|
424
|
+
throw new Error('A comment cannot contain text that would end it early');
|
|
425
|
+
allowed.set(key, remaining - 1);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/** Marks a transaction that replaces the document with source given as written. */
|
|
429
|
+
export const sourceReplacement = 'inkkit:sourceReplacement';
|
|
430
|
+
/**
|
|
431
|
+
* Whether an edit is typed or inserted by a person in the formatted editor. A
|
|
432
|
+
* history step restores a document the editor already accepted, and a source
|
|
433
|
+
* replacement is the author's own text, so neither is checked.
|
|
434
|
+
*/
|
|
435
|
+
export function isWrittenEdit(tr) {
|
|
436
|
+
return !isHistoryTransaction(tr) && !tr.getMeta(sourceReplacement);
|
|
437
|
+
}
|
|
387
438
|
export function isComment(node) {
|
|
388
439
|
return (node.type.name === 'comment_inline' || node.type.name === 'comment_block');
|
|
389
440
|
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { InkKitEditor } from './editor.js';
|
|
2
|
+
export type { EditorEvents, EditorOptions, TextInputPreferences, CommandState, EditingMode, CaretState, Mark, Block, FormatCommand, Keymap, } from './editor.js';
|
|
3
|
+
export type { TableCommand, TableOptions } from './tables.js';
|
|
4
|
+
export type { Heading } from './outline.js';
|
|
5
|
+
export { InkKitError } from './types.js';
|
|
6
|
+
export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, WikiLinkReference, WikiLinkAdapter, FileReference, FileContext, FilePresentation, FileAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, ClipboardDiagram, PrintableDocument, PrintableWarning, TextRange, ReadableTextSnapshot, TextRect, ViewportInsets, ViewportOptions, ViewportSnapshot, } from './types.js';
|
|
7
|
+
export type { EditorLabels } from './labels.js';
|
package/dist/core.js
ADDED
package/dist/cursor.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { dropIndicatorConfig } from '@milkdown/kit/plugin/cursor';
|
|
2
|
+
export declare const cursor: (import("@milkdown/kit/utils").$Ctx<dropIndicatorConfig, "dropIndicatorConfig"> | import("@milkdown/kit/utils").$Ctx<import("@milkdown/kit/plugin/cursor").DropIndicatorState, "dropIndicatorState"> | import("@milkdown/kit/utils").$Prose)[];
|
package/dist/cursor.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { dropIndicatorConfig, dropIndicatorDOMPlugin, dropIndicatorState, gapCursorPlugin, } from '@milkdown/kit/plugin/cursor';
|
|
2
|
+
import { Plugin } from '@milkdown/kit/prose/state';
|
|
3
|
+
import { $prose } from '@milkdown/kit/utils';
|
|
4
|
+
import { createDropIndicatorPlugin } from 'prosemirror-drop-indicator';
|
|
5
|
+
// Milkdown's drop indicator, except that it does not hide once the editor is
|
|
6
|
+
// gone. The indicator hides 30 ms after a drop or drag end, and setting its
|
|
7
|
+
// state after the editor is destroyed throws.
|
|
8
|
+
const dropIndicator = $prose((ctx) => {
|
|
9
|
+
let live = false;
|
|
10
|
+
const plugin = createDropIndicatorPlugin({
|
|
11
|
+
onShow: (options) => ctx.set(dropIndicatorState.key, options),
|
|
12
|
+
onHide: () => {
|
|
13
|
+
if (live)
|
|
14
|
+
ctx.set(dropIndicatorState.key, null);
|
|
15
|
+
},
|
|
16
|
+
onDrag: () => true,
|
|
17
|
+
});
|
|
18
|
+
const view = plugin.spec.view;
|
|
19
|
+
return new Plugin({
|
|
20
|
+
...plugin.spec,
|
|
21
|
+
view(editorView) {
|
|
22
|
+
live = true;
|
|
23
|
+
const inner = view(editorView);
|
|
24
|
+
return {
|
|
25
|
+
...inner,
|
|
26
|
+
destroy() {
|
|
27
|
+
live = false;
|
|
28
|
+
inner.destroy?.();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
export const cursor = [
|
|
35
|
+
gapCursorPlugin,
|
|
36
|
+
dropIndicatorConfig,
|
|
37
|
+
dropIndicatorState,
|
|
38
|
+
dropIndicatorDOMPlugin,
|
|
39
|
+
dropIndicator,
|
|
40
|
+
].flat();
|
package/dist/dialect.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core';
|
|
2
2
|
import { commonmark, docSchema, hardbreakSchema, imageSchema, paragraphSchema, remarkInlineLinkPlugin, } from '@milkdown/kit/preset/commonmark';
|
|
3
3
|
import { extendListItemSchemaForTask, strikethroughAttr, strikethroughInputRule, strikethroughKeymap, strikethroughSchema, toggleStrikethroughCommand, wrapInTaskListInputRule, tableSchema, tableHeaderRowSchema, tableHeaderSchema, tableRowSchema, tableCellSchema, tableEditingPlugin, tableKeymap, goToNextTableCellCommand, goToPrevTableCellCommand, insertTableCommand, addRowBeforeCommand, addRowAfterCommand, addColBeforeCommand, addColAfterCommand, selectRowCommand, selectColCommand, deleteSelectedCellsCommand, setAlignCommand, exitTable, } from '@milkdown/kit/preset/gfm';
|
|
4
|
-
import { $node, $remark } from '@milkdown/kit/utils';
|
|
4
|
+
import { $node, $prose, $remark } from '@milkdown/kit/utils';
|
|
5
|
+
import { keymap } from '@milkdown/kit/prose/keymap';
|
|
5
6
|
import { autolinkInputRule } from './autolink.js';
|
|
7
|
+
import { isScriptAddress } from './link-safety.js';
|
|
6
8
|
import { sourceAttribute } from './source.js';
|
|
7
9
|
import { linkedSyntax, wikiLinkSchema, namedFileMarkdown, fileReference, } from './linked-syntax.js';
|
|
8
10
|
import { normalizeTableAlignment, normalizeTableColumnAlignment, } from './table-alignment.js';
|
|
9
11
|
import { preservingCodeBlocks, remarkCodeFences, fencedCodeHandler, } from './code-fences.js';
|
|
10
12
|
import { literalBlock, createLiteralPreservation } from './literals.js';
|
|
11
|
-
import { imageAttrs, imageBlocks, markdownImageAttrs, readParagraph, } from './image-blocks.js';
|
|
13
|
+
import { imageAttrs, imageBlockName, imageBlocks, markdownImageAttrs, readParagraph, } from './image-blocks.js';
|
|
12
14
|
import { references, remarkReferencesPlugin } from './references.js';
|
|
13
15
|
import { callouts, remarkCalloutsPlugin } from './callouts.js';
|
|
14
16
|
import { inlineHighlight } from './inline-highlight.js';
|
|
@@ -47,7 +49,7 @@ function textAt(parent, node, offset) {
|
|
|
47
49
|
}
|
|
48
50
|
/** A link whose text is its own URL is written as the bare URL, the way it was typed. Only where reading
|
|
49
51
|
* it back gives the same link again: nothing may run into it at either end. */
|
|
50
|
-
function writesBare(node, parent) {
|
|
52
|
+
function writesBare(node, parent, inCell) {
|
|
51
53
|
const [child, ...rest] = node.children;
|
|
52
54
|
if (node.title ||
|
|
53
55
|
rest.length > 0 ||
|
|
@@ -64,12 +66,16 @@ function writesBare(node, parent) {
|
|
|
64
66
|
const after = textAt(parent, node, 1);
|
|
65
67
|
if (after && (after.type !== 'text' || !afterURL.test(after.value)))
|
|
66
68
|
return false;
|
|
67
|
-
|
|
69
|
+
// Spaces that end a cell are written as character references, which a bare URL would run into.
|
|
70
|
+
return !(inCell &&
|
|
71
|
+
after?.type === 'text' &&
|
|
72
|
+
/^[ \t]+$/.test(after.value) &&
|
|
73
|
+
parent?.children.at(-1) === after);
|
|
68
74
|
}
|
|
69
|
-
const link = (node, parent, state, info) => writesBare(node, parent)
|
|
75
|
+
const link = (node, parent, state, info) => writesBare(node, parent, state.stack.includes('tableCell'))
|
|
70
76
|
? node.url
|
|
71
77
|
: defaultHandlers.link(node, parent, state, info);
|
|
72
|
-
link.peek = (node, parent, state) => writesBare(node, parent)
|
|
78
|
+
link.peek = (node, parent, state) => writesBare(node, parent, state.stack.includes('tableCell'))
|
|
73
79
|
? node.url[0]
|
|
74
80
|
: defaultHandlers.link.peek(node, parent, state);
|
|
75
81
|
function remarkDialect() {
|
|
@@ -94,14 +100,45 @@ function remarkDialect() {
|
|
|
94
100
|
handlers: {
|
|
95
101
|
text(node, parent, state, info) {
|
|
96
102
|
const value = defaultHandlers.text(node, parent, state, info);
|
|
97
|
-
// GFM trims
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
// GFM trims spaces and tabs at the edges of a cell on reopening, so
|
|
104
|
+
// only the start of its first node and the end of its last need
|
|
105
|
+
// encoding. Inside a mark or link, delimiters stand before the pipe.
|
|
106
|
+
if (!state.stack.includes('tableCell') ||
|
|
107
|
+
(parent?.type !== 'paragraph' && parent?.type !== 'tableCell'))
|
|
108
|
+
return value;
|
|
109
|
+
const siblings = parent.children;
|
|
110
|
+
const encode = (spaces) => spaces.replaceAll(' ', ' ').replaceAll('\t', '	');
|
|
111
|
+
return value
|
|
112
|
+
.replace(/^[ \t]+/, (spaces) => siblings[0] === node ? encode(spaces) : spaces)
|
|
113
|
+
.replace(/[ \t]+$/, (spaces) => siblings.at(-1) === node ? encode(spaces) : spaces);
|
|
114
|
+
},
|
|
115
|
+
thematicBreak(node, parent, state) {
|
|
116
|
+
const value = defaultHandlers.thematicBreak(node, parent, state);
|
|
117
|
+
// A document opening with `---` reads back as front matter when
|
|
118
|
+
// another `---` line follows.
|
|
119
|
+
return parent?.type === 'root' &&
|
|
120
|
+
parent.children.length > 1 &&
|
|
121
|
+
parent.children[0] === node
|
|
122
|
+
? value.replaceAll('-', '*')
|
|
100
123
|
: value;
|
|
101
124
|
},
|
|
102
125
|
},
|
|
103
126
|
});
|
|
104
127
|
add('toMarkdownExtensions', { handlers: { link, code: fencedCodeHandler } });
|
|
128
|
+
return padShortRows;
|
|
129
|
+
}
|
|
130
|
+
// GFM reads a row with fewer cells than its header as ending in empty cells.
|
|
131
|
+
// The editor holds them from the start; otherwise table editing adds them
|
|
132
|
+
// after the first edit, and again after every Undo.
|
|
133
|
+
function padShortRows(node) {
|
|
134
|
+
if (node.type === 'table') {
|
|
135
|
+
const width = node.children[0]?.children.length ?? 0;
|
|
136
|
+
for (const row of node.children)
|
|
137
|
+
while (row.children.length < width)
|
|
138
|
+
row.children.push({ type: 'tableCell', children: [] });
|
|
139
|
+
}
|
|
140
|
+
if ('children' in node)
|
|
141
|
+
node.children.forEach(padShortRows);
|
|
105
142
|
}
|
|
106
143
|
export const remarkDialectPlugin = $remark('remarkDialect', () => remarkDialect);
|
|
107
144
|
// Reference links retain their authored form instead of expanding to inline links.
|
|
@@ -190,8 +227,11 @@ const normalizedImages = imageSchema.extendSchema((base) => (ctx) => {
|
|
|
190
227
|
{
|
|
191
228
|
tag: 'img[src]',
|
|
192
229
|
getAttrs(dom) {
|
|
230
|
+
const src = dom.getAttribute('src') ?? '';
|
|
231
|
+
if (isScriptAddress(src))
|
|
232
|
+
return false;
|
|
193
233
|
return {
|
|
194
|
-
src
|
|
234
|
+
src,
|
|
195
235
|
alt: dom.getAttribute('alt') ?? '',
|
|
196
236
|
title: dom.getAttribute('title') ?? '',
|
|
197
237
|
};
|
|
@@ -224,6 +264,61 @@ const normalizedImages = imageSchema.extendSchema((base) => (ctx) => {
|
|
|
224
264
|
},
|
|
225
265
|
};
|
|
226
266
|
});
|
|
267
|
+
// The preset drops a cell's final break, and a paragraph writes an empty one as
|
|
268
|
+
// the spacer `<br />`; either reads back differently, so a cell writes its
|
|
269
|
+
// paragraph's content directly.
|
|
270
|
+
function cellToMarkdown(base) {
|
|
271
|
+
return {
|
|
272
|
+
...base,
|
|
273
|
+
runner(state, node) {
|
|
274
|
+
if (node.childCount === 1 && node.firstChild.type.name === 'paragraph')
|
|
275
|
+
state.openNode('tableCell').next(node.firstChild.content).closeNode();
|
|
276
|
+
else
|
|
277
|
+
base.runner(state, node);
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// A cell cannot hold a line ending, so Shift-Enter there inserts the `<br />`
|
|
282
|
+
// a cell reads back. The preset's command would instead split the paragraph
|
|
283
|
+
// when it already ends in a break, which reshapes the table.
|
|
284
|
+
const cellBreakKeymap = $prose(() => keymap({
|
|
285
|
+
'Shift-Enter': (state, dispatch) => {
|
|
286
|
+
const { $from } = state.selection;
|
|
287
|
+
if (!/^table_(cell|header)$/.test($from.node(-1)?.type.name ?? ''))
|
|
288
|
+
return false;
|
|
289
|
+
dispatch?.(state.tr
|
|
290
|
+
// A break inside a mark or link would be inline HTML there, which
|
|
291
|
+
// a cell does not read back as a break.
|
|
292
|
+
.replaceSelectionWith(state.schema.nodes.hardbreak.create({ isHTML: true }), false)
|
|
293
|
+
.scrollIntoView());
|
|
294
|
+
return true;
|
|
295
|
+
},
|
|
296
|
+
}));
|
|
297
|
+
// A list item that opens with a block, such as a code fence, is held with the
|
|
298
|
+
// empty first paragraph the schema requires. Written out, that paragraph would
|
|
299
|
+
// be a spacer `<br />` line; without it the item reads back the same.
|
|
300
|
+
const listItems = extendListItemSchemaForTask.extendSchema((previous) => (ctx) => {
|
|
301
|
+
const schema = previous(ctx);
|
|
302
|
+
return {
|
|
303
|
+
...schema,
|
|
304
|
+
toMarkdown: {
|
|
305
|
+
...schema.toMarkdown,
|
|
306
|
+
runner(state, node) {
|
|
307
|
+
const [first, second] = [node.firstChild, node.maybeChild(1)];
|
|
308
|
+
// A task item's checkbox is written on its first line.
|
|
309
|
+
if (node.attrs.checked == null &&
|
|
310
|
+
first?.type.name === 'paragraph' &&
|
|
311
|
+
first.content.size === 0 &&
|
|
312
|
+
second &&
|
|
313
|
+
second.type.name !== 'paragraph' &&
|
|
314
|
+
// An image block reads back as a paragraph in that position.
|
|
315
|
+
second.type.name !== imageBlockName)
|
|
316
|
+
node = node.copy(node.content.cut(first.nodeSize));
|
|
317
|
+
schema.toMarkdown.runner(state, node);
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
});
|
|
227
322
|
export const tables = [
|
|
228
323
|
tableSchema.extendSchema((previous) => (ctx) => ({
|
|
229
324
|
...previous(ctx),
|
|
@@ -235,6 +330,7 @@ export const tables = [
|
|
|
235
330
|
return {
|
|
236
331
|
...schema,
|
|
237
332
|
attrs: { ...schema.attrs, alignment: { default: null } },
|
|
333
|
+
toMarkdown: cellToMarkdown(schema.toMarkdown),
|
|
238
334
|
parseDOM: schema.parseDOM?.map((rule) => 'tag' in rule
|
|
239
335
|
? {
|
|
240
336
|
...rule,
|
|
@@ -257,6 +353,7 @@ export const tables = [
|
|
|
257
353
|
return {
|
|
258
354
|
...schema,
|
|
259
355
|
attrs: { ...schema.attrs, alignment: { default: null } },
|
|
356
|
+
toMarkdown: cellToMarkdown(schema.toMarkdown),
|
|
260
357
|
parseDOM: schema.parseDOM?.map((rule) => 'tag' in rule
|
|
261
358
|
? {
|
|
262
359
|
...rule,
|
|
@@ -290,6 +387,7 @@ export const tables = [
|
|
|
290
387
|
].flat();
|
|
291
388
|
export function createDialect(images, wikiLinks = false, files = false) {
|
|
292
389
|
return [
|
|
390
|
+
cellBreakKeymap,
|
|
293
391
|
remarkCodeFences,
|
|
294
392
|
remarkReferencesPlugin,
|
|
295
393
|
remarkCalloutsPlugin,
|
|
@@ -316,7 +414,7 @@ export function createDialect(images, wikiLinks = false, files = false) {
|
|
|
316
414
|
normalizedImages,
|
|
317
415
|
imageBlocks,
|
|
318
416
|
autolinkInputRule,
|
|
319
|
-
|
|
417
|
+
listItems,
|
|
320
418
|
strikethroughAttr,
|
|
321
419
|
strikethroughSchema,
|
|
322
420
|
strikethroughInputRule,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Node } from '@milkdown/kit/prose/model';
|
|
2
|
+
import { Selection } from '@milkdown/kit/prose/state';
|
|
3
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
4
|
+
/**
|
|
5
|
+
* Where blocks dropped at a point go: in place of an empty top-level
|
|
6
|
+
* paragraph at or beside the point, otherwise after the top-level block under the
|
|
7
|
+
* point, or beside a top-level leaf such as an image rather than inside it. The result is a range
|
|
8
|
+
* to replace; an insertion point has `from` equal to `to`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function blockDropRange(view: EditorView, x: number, y: number): {
|
|
11
|
+
from: number;
|
|
12
|
+
to: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A caret just after inserted blocks that end at `pos`: at the end of the
|
|
16
|
+
* last text block, or a gap cursor after a block that holds no text, such as
|
|
17
|
+
* a rule or table. It never selects a node, so typing never replaces one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function caretAfter(doc: Node, pos: number): Selection;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { GapCursor } from '@milkdown/kit/prose/gapcursor';
|
|
2
|
+
import { Selection, TextSelection } from '@milkdown/kit/prose/state';
|
|
3
|
+
/**
|
|
4
|
+
* Where blocks dropped at a point go: in place of an empty top-level
|
|
5
|
+
* paragraph at or beside the point, otherwise after the top-level block under the
|
|
6
|
+
* point, or beside a top-level leaf such as an image rather than inside it. The result is a range
|
|
7
|
+
* to replace; an insertion point has `from` equal to `to`.
|
|
8
|
+
*/
|
|
9
|
+
export function blockDropRange(view, x, y) {
|
|
10
|
+
const { doc, selection } = view.state;
|
|
11
|
+
const hit = view.posAtCoords({ left: x, top: y });
|
|
12
|
+
const $pos = doc.resolve(hit?.pos ?? selection.from);
|
|
13
|
+
// Only an empty paragraph is a placeholder; an empty heading or code block
|
|
14
|
+
// was written.
|
|
15
|
+
const empty = (node) => node?.type.name === 'paragraph' && node.content.size === 0;
|
|
16
|
+
if ($pos.depth === 1 && empty($pos.parent))
|
|
17
|
+
return { from: $pos.before(1), to: $pos.after(1) };
|
|
18
|
+
if ($pos.depth > 0)
|
|
19
|
+
return { from: $pos.after(1), to: $pos.after(1) };
|
|
20
|
+
// A point in the margin beside an empty paragraph, as in an empty note.
|
|
21
|
+
if (empty($pos.nodeAfter))
|
|
22
|
+
return { from: $pos.pos, to: $pos.pos + $pos.nodeAfter.nodeSize };
|
|
23
|
+
if (empty($pos.nodeBefore))
|
|
24
|
+
return { from: $pos.pos - $pos.nodeBefore.nodeSize, to: $pos.pos };
|
|
25
|
+
const leaf = hit && hit.inside >= 0 && doc.resolve(hit.inside).depth === 0
|
|
26
|
+
? doc.nodeAt(hit.inside)
|
|
27
|
+
: null;
|
|
28
|
+
const at = leaf ? hit.inside + leaf.nodeSize : $pos.pos;
|
|
29
|
+
return { from: at, to: at };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A caret just after inserted blocks that end at `pos`: at the end of the
|
|
33
|
+
* last text block, or a gap cursor after a block that holds no text, such as
|
|
34
|
+
* a rule or table. It never selects a node, so typing never replaces one.
|
|
35
|
+
*/
|
|
36
|
+
export function caretAfter(doc, pos) {
|
|
37
|
+
const $pos = doc.resolve(pos);
|
|
38
|
+
if ($pos.nodeBefore?.isTextblock)
|
|
39
|
+
return TextSelection.create(doc, pos - 1);
|
|
40
|
+
// `valid` is public at run time but missing from the type declarations.
|
|
41
|
+
const gap = GapCursor;
|
|
42
|
+
if (gap.valid($pos))
|
|
43
|
+
return new GapCursor($pos);
|
|
44
|
+
return (TextSelection.findFrom($pos, -1, true) ??
|
|
45
|
+
TextSelection.findFrom($pos, 1, true) ??
|
|
46
|
+
Selection.near($pos));
|
|
47
|
+
}
|