@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 August Cayzer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
InkKit includes editor behavior and source from PadPad and Memos.
|
|
2
|
+
Copyright (c) 2026 August Cayzer. Licensed under the MIT License.
|
|
3
|
+
Sources: https://github.com/aicayzer/padpad and https://github.com/aicayzer/memos
|
|
4
|
+
|
|
5
|
+
Milkdown and other runtime dependencies retain their own licenses. They are
|
|
6
|
+
installed as dependencies rather than embedded in this package.
|
package/README.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# InkKit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
InkKit is a TypeScript Markdown editor for offline web views and the web. It provides readable clipboard output, editable tables, optional host-managed images, and preservation of original Markdown and unsupported syntax.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @aicayzer/inkkit
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { InkKitEditor } from '@aicayzer/inkkit'
|
|
11
|
+
import '@aicayzer/inkkit/style.css'
|
|
12
|
+
|
|
13
|
+
const editor = await InkKitEditor.mount(document.getElementById('editor')!, {
|
|
14
|
+
changed(text, generation) {
|
|
15
|
+
/* Notify the host. */
|
|
16
|
+
},
|
|
17
|
+
stateChanged(state) {
|
|
18
|
+
/* Update formatting controls. */
|
|
19
|
+
},
|
|
20
|
+
openLink(href) {
|
|
21
|
+
/* Open through the host. */
|
|
22
|
+
},
|
|
23
|
+
copy(text) {
|
|
24
|
+
/* Write code-block text to the clipboard. */
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
editor.loadDocument({
|
|
28
|
+
documentId: 'note',
|
|
29
|
+
generation: 1,
|
|
30
|
+
format: 'md',
|
|
31
|
+
text: '# Hello\n',
|
|
32
|
+
})
|
|
33
|
+
const snapshot = editor.snapshot(1)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Markdown includes CommonMark, task lists, strikethrough, autolinks and GFM tables. Unsupported constructs remain literal Markdown. TXT mode treats all text literally. Images require a host adapter. Footnote editing and RTF files are not included in 0.0.1.
|
|
37
|
+
|
|
38
|
+
See [the host API](docs/api.md), [preservation and clipboard behavior](docs/preservation.md), [development](docs/development.md), and [releases](docs/releasing.md).
|
|
39
|
+
|
|
40
|
+
MIT licensed. InkKit builds on the public PadPad and Memos editors; see [NOTICE](NOTICE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const autolinkInputRule: import("@milkdown/kit/utils").$InputRule;
|
package/dist/autolink.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { linkSchema } from '@milkdown/kit/preset/commonmark';
|
|
2
|
+
import { InputRule } from '@milkdown/kit/prose/inputrules';
|
|
3
|
+
import { $inputRule } from '@milkdown/kit/utils';
|
|
4
|
+
// A typed URL becomes a link once a space follows it; nothing links it otherwise.
|
|
5
|
+
export const autolinkInputRule = $inputRule((ctx) => {
|
|
6
|
+
return new InputRule(/(?:^|\s)(https?:\/\/[^\s<>]+[^\s<>.,;:!?)\]])\s$/, (state, match, _start, end) => {
|
|
7
|
+
const url = match[1];
|
|
8
|
+
if (!url)
|
|
9
|
+
return null;
|
|
10
|
+
const to = end;
|
|
11
|
+
const from = to - url.length;
|
|
12
|
+
const mark = linkSchema.type(ctx).create({ href: url });
|
|
13
|
+
return state.tr
|
|
14
|
+
.addMark(from, to, mark)
|
|
15
|
+
.insertText(' ', end, end)
|
|
16
|
+
.removeStoredMark(mark);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Fragment, type Schema } from '@milkdown/kit/prose/model';
|
|
2
|
+
import type { ClipboardOutput, DocumentContext, ImageAdapter } from './types.js';
|
|
3
|
+
export interface ClipboardContent {
|
|
4
|
+
text: string;
|
|
5
|
+
html: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function clipboardText(content: Fragment, missingImages?: ReadonlySet<string>): string;
|
|
8
|
+
export declare function clipboardContent(content: Fragment, schema: Schema): ClipboardContent;
|
|
9
|
+
export declare const visibleClipboard: import("@milkdown/kit/utils").$Prose;
|
|
10
|
+
/** Export a frozen document fragment; presentation URLs never enter the clipboard. */
|
|
11
|
+
export declare function portableClipboard(content: Fragment, schema: Schema, markdown: string, adapter: ImageAdapter | undefined, context: DocumentContext): Promise<ClipboardOutput>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { DOMSerializer, } from '@milkdown/kit/prose/model';
|
|
2
|
+
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
3
|
+
import { $prose } from '@milkdown/kit/utils';
|
|
4
|
+
// Clipboard text is the visible document, not Markdown serialization: escaping
|
|
5
|
+
// punctuation or trailing spaces here leaks source syntax into other apps.
|
|
6
|
+
export function clipboardText(content, missingImages = new Set()) {
|
|
7
|
+
const blocks = [];
|
|
8
|
+
content.forEach((node) => blocks.push(nodeText(node, missingImages)));
|
|
9
|
+
const inline = content.firstChild?.isInline ?? false;
|
|
10
|
+
return blocks.join(inline ? '' : '\n\n');
|
|
11
|
+
}
|
|
12
|
+
function nodeText(node, missingImages) {
|
|
13
|
+
if (node.isText)
|
|
14
|
+
return node.text ?? '';
|
|
15
|
+
if (node.type.name === 'hardbreak' || node.type.name === 'hard_break')
|
|
16
|
+
return '\n';
|
|
17
|
+
if (node.type.name === 'bullet_list' || node.type.name === 'ordered_list') {
|
|
18
|
+
const items = [];
|
|
19
|
+
node.forEach((item, _offset, index) => {
|
|
20
|
+
const marker = node.type.name === 'ordered_list'
|
|
21
|
+
? `${Number(node.attrs.order ?? 1) + index}. `
|
|
22
|
+
: '- ';
|
|
23
|
+
const task = item.attrs.checked == null ? '' : item.attrs.checked ? '[x] ' : '[ ] ';
|
|
24
|
+
const prefix = marker + task;
|
|
25
|
+
const indent = ' '.repeat(marker.length);
|
|
26
|
+
const blocks = [];
|
|
27
|
+
item.forEach((child, _childOffset, childIndex) => {
|
|
28
|
+
const text = nodeText(child, missingImages);
|
|
29
|
+
const nestedList = child.type.name === 'bullet_list' ||
|
|
30
|
+
child.type.name === 'ordered_list';
|
|
31
|
+
const lines = text.split('\n');
|
|
32
|
+
const indented = lines
|
|
33
|
+
.map((line, lineIndex) => childIndex === 0 && lineIndex === 0 ? prefix + line : indent + line)
|
|
34
|
+
.join('\n');
|
|
35
|
+
// Nested lists follow their parent directly; subsequent paragraphs keep
|
|
36
|
+
// their blank line and every continuation stays inside its list item.
|
|
37
|
+
blocks.push((childIndex > 0 && !nestedList ? '\n' : '') + indented);
|
|
38
|
+
});
|
|
39
|
+
items.push(blocks.length ? blocks.join('\n') : prefix.trimEnd());
|
|
40
|
+
});
|
|
41
|
+
return items.join('\n');
|
|
42
|
+
}
|
|
43
|
+
if (node.type.name === 'image') {
|
|
44
|
+
const alt = String(node.attrs.alt ?? '').replace(/\|\d{1,5}$/, '') || 'Image';
|
|
45
|
+
return missingImages.has(String(node.attrs.src ?? ''))
|
|
46
|
+
? `[${alt}: image unavailable]`
|
|
47
|
+
: alt;
|
|
48
|
+
}
|
|
49
|
+
if (node.type.name === 'table') {
|
|
50
|
+
const rows = [];
|
|
51
|
+
node.forEach((row) => {
|
|
52
|
+
const cells = [];
|
|
53
|
+
row.forEach((cell) => cells.push(clipboardText(cell.content, missingImages)));
|
|
54
|
+
rows.push(cells.join('\t'));
|
|
55
|
+
});
|
|
56
|
+
return rows.join('\n');
|
|
57
|
+
}
|
|
58
|
+
return clipboardText(node.content, missingImages);
|
|
59
|
+
}
|
|
60
|
+
export function clipboardContent(content, schema) {
|
|
61
|
+
const container = document.createElement('div');
|
|
62
|
+
const serializer = DOMSerializer.fromSchema(schema);
|
|
63
|
+
// Construct image elements without a source so detached clipboard DOM never
|
|
64
|
+
// fetches authored references or temporary presentation URLs.
|
|
65
|
+
const safe = new DOMSerializer({
|
|
66
|
+
...serializer.nodes,
|
|
67
|
+
image: (node) => [
|
|
68
|
+
'img',
|
|
69
|
+
{
|
|
70
|
+
alt: String(node.attrs.alt ?? ''),
|
|
71
|
+
title: String(node.attrs.title ?? ''),
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
}, serializer.marks);
|
|
75
|
+
container.append(safe.serializeFragment(content));
|
|
76
|
+
return { text: clipboardText(content), html: container.innerHTML };
|
|
77
|
+
}
|
|
78
|
+
export const visibleClipboard = $prose(() => new Plugin({
|
|
79
|
+
key: new PluginKey('visibleClipboard'),
|
|
80
|
+
props: {
|
|
81
|
+
clipboardTextSerializer: (slice) => clipboardText(slice.content),
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
/** Export a frozen document fragment; presentation URLs never enter the clipboard. */
|
|
85
|
+
export async function portableClipboard(content, schema, markdown, adapter, context) {
|
|
86
|
+
const base = clipboardContent(content, schema);
|
|
87
|
+
const container = document.createElement('div');
|
|
88
|
+
container.innerHTML = base.html;
|
|
89
|
+
const images = [];
|
|
90
|
+
const references = [];
|
|
91
|
+
content.descendants((node) => {
|
|
92
|
+
if (node.type.name === 'image')
|
|
93
|
+
references.push(String(node.attrs.src ?? ''));
|
|
94
|
+
});
|
|
95
|
+
const captured = [...container.querySelectorAll('img')].map((element, index) => {
|
|
96
|
+
const reference = references[index] ?? '';
|
|
97
|
+
const alt = element.getAttribute('alt')?.replace(/\|\d{1,5}$/, '') || 'Image';
|
|
98
|
+
element.removeAttribute('src');
|
|
99
|
+
element.removeAttribute('srcset');
|
|
100
|
+
return { element, reference, alt };
|
|
101
|
+
});
|
|
102
|
+
await Promise.all(captured.map(async ({ element, reference, alt }, index) => {
|
|
103
|
+
try {
|
|
104
|
+
if (!adapter)
|
|
105
|
+
throw new Error('The image is unavailable.');
|
|
106
|
+
const image = await adapter.exportImage(reference, { ...context });
|
|
107
|
+
if (!/^image\/(png|jpeg|gif|webp|avif|bmp|tiff)$/i.test(image.mimeType))
|
|
108
|
+
throw new Error('The image format cannot be exported.');
|
|
109
|
+
let binary = '';
|
|
110
|
+
for (const byte of image.bytes)
|
|
111
|
+
binary += String.fromCharCode(byte);
|
|
112
|
+
const portable = `data:${image.mimeType};base64,${btoa(binary)}`;
|
|
113
|
+
element.setAttribute('src', portable);
|
|
114
|
+
element.setAttribute('alt', alt);
|
|
115
|
+
images[index] = { reference, alt, image };
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const message = error instanceof Error ? error.message : 'The image is unavailable.';
|
|
119
|
+
const warning = document.createElement('span');
|
|
120
|
+
warning.textContent = `[${alt}: image unavailable]`;
|
|
121
|
+
element.replaceWith(warning);
|
|
122
|
+
images[index] = { reference, alt, error: message };
|
|
123
|
+
}
|
|
124
|
+
}));
|
|
125
|
+
// Scriptable links and event attributes are not part of semantic rich text.
|
|
126
|
+
for (const element of container.querySelectorAll('*')) {
|
|
127
|
+
for (const attribute of [...element.attributes])
|
|
128
|
+
if (attribute.name.startsWith('on') ||
|
|
129
|
+
((attribute.name === 'href' || attribute.name === 'src') &&
|
|
130
|
+
/^javascript:/i.test(attribute.value)))
|
|
131
|
+
element.removeAttribute(attribute.name);
|
|
132
|
+
}
|
|
133
|
+
const missing = new Set(images.filter((image) => image.error).map((image) => image.reference));
|
|
134
|
+
return {
|
|
135
|
+
text: clipboardText(content, missing),
|
|
136
|
+
html: container.innerHTML,
|
|
137
|
+
markdown,
|
|
138
|
+
images,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
2
|
+
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
|
|
3
|
+
import { $prose } from '@milkdown/kit/utils';
|
|
4
|
+
const copyIcon = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2"/></svg>';
|
|
5
|
+
const doneIcon = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-6.5"/></svg>';
|
|
6
|
+
// The button reads the block's text when clicked, so the same widget can stay
|
|
7
|
+
// mounted while the block is edited.
|
|
8
|
+
function copyButton(onCopy) {
|
|
9
|
+
const button = document.createElement('button');
|
|
10
|
+
button.type = 'button';
|
|
11
|
+
button.className = 'copy';
|
|
12
|
+
button.setAttribute('aria-label', 'Copy');
|
|
13
|
+
button.innerHTML = copyIcon;
|
|
14
|
+
button.addEventListener('mousedown', (event) => event.preventDefault());
|
|
15
|
+
button.addEventListener('click', () => {
|
|
16
|
+
const code = button.closest('pre')?.querySelector('code');
|
|
17
|
+
onCopy(code?.textContent ?? '');
|
|
18
|
+
button.dataset.copied = '';
|
|
19
|
+
button.innerHTML = doneIcon;
|
|
20
|
+
window.setTimeout(() => {
|
|
21
|
+
delete button.dataset.copied;
|
|
22
|
+
button.innerHTML = copyIcon;
|
|
23
|
+
}, 1200);
|
|
24
|
+
});
|
|
25
|
+
return button;
|
|
26
|
+
}
|
|
27
|
+
export function codeCopyPlugin(onCopy) {
|
|
28
|
+
return $prose(() => new Plugin({
|
|
29
|
+
key: new PluginKey('codeCopy'),
|
|
30
|
+
props: {
|
|
31
|
+
decorations(state) {
|
|
32
|
+
const widgets = [];
|
|
33
|
+
state.doc.descendants((node, pos) => {
|
|
34
|
+
if (node.type.name !== 'code_block')
|
|
35
|
+
return true;
|
|
36
|
+
widgets.push(Decoration.widget(pos + 1, () => copyButton(onCopy), {
|
|
37
|
+
side: -1,
|
|
38
|
+
key: 'copy',
|
|
39
|
+
ignoreSelection: true,
|
|
40
|
+
}));
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
return DecorationSet.create(state.doc, widgets);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
export const placeholderPlugin = $prose(() => new Plugin({
|
|
49
|
+
key: new PluginKey('placeholder'),
|
|
50
|
+
props: {
|
|
51
|
+
decorations(state) {
|
|
52
|
+
const first = state.doc.firstChild;
|
|
53
|
+
if (state.doc.childCount !== 1 ||
|
|
54
|
+
!first?.isTextblock ||
|
|
55
|
+
first.type.name === 'heading' ||
|
|
56
|
+
first.content.size > 0)
|
|
57
|
+
return null;
|
|
58
|
+
return DecorationSet.create(state.doc, [
|
|
59
|
+
Decoration.node(0, first.nodeSize, {
|
|
60
|
+
class: 'empty',
|
|
61
|
+
'data-placeholder': 'Start typing…',
|
|
62
|
+
}),
|
|
63
|
+
]);
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Ctx, MilkdownPlugin } from '@milkdown/kit/ctx';
|
|
2
|
+
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
3
|
+
import { type Options as StringifyOptions } from 'mdast-util-to-markdown';
|
|
4
|
+
export declare const remarkDialectPlugin: import("@milkdown/kit/utils").$Remark<"remarkDialect", unknown>;
|
|
5
|
+
export declare const tables: MilkdownPlugin[];
|
|
6
|
+
export declare function createDialect(images: boolean): MilkdownPlugin[];
|
|
7
|
+
export declare const dialect: MilkdownPlugin[];
|
|
8
|
+
export declare const stringifyOptions: StringifyOptions;
|
|
9
|
+
export declare function serialize(ctx: Ctx, doc?: ProseNode): string;
|
package/dist/dialect.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core';
|
|
2
|
+
import { commonmark, hardbreakSchema, imageSchema, paragraphSchema, remarkInlineLinkPlugin, } from '@milkdown/kit/preset/commonmark';
|
|
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 { $remark } from '@milkdown/kit/utils';
|
|
5
|
+
import { autolinkInputRule } from './autolink.js';
|
|
6
|
+
import { literalBlock, createLiteralPreservation } from './literals.js';
|
|
7
|
+
import { gfmTableFromMarkdown, gfmTableToMarkdown } from 'mdast-util-gfm-table';
|
|
8
|
+
import { gfmTable } from 'micromark-extension-gfm-table';
|
|
9
|
+
import { defaultHandlers, } from 'mdast-util-to-markdown';
|
|
10
|
+
import { gfmAutolinkLiteralFromMarkdown, gfmAutolinkLiteralToMarkdown, } from 'mdast-util-gfm-autolink-literal';
|
|
11
|
+
import { gfmStrikethroughFromMarkdown, gfmStrikethroughToMarkdown, } from 'mdast-util-gfm-strikethrough';
|
|
12
|
+
import { gfmTaskListItemFromMarkdown, gfmTaskListItemToMarkdown, } from 'mdast-util-gfm-task-list-item';
|
|
13
|
+
import { gfmAutolinkLiteral } from 'micromark-extension-gfm-autolink-literal';
|
|
14
|
+
import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
|
|
15
|
+
import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
|
|
16
|
+
const bareURL = /^https?:\/\/[^\s<>]+$/;
|
|
17
|
+
// The literal form stops at these, so a URL ending in one would come back shorter than it went out.
|
|
18
|
+
const endsInPunctuation = /[.,;:!?]$/;
|
|
19
|
+
// What may sit against a bare URL without the reader running the two together.
|
|
20
|
+
const beforeURL = /(^|[\s([*_~])$/;
|
|
21
|
+
const afterURL = /^([\s.,;:!?)\]}<]|$)/;
|
|
22
|
+
function balanced(url) {
|
|
23
|
+
let open = 0;
|
|
24
|
+
for (const character of url) {
|
|
25
|
+
if (character === '(')
|
|
26
|
+
open += 1;
|
|
27
|
+
else if (character === ')')
|
|
28
|
+
open -= 1;
|
|
29
|
+
if (open < 0)
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return open === 0;
|
|
33
|
+
}
|
|
34
|
+
function textAt(parent, node, offset) {
|
|
35
|
+
const children = parent?.children;
|
|
36
|
+
const index = children?.indexOf(node) ?? -1;
|
|
37
|
+
return index < 0 ? undefined : children?.[index + offset];
|
|
38
|
+
}
|
|
39
|
+
/** A link whose text is its own URL is written as the bare URL, the way it was typed. Only where reading
|
|
40
|
+
* it back gives the same link again: nothing may run into it at either end. */
|
|
41
|
+
function writesBare(node, parent) {
|
|
42
|
+
const [child, ...rest] = node.children;
|
|
43
|
+
if (node.title ||
|
|
44
|
+
rest.length > 0 ||
|
|
45
|
+
child?.type !== 'text' ||
|
|
46
|
+
child.value !== node.url)
|
|
47
|
+
return false;
|
|
48
|
+
if (!bareURL.test(node.url) ||
|
|
49
|
+
endsInPunctuation.test(node.url) ||
|
|
50
|
+
!balanced(node.url))
|
|
51
|
+
return false;
|
|
52
|
+
const before = textAt(parent, node, -1);
|
|
53
|
+
if (before && (before.type !== 'text' || !beforeURL.test(before.value)))
|
|
54
|
+
return false;
|
|
55
|
+
const after = textAt(parent, node, 1);
|
|
56
|
+
if (after && (after.type !== 'text' || !afterURL.test(after.value)))
|
|
57
|
+
return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const link = (node, parent, state, info) => writesBare(node, parent)
|
|
61
|
+
? node.url
|
|
62
|
+
: defaultHandlers.link(node, parent, state, info);
|
|
63
|
+
link.peek = (node, parent, state) => writesBare(node, parent)
|
|
64
|
+
? node.url[0]
|
|
65
|
+
: defaultHandlers.link.peek(node, parent, state);
|
|
66
|
+
// Footnotes remain literal until the editor supports editing their definitions.
|
|
67
|
+
function remarkDialect() {
|
|
68
|
+
const data = this.data();
|
|
69
|
+
const add = (key, value) => {
|
|
70
|
+
const list = (data[key] ??= []);
|
|
71
|
+
list.push(value);
|
|
72
|
+
};
|
|
73
|
+
add('micromarkExtensions', gfmStrikethrough());
|
|
74
|
+
add('micromarkExtensions', gfmTaskListItem());
|
|
75
|
+
add('micromarkExtensions', gfmAutolinkLiteral());
|
|
76
|
+
add('micromarkExtensions', gfmTable());
|
|
77
|
+
add('fromMarkdownExtensions', gfmStrikethroughFromMarkdown());
|
|
78
|
+
add('fromMarkdownExtensions', gfmTaskListItemFromMarkdown());
|
|
79
|
+
add('fromMarkdownExtensions', gfmAutolinkLiteralFromMarkdown());
|
|
80
|
+
add('fromMarkdownExtensions', gfmTableFromMarkdown());
|
|
81
|
+
add('toMarkdownExtensions', gfmStrikethroughToMarkdown());
|
|
82
|
+
add('toMarkdownExtensions', gfmTaskListItemToMarkdown());
|
|
83
|
+
add('toMarkdownExtensions', gfmAutolinkLiteralToMarkdown());
|
|
84
|
+
add('toMarkdownExtensions', gfmTableToMarkdown());
|
|
85
|
+
add('toMarkdownExtensions', { handlers: { link } });
|
|
86
|
+
}
|
|
87
|
+
export const remarkDialectPlugin = $remark('remarkDialect', () => remarkDialect);
|
|
88
|
+
// Keep authored spacer paragraphs; references remain literal instead of becoming inline links.
|
|
89
|
+
const commonmarkWithLiteralReferences = commonmark.filter((plugin) => !remarkInlineLinkPlugin.includes(plugin));
|
|
90
|
+
// The preset drops the final empty paragraph, which makes trailing spacers shrink on each reload.
|
|
91
|
+
const preserveSpacerParagraphs = paragraphSchema.extendSchema((base) => (ctx) => {
|
|
92
|
+
const schema = base(ctx);
|
|
93
|
+
return {
|
|
94
|
+
...schema,
|
|
95
|
+
toMarkdown: {
|
|
96
|
+
...schema.toMarkdown,
|
|
97
|
+
runner(state, node) {
|
|
98
|
+
if (node.lastChild?.type.name === 'hardbreak') {
|
|
99
|
+
state.openNode('paragraph');
|
|
100
|
+
state.next(node.content.cut(0, node.content.size - node.lastChild.nodeSize));
|
|
101
|
+
state.addNode('html', undefined, '<br />');
|
|
102
|
+
state.closeNode();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (node.content.size > 0)
|
|
106
|
+
return schema.toMarkdown.runner(state, node);
|
|
107
|
+
state.openNode('paragraph');
|
|
108
|
+
state.addNode('html', undefined, '<br />');
|
|
109
|
+
state.closeNode();
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
// Soft Markdown breaks still serialize as a single newline. Display the actual
|
|
115
|
+
// line boundary rather than a space, so changing editor mode never joins lines.
|
|
116
|
+
const visibleSoftbreaks = hardbreakSchema.extendSchema((base) => (ctx) => {
|
|
117
|
+
const schema = base(ctx);
|
|
118
|
+
return {
|
|
119
|
+
...schema,
|
|
120
|
+
attrs: { ...schema.attrs, isHTML: { default: false } },
|
|
121
|
+
parseDOM: [
|
|
122
|
+
{
|
|
123
|
+
tag: 'br[data-type="softbreak"]',
|
|
124
|
+
getAttrs: () => ({ isInline: true }),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
tag: 'br[data-type="hardbreak"]',
|
|
128
|
+
getAttrs: () => ({ isInline: false }),
|
|
129
|
+
},
|
|
130
|
+
{ tag: 'br', getAttrs: () => ({ isInline: false, isHTML: true }) },
|
|
131
|
+
],
|
|
132
|
+
parseMarkdown: {
|
|
133
|
+
...schema.parseMarkdown,
|
|
134
|
+
runner(state, node, type) {
|
|
135
|
+
if (node.data
|
|
136
|
+
?.inkkitHTMLBreak)
|
|
137
|
+
state.addNode(type, { isInline: false, isHTML: true });
|
|
138
|
+
else
|
|
139
|
+
schema.parseMarkdown.runner(state, node, type);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
toMarkdown: {
|
|
143
|
+
...schema.toMarkdown,
|
|
144
|
+
runner(state, node) {
|
|
145
|
+
if (node.attrs.isHTML)
|
|
146
|
+
state.addNode('html', undefined, '<br />');
|
|
147
|
+
else
|
|
148
|
+
schema.toMarkdown.runner(state, node);
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
toDOM: (node) => node.attrs.isInline
|
|
152
|
+
? ['br', { 'data-type': 'softbreak' }]
|
|
153
|
+
: [
|
|
154
|
+
'br',
|
|
155
|
+
{ 'data-type': node.attrs.isHTML ? 'htmlbreak' : 'hardbreak' },
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
// Remark represents a missing title as null; the image schema accepts strings.
|
|
160
|
+
const normalizedImages = imageSchema.extendSchema((base) => (ctx) => {
|
|
161
|
+
const schema = base(ctx);
|
|
162
|
+
return {
|
|
163
|
+
...schema,
|
|
164
|
+
parseDOM: [
|
|
165
|
+
{
|
|
166
|
+
tag: 'img[src]',
|
|
167
|
+
getAttrs(dom) {
|
|
168
|
+
return {
|
|
169
|
+
src: dom.getAttribute('src') ?? '',
|
|
170
|
+
alt: dom.getAttribute('alt') ?? '',
|
|
171
|
+
title: dom.getAttribute('title') ?? '',
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
parseMarkdown: {
|
|
177
|
+
...schema.parseMarkdown,
|
|
178
|
+
runner(state, node, type) {
|
|
179
|
+
state.addNode(type, {
|
|
180
|
+
src: String(node.url ?? ''),
|
|
181
|
+
alt: String(node.alt ?? ''),
|
|
182
|
+
title: String(node.title ?? ''),
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
export const tables = [
|
|
189
|
+
tableSchema.extendSchema((previous) => (ctx) => ({
|
|
190
|
+
...previous(ctx),
|
|
191
|
+
content: 'table_header_row table_row*',
|
|
192
|
+
})),
|
|
193
|
+
tableHeaderRowSchema,
|
|
194
|
+
tableHeaderSchema.extendSchema((previous) => (ctx) => {
|
|
195
|
+
const schema = previous(ctx);
|
|
196
|
+
return {
|
|
197
|
+
...schema,
|
|
198
|
+
attrs: { ...schema.attrs, alignment: { default: null } },
|
|
199
|
+
parseDOM: schema.parseDOM?.map((rule) => 'tag' in rule
|
|
200
|
+
? {
|
|
201
|
+
...rule,
|
|
202
|
+
getAttrs(dom) {
|
|
203
|
+
const attrs = rule.getAttrs?.(dom);
|
|
204
|
+
if (attrs === false)
|
|
205
|
+
return false;
|
|
206
|
+
return {
|
|
207
|
+
...attrs,
|
|
208
|
+
alignment: dom.style.textAlign || dom.getAttribute('align') || null,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
: rule),
|
|
213
|
+
};
|
|
214
|
+
}),
|
|
215
|
+
tableRowSchema,
|
|
216
|
+
tableCellSchema.extendSchema((previous) => (ctx) => {
|
|
217
|
+
const schema = previous(ctx);
|
|
218
|
+
return {
|
|
219
|
+
...schema,
|
|
220
|
+
attrs: { ...schema.attrs, alignment: { default: null } },
|
|
221
|
+
parseDOM: schema.parseDOM?.map((rule) => 'tag' in rule
|
|
222
|
+
? {
|
|
223
|
+
...rule,
|
|
224
|
+
getAttrs(dom) {
|
|
225
|
+
const attrs = rule.getAttrs?.(dom);
|
|
226
|
+
if (attrs === false)
|
|
227
|
+
return false;
|
|
228
|
+
return {
|
|
229
|
+
...attrs,
|
|
230
|
+
alignment: dom.style.textAlign || dom.getAttribute('align') || null,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
: rule),
|
|
235
|
+
};
|
|
236
|
+
}),
|
|
237
|
+
tableEditingPlugin,
|
|
238
|
+
tableKeymap,
|
|
239
|
+
goToNextTableCellCommand,
|
|
240
|
+
goToPrevTableCellCommand,
|
|
241
|
+
insertTableCommand,
|
|
242
|
+
addRowBeforeCommand,
|
|
243
|
+
addRowAfterCommand,
|
|
244
|
+
addColBeforeCommand,
|
|
245
|
+
addColAfterCommand,
|
|
246
|
+
selectRowCommand,
|
|
247
|
+
selectColCommand,
|
|
248
|
+
deleteSelectedCellsCommand,
|
|
249
|
+
setAlignCommand,
|
|
250
|
+
exitTable,
|
|
251
|
+
].flat();
|
|
252
|
+
export function createDialect(images) {
|
|
253
|
+
return [
|
|
254
|
+
// Protect unsupported inline HTML before the empty-line plugin consumes break nodes.
|
|
255
|
+
createLiteralPreservation(images),
|
|
256
|
+
commonmarkWithLiteralReferences,
|
|
257
|
+
preserveSpacerParagraphs,
|
|
258
|
+
visibleSoftbreaks,
|
|
259
|
+
normalizedImages,
|
|
260
|
+
autolinkInputRule,
|
|
261
|
+
extendListItemSchemaForTask,
|
|
262
|
+
strikethroughAttr,
|
|
263
|
+
strikethroughSchema,
|
|
264
|
+
strikethroughInputRule,
|
|
265
|
+
strikethroughKeymap,
|
|
266
|
+
toggleStrikethroughCommand,
|
|
267
|
+
wrapInTaskListInputRule,
|
|
268
|
+
remarkDialectPlugin,
|
|
269
|
+
tables,
|
|
270
|
+
literalBlock,
|
|
271
|
+
].flat();
|
|
272
|
+
}
|
|
273
|
+
export const dialect = createDialect(false);
|
|
274
|
+
// One output form, so a document written back unchanged is byte-stable.
|
|
275
|
+
export const stringifyOptions = {
|
|
276
|
+
bullet: '-',
|
|
277
|
+
emphasis: '*',
|
|
278
|
+
strong: '*',
|
|
279
|
+
fences: true,
|
|
280
|
+
listItemIndent: 'one',
|
|
281
|
+
rule: '-',
|
|
282
|
+
};
|
|
283
|
+
// Milkdown preserves authored spacer paragraphs with standalone Markdown HTML breaks.
|
|
284
|
+
export function serialize(ctx, doc = ctx.get(editorViewCtx).state.doc) {
|
|
285
|
+
if (doc.childCount === 1 &&
|
|
286
|
+
doc.firstChild?.type.name === 'paragraph' &&
|
|
287
|
+
doc.firstChild.content.size === 0)
|
|
288
|
+
return '';
|
|
289
|
+
return ctx.get(serializerCtx)(doc);
|
|
290
|
+
}
|