@the-sumeet/sveltedown 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -2
- package/dist/cm/keymaps.d.ts +31 -0
- package/dist/cm/keymaps.js +108 -0
- package/dist/cm/plugins/block-widgets.d.ts +20 -0
- package/dist/cm/plugins/block-widgets.js +313 -0
- package/dist/cm/plugins/inline-live-preview.d.ts +14 -0
- package/dist/cm/plugins/inline-live-preview.js +168 -0
- package/dist/cm/setup.d.ts +36 -0
- package/dist/cm/setup.js +124 -0
- package/dist/cm/theme.d.ts +13 -0
- package/dist/cm/theme.js +37 -0
- package/dist/cm/utils/code-blocks.d.ts +41 -0
- package/dist/cm/utils/code-blocks.js +121 -0
- package/dist/cm/utils/cursor.d.ts +20 -0
- package/dist/cm/utils/cursor.js +45 -0
- package/dist/cm/utils/dom.d.ts +9 -0
- package/dist/cm/utils/dom.js +25 -0
- package/dist/cm/utils/links.d.ts +2 -0
- package/dist/cm/utils/links.js +13 -0
- package/dist/cm/utils/svelte-widget.svelte.d.ts +31 -0
- package/dist/cm/utils/svelte-widget.svelte.js +69 -0
- package/dist/cm/utils/syntax.d.ts +8 -0
- package/dist/cm/utils/syntax.js +23 -0
- package/dist/cm/utils/tables.d.ts +34 -0
- package/dist/cm/utils/tables.js +245 -0
- package/dist/cm/utils/tasks.d.ts +6 -0
- package/dist/cm/utils/tasks.js +17 -0
- package/dist/components/MarkdownEditor.svelte +72 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +5 -0
- package/dist/components/widgets/CheckboxWidget.svelte +25 -0
- package/dist/components/widgets/CheckboxWidget.svelte.d.ts +8 -0
- package/dist/components/widgets/CodeBlockHeader.svelte +22 -0
- package/dist/components/widgets/CodeBlockHeader.svelte.d.ts +4 -0
- package/dist/components/widgets/CodeBlockWidget.svelte +67 -0
- package/dist/components/widgets/CodeBlockWidget.svelte.d.ts +13 -0
- package/dist/components/widgets/ImageWidget.svelte +24 -0
- package/dist/components/widgets/ImageWidget.svelte.d.ts +4 -0
- package/dist/components/widgets/SnippetHost.svelte +11 -0
- package/dist/components/widgets/SnippetHost.svelte.d.ts +7 -0
- package/dist/components/widgets/TableWidget.svelte +101 -0
- package/dist/components/widgets/TableWidget.svelte.d.ts +9 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/styles/editor.css +45 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +1 -0
- package/package.json +95 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sumeet
|
|
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/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sveltedown
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A live-preview Markdown editor for **Svelte 5**, built on **CodeMirror 6**, in the style of Typora and Obsidian.
|
|
4
|
+
|
|
5
|
+
The document stays plain Markdown text. Syntax such as `**`, `#` and `[text](url)` is hidden while the cursor is elsewhere and shows again when the cursor moves into it. Images, task checkboxes, code blocks and tables render as interactive Svelte components.
|
|
6
|
+
|
|
7
|
+
- Two-way `bind:value`, safe to render with SSR in SvelteKit
|
|
8
|
+
- Headings, emphasis, inline code and links styled in place
|
|
9
|
+
- Image previews, clickable task checkboxes, horizontal rules
|
|
10
|
+
- Code blocks that scroll instead of wrapping, with a copy button
|
|
11
|
+
- GFM tables rendered as HTML tables, with a command that lines up their source
|
|
12
|
+
- Formatting shortcuts, plus list and task continuation on Enter
|
|
13
|
+
- Themed with CSS variables, with light and dark modes
|
|
14
|
+
- Custom Svelte snippets for images, code block headers and tables
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install @the-sumeet/sveltedown
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`svelte@^5` is a peer dependency.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```svelte
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
import { MarkdownEditor } from '@the-sumeet/sveltedown';
|
|
29
|
+
|
|
30
|
+
let value = $state('# Hello\n\nStart **writing**…');
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<MarkdownEditor bind:value placeholder="Start writing…" />
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Documentation
|
|
37
|
+
|
|
38
|
+
The full documentation, with live demos, is the Starlight site in [`docs/`](docs/), published to GitHub Pages. It covers:
|
|
39
|
+
|
|
40
|
+
- [Installation](docs/src/content/docs/getting-started/installation.mdx) and [quick start](docs/src/content/docs/getting-started/quick-start.mdx)
|
|
41
|
+
- [Live preview](docs/src/content/docs/guides/live-preview.mdx), [code blocks](docs/src/content/docs/guides/code-blocks.mdx) and [tables](docs/src/content/docs/guides/tables.mdx)
|
|
42
|
+
- [Theming](docs/src/content/docs/guides/theming.mdx) and [custom snippets](docs/src/content/docs/guides/custom-snippets.mdx)
|
|
43
|
+
- [CodeMirror building blocks](docs/src/content/docs/guides/codemirror.mdx)
|
|
44
|
+
- Reference: [props](docs/src/content/docs/reference/props.md), [keyboard shortcuts](docs/src/content/docs/reference/keyboard-shortcuts.md) and [API](docs/src/content/docs/reference/api.md)
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm install
|
|
50
|
+
npm run dev # playground at src/routes
|
|
51
|
+
npm run check # svelte-check
|
|
52
|
+
npm run lint
|
|
53
|
+
npm run test:unit # Vitest: node and browser (Chromium) projects
|
|
54
|
+
npm run test:e2e # Playwright against the production build
|
|
55
|
+
npm run package # svelte-package + publint
|
|
56
|
+
|
|
57
|
+
npm run docs:dev # docs site (run `npm install` in docs/ first)
|
|
58
|
+
npm run docs:build
|
|
59
|
+
npm run docs:check
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Extension, type StateCommand } from '@codemirror/state';
|
|
2
|
+
/**
|
|
3
|
+
* Toggles inline markup like `**` around each selection range. Ranges inside an existing
|
|
4
|
+
* node of that kind are unwrapped (found via the syntax tree, so `*` never matches the
|
|
5
|
+
* markers of `**bold**`); otherwise the selection, or the word at the cursor, is wrapped.
|
|
6
|
+
*/
|
|
7
|
+
export declare function toggleWrap(marker: string): StateCommand;
|
|
8
|
+
export declare const toggleBold: StateCommand;
|
|
9
|
+
export declare const toggleItalic: StateCommand;
|
|
10
|
+
export declare const toggleStrikethrough: StateCommand;
|
|
11
|
+
/**
|
|
12
|
+
* Turns the selection (or the word at the cursor) into `[text](url)` with `url` selected.
|
|
13
|
+
* A selected URL becomes `[](url)` with the cursor in the brackets.
|
|
14
|
+
*/
|
|
15
|
+
export declare const insertLink: StateCommand;
|
|
16
|
+
/** Markdown formatting shortcuts. */
|
|
17
|
+
export declare const formattingKeymap: ({
|
|
18
|
+
key: string;
|
|
19
|
+
run: StateCommand;
|
|
20
|
+
preventDefault: boolean;
|
|
21
|
+
} | {
|
|
22
|
+
key: string;
|
|
23
|
+
run: StateCommand;
|
|
24
|
+
preventDefault?: undefined;
|
|
25
|
+
})[];
|
|
26
|
+
/**
|
|
27
|
+
* Formatting shortcuts (taking precedence over defaults such as `Mod-i`, "select parent
|
|
28
|
+
* syntax"), plus the default and history keymaps. List continuation on Enter comes from
|
|
29
|
+
* `@codemirror/lang-markdown`'s own keymap.
|
|
30
|
+
*/
|
|
31
|
+
export declare function editorKeymaps(): Extension;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { EditorSelection, Prec } from '@codemirror/state';
|
|
2
|
+
import { keymap } from '@codemirror/view';
|
|
3
|
+
import { defaultKeymap, historyKeymap } from '@codemirror/commands';
|
|
4
|
+
import { findNodeAt } from './utils/syntax.js';
|
|
5
|
+
import { formatTable } from './utils/tables.js';
|
|
6
|
+
const WRAP_NODES = {
|
|
7
|
+
'**': { node: 'StrongEmphasis', mark: 'EmphasisMark' },
|
|
8
|
+
'*': { node: 'Emphasis', mark: 'EmphasisMark' },
|
|
9
|
+
'~~': { node: 'Strikethrough', mark: 'StrikethroughMark' }
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Toggles inline markup like `**` around each selection range. Ranges inside an existing
|
|
13
|
+
* node of that kind are unwrapped (found via the syntax tree, so `*` never matches the
|
|
14
|
+
* markers of `**bold**`); otherwise the selection, or the word at the cursor, is wrapped.
|
|
15
|
+
*/
|
|
16
|
+
export function toggleWrap(marker) {
|
|
17
|
+
const kind = WRAP_NODES[marker];
|
|
18
|
+
return ({ state, dispatch }) => {
|
|
19
|
+
if (state.readOnly)
|
|
20
|
+
return false;
|
|
21
|
+
const n = marker.length;
|
|
22
|
+
const tr = state.changeByRange((range) => {
|
|
23
|
+
// `**|**` left by a previous toggle on empty space: take the markers back out.
|
|
24
|
+
if (range.empty && state.sliceDoc(range.head - n, range.head + n) === marker + marker) {
|
|
25
|
+
const changes = state.changes({ from: range.head - n, to: range.head + n });
|
|
26
|
+
return { changes, range: range.map(changes) };
|
|
27
|
+
}
|
|
28
|
+
// A cursor on either edge of `**bold**` counts as inside, so the toggle undoes it.
|
|
29
|
+
const node = kind ? findNodeAt(state, range.from, kind.node) : null;
|
|
30
|
+
if (node && node.from <= range.from && range.to <= node.to) {
|
|
31
|
+
const marks = node.getChildren(kind.mark);
|
|
32
|
+
const changes = state.changes([marks[0], marks[marks.length - 1]].map((m) => ({ from: m.from, to: m.to })));
|
|
33
|
+
return { changes, range: range.map(changes) };
|
|
34
|
+
}
|
|
35
|
+
let { from, to } = range;
|
|
36
|
+
if (range.empty) {
|
|
37
|
+
const word = state.wordAt(range.head);
|
|
38
|
+
if (word)
|
|
39
|
+
({ from, to } = word);
|
|
40
|
+
}
|
|
41
|
+
// `** bold **` is not bold, so keep surrounding whitespace outside the markers.
|
|
42
|
+
while (from < to && /\s/.test(state.sliceDoc(from, from + 1)))
|
|
43
|
+
from++;
|
|
44
|
+
while (to > from && /\s/.test(state.sliceDoc(to - 1, to)))
|
|
45
|
+
to--;
|
|
46
|
+
const changes = [
|
|
47
|
+
{ from, insert: marker },
|
|
48
|
+
{ from: to, insert: marker }
|
|
49
|
+
];
|
|
50
|
+
if (range.empty)
|
|
51
|
+
return { changes, range: EditorSelection.cursor(range.head + n) };
|
|
52
|
+
const [anchor, head] = range.anchor <= range.head ? [from, to] : [to, from];
|
|
53
|
+
return { changes, range: EditorSelection.range(anchor + n, head + n) };
|
|
54
|
+
});
|
|
55
|
+
dispatch(state.update(tr, { scrollIntoView: true, userEvent: 'input.format' }));
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export const toggleBold = toggleWrap('**');
|
|
60
|
+
export const toggleItalic = toggleWrap('*');
|
|
61
|
+
export const toggleStrikethrough = toggleWrap('~~');
|
|
62
|
+
/**
|
|
63
|
+
* Turns the selection (or the word at the cursor) into `[text](url)` with `url` selected.
|
|
64
|
+
* A selected URL becomes `[](url)` with the cursor in the brackets.
|
|
65
|
+
*/
|
|
66
|
+
export const insertLink = ({ state, dispatch }) => {
|
|
67
|
+
if (state.readOnly)
|
|
68
|
+
return false;
|
|
69
|
+
const tr = state.changeByRange((range) => {
|
|
70
|
+
let { from, to } = range;
|
|
71
|
+
if (range.empty) {
|
|
72
|
+
const word = state.wordAt(range.head);
|
|
73
|
+
if (word)
|
|
74
|
+
({ from, to } = word);
|
|
75
|
+
}
|
|
76
|
+
const text = state.sliceDoc(from, to);
|
|
77
|
+
if (/^https?:\/\/\S+$/.test(text)) {
|
|
78
|
+
return {
|
|
79
|
+
changes: { from, to, insert: `[](${text})` },
|
|
80
|
+
range: EditorSelection.cursor(from + 1)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const urlFrom = from + text.length + 3;
|
|
84
|
+
return {
|
|
85
|
+
changes: { from, to, insert: `[${text}](url)` },
|
|
86
|
+
range: text ? EditorSelection.range(urlFrom, urlFrom + 3) : EditorSelection.cursor(from + 1)
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
dispatch(state.update(tr, { scrollIntoView: true, userEvent: 'input.format' }));
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
/** Markdown formatting shortcuts. */
|
|
93
|
+
export const formattingKeymap = [
|
|
94
|
+
{ key: 'Mod-b', run: toggleBold, preventDefault: true },
|
|
95
|
+
{ key: 'Mod-i', run: toggleItalic, preventDefault: true },
|
|
96
|
+
{ key: 'Mod-Shift-x', run: toggleStrikethrough, preventDefault: true },
|
|
97
|
+
{ key: 'Mod-k', run: insertLink, preventDefault: true },
|
|
98
|
+
// No preventDefault: outside a table the browser keeps the shortcut.
|
|
99
|
+
{ key: 'Mod-Shift-f', run: formatTable }
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Formatting shortcuts (taking precedence over defaults such as `Mod-i`, "select parent
|
|
103
|
+
* syntax"), plus the default and history keymaps. List continuation on Enter comes from
|
|
104
|
+
* `@codemirror/lang-markdown`'s own keymap.
|
|
105
|
+
*/
|
|
106
|
+
export function editorKeymaps() {
|
|
107
|
+
return [Prec.high(keymap.of(formattingKeymap)), keymap.of([...defaultKeymap, ...historyKeymap])];
|
|
108
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Facet, StateField, type EditorState, type Extension } from '@codemirror/state';
|
|
2
|
+
import { type DecorationSet } from '@codemirror/view';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import type { CodeHeaderSnippetProps, ImageSnippetProps, TableSnippetProps } from '../../types.js';
|
|
5
|
+
export interface WidgetSnippets {
|
|
6
|
+
image?: Snippet<[ImageSnippetProps]>;
|
|
7
|
+
codeHeader?: Snippet<[CodeHeaderSnippetProps]>;
|
|
8
|
+
table?: Snippet<[TableSnippetProps]>;
|
|
9
|
+
}
|
|
10
|
+
/** User snippets that replace the default widgets. */
|
|
11
|
+
export declare const widgetSnippets: Facet<WidgetSnippets, WidgetSnippets>;
|
|
12
|
+
/**
|
|
13
|
+
* Widget decorations for images, task checkboxes, horizontal rules and fenced code.
|
|
14
|
+
* Pure, so it can be tested against an `EditorState` without a DOM.
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildBlockDecorations(state: EditorState): DecorationSet;
|
|
17
|
+
/** Block and widget decorations. A StateField, since some of them change line layout. */
|
|
18
|
+
export declare const blockWidgetsField: StateField<DecorationSet>;
|
|
19
|
+
/** Images, task checkboxes, horizontal rules, fenced code blocks and tables as widgets. */
|
|
20
|
+
export declare function blockWidgets(): Extension;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { syntaxTree } from '@codemirror/language';
|
|
2
|
+
import { Facet, Prec, StateField } from '@codemirror/state';
|
|
3
|
+
import { Decoration, EditorView, WidgetType, keymap } from '@codemirror/view';
|
|
4
|
+
import { isRevealed, revealInputsChanged } from '../utils/cursor.js';
|
|
5
|
+
import { SvelteWidget } from '../utils/svelte-widget.svelte.js';
|
|
6
|
+
import { codeBlockKeymap, fencedCodeParts } from '../utils/code-blocks.js';
|
|
7
|
+
import { linkDestination, linkTitle } from '../utils/syntax.js';
|
|
8
|
+
import { tableData } from '../utils/tables.js';
|
|
9
|
+
import ImageWidget from '../../components/widgets/ImageWidget.svelte';
|
|
10
|
+
import CheckboxWidget from '../../components/widgets/CheckboxWidget.svelte';
|
|
11
|
+
import CodeBlockWidget from '../../components/widgets/CodeBlockWidget.svelte';
|
|
12
|
+
import SnippetHost from '../../components/widgets/SnippetHost.svelte';
|
|
13
|
+
import TableWidget from '../../components/widgets/TableWidget.svelte';
|
|
14
|
+
/** User snippets that replace the default widgets. */
|
|
15
|
+
export const widgetSnippets = Facet.define({
|
|
16
|
+
combine: (values) => values[0] ?? {}
|
|
17
|
+
});
|
|
18
|
+
const snippetHost = SnippetHost;
|
|
19
|
+
class HorizontalRuleWidget extends WidgetType {
|
|
20
|
+
eq() {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
toDOM() {
|
|
24
|
+
const dom = document.createElement('span');
|
|
25
|
+
dom.className = 'cm-md-hr';
|
|
26
|
+
dom.setAttribute('role', 'separator');
|
|
27
|
+
return dom;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const horizontalRule = Decoration.replace({ widget: new HorizontalRuleWidget() });
|
|
31
|
+
const codeLine = Decoration.line({ class: 'cm-md-codeblock' });
|
|
32
|
+
const codeLineBegin = Decoration.line({ class: 'cm-md-codeblock cm-md-codeblock-begin' });
|
|
33
|
+
const codeLineEnd = Decoration.line({ class: 'cm-md-codeblock cm-md-codeblock-end' });
|
|
34
|
+
const codeLineSingle = Decoration.line({
|
|
35
|
+
class: 'cm-md-codeblock cm-md-codeblock-begin cm-md-codeblock-end'
|
|
36
|
+
});
|
|
37
|
+
/** A widget for the user's snippet if one is configured, else for the default component. */
|
|
38
|
+
function snippetOr(snippet, component, props, className, block = false) {
|
|
39
|
+
const options = { class: className, block };
|
|
40
|
+
if (snippet)
|
|
41
|
+
return new SvelteWidget(snippetHost, { snippet, ...props }, options);
|
|
42
|
+
return new SvelteWidget(component, props, options);
|
|
43
|
+
}
|
|
44
|
+
function addImage(state, node, out) {
|
|
45
|
+
if (isRevealed(state, node.from, node.to))
|
|
46
|
+
return;
|
|
47
|
+
const marks = node.getChildren('LinkMark');
|
|
48
|
+
const url = node.getChild('URL');
|
|
49
|
+
if (!url || marks.length < 2)
|
|
50
|
+
return;
|
|
51
|
+
const title = node.getChild('LinkTitle');
|
|
52
|
+
const props = {
|
|
53
|
+
src: linkDestination(state, url),
|
|
54
|
+
alt: state.sliceDoc(marks[0].to, marks[1].from),
|
|
55
|
+
title: title ? linkTitle(state, title) : undefined
|
|
56
|
+
};
|
|
57
|
+
const snippet = state.facet(widgetSnippets).image;
|
|
58
|
+
const widget = snippetOr(snippet, ImageWidget, props, 'cm-md-image');
|
|
59
|
+
out.push(Decoration.replace({ widget }).range(node.from, node.to));
|
|
60
|
+
}
|
|
61
|
+
/** The range a block node's decorations live in. Code blocks and tables reveal per whole line. */
|
|
62
|
+
function extentOf(state, node) {
|
|
63
|
+
if (node.name !== 'FencedCode' && node.name !== 'Table')
|
|
64
|
+
return { from: node.from, to: node.to };
|
|
65
|
+
return { from: state.doc.lineAt(node.from).from, to: state.doc.lineAt(node.to).to };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Away from the cursor the block becomes one scrollable widget, starting at the fence
|
|
69
|
+
* so container markup before it (a list bullet, `>`) stays visible. Near it, the raw
|
|
70
|
+
* lines are styled and do not wrap.
|
|
71
|
+
*/
|
|
72
|
+
function addFencedCode(state, node, out) {
|
|
73
|
+
const { from, to } = extentOf(state, node);
|
|
74
|
+
const first = state.doc.lineAt(from).number;
|
|
75
|
+
const last = state.doc.lineAt(to).number;
|
|
76
|
+
if (isRevealed(state, from, to)) {
|
|
77
|
+
for (let n = first; n <= last; n++) {
|
|
78
|
+
const deco = first === last
|
|
79
|
+
? codeLineSingle
|
|
80
|
+
: n === first
|
|
81
|
+
? codeLineBegin
|
|
82
|
+
: n === last
|
|
83
|
+
? codeLineEnd
|
|
84
|
+
: codeLine;
|
|
85
|
+
out.push(deco.range(state.doc.line(n).from));
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const { language, info, code } = fencedCodeParts(state, node);
|
|
90
|
+
const widget = new SvelteWidget(CodeBlockWidget, { language, info, code, headerSnippet: state.facet(widgetSnippets).codeHeader }, { block: true, class: 'cm-md-codeblock-widget' });
|
|
91
|
+
out.push(Decoration.replace({ widget, block: true }).range(node.from, to));
|
|
92
|
+
}
|
|
93
|
+
const tableLine = Decoration.line({ class: 'cm-md-table-line' });
|
|
94
|
+
/**
|
|
95
|
+
* Away from the cursor a table renders as an HTML table, starting at its first pipe so
|
|
96
|
+
* container markup stays visible. Near it, the raw rows show unwrapped in monospace,
|
|
97
|
+
* so the pipes line up (see `formatTable`).
|
|
98
|
+
*/
|
|
99
|
+
function addTable(state, node, out) {
|
|
100
|
+
const { from, to } = extentOf(state, node);
|
|
101
|
+
if (isRevealed(state, from, to)) {
|
|
102
|
+
for (let n = state.doc.lineAt(from).number; n <= state.doc.lineAt(to).number; n++) {
|
|
103
|
+
out.push(tableLine.range(state.doc.line(n).from));
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const snippet = state.facet(widgetSnippets).table;
|
|
108
|
+
const widget = snippetOr(snippet, TableWidget, { table: tableData(state, node) }, 'cm-md-table', true);
|
|
109
|
+
out.push(Decoration.replace({ widget, block: true }).range(node.from, to));
|
|
110
|
+
}
|
|
111
|
+
const BLOCK_NODES = new Set(['Image', 'TaskMarker', 'HorizontalRule', 'FencedCode', 'Table']);
|
|
112
|
+
/** Adds decorations for the block nodes overlapping `[from, to]`, reporting each node's extent. */
|
|
113
|
+
function collect(state, from, to, out, onNode) {
|
|
114
|
+
syntaxTree(state).iterate({
|
|
115
|
+
from,
|
|
116
|
+
to,
|
|
117
|
+
enter: (ref) => {
|
|
118
|
+
if (!BLOCK_NODES.has(ref.name))
|
|
119
|
+
return;
|
|
120
|
+
onNode?.(extentOf(state, ref.node));
|
|
121
|
+
switch (ref.name) {
|
|
122
|
+
case 'Image':
|
|
123
|
+
addImage(state, ref.node, out);
|
|
124
|
+
break;
|
|
125
|
+
case 'TaskMarker': {
|
|
126
|
+
const checked = /^\[[xX]\]$/.test(state.sliceDoc(ref.from, ref.to));
|
|
127
|
+
const widget = new SvelteWidget(CheckboxWidget, { checked }, { class: 'cm-md-task' });
|
|
128
|
+
out.push(Decoration.replace({ widget }).range(ref.from, ref.to));
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case 'HorizontalRule':
|
|
132
|
+
if (!isRevealed(state, ref.from, ref.to))
|
|
133
|
+
out.push(horizontalRule.range(ref.from, ref.to));
|
|
134
|
+
break;
|
|
135
|
+
case 'FencedCode':
|
|
136
|
+
addFencedCode(state, ref.node, out);
|
|
137
|
+
break;
|
|
138
|
+
case 'Table':
|
|
139
|
+
addTable(state, ref.node, out);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Widget decorations for images, task checkboxes, horizontal rules and fenced code.
|
|
148
|
+
* Pure, so it can be tested against an `EditorState` without a DOM.
|
|
149
|
+
*/
|
|
150
|
+
export function buildBlockDecorations(state) {
|
|
151
|
+
const out = [];
|
|
152
|
+
collect(state, 0, state.doc.length, out);
|
|
153
|
+
return Decoration.set(out, true);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* After a selection-only change, only nodes on lines touched by the old or new
|
|
157
|
+
* selection can switch between rendered and revealed: rebuild just those.
|
|
158
|
+
*/
|
|
159
|
+
function updateForSelection(decorations, startState, state) {
|
|
160
|
+
const extents = new Map();
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const range of [...startState.selection.ranges, ...state.selection.ranges]) {
|
|
163
|
+
const from = state.doc.lineAt(range.from).from;
|
|
164
|
+
const to = state.doc.lineAt(range.to).to;
|
|
165
|
+
collect(state, from, to, [], (extent) => extents.set(extent.from, extent));
|
|
166
|
+
}
|
|
167
|
+
if (!extents.size)
|
|
168
|
+
return decorations;
|
|
169
|
+
for (const extent of extents.values())
|
|
170
|
+
collect(state, extent.from, extent.to, out);
|
|
171
|
+
const inside = (from, to) => [...extents.values()].some((e) => from >= e.from && to <= e.to);
|
|
172
|
+
return decorations.update({
|
|
173
|
+
filter: (from, to) => !inside(from, to),
|
|
174
|
+
add: out.filter((range) => inside(range.from, range.to)),
|
|
175
|
+
sort: true
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/** Block and widget decorations. A StateField, since some of them change line layout. */
|
|
179
|
+
export const blockWidgetsField = StateField.define({
|
|
180
|
+
create: buildBlockDecorations,
|
|
181
|
+
update(decorations, tr) {
|
|
182
|
+
if (tr.docChanged ||
|
|
183
|
+
syntaxTree(tr.state) !== syntaxTree(tr.startState) ||
|
|
184
|
+
tr.state.facet(widgetSnippets) !== tr.startState.facet(widgetSnippets) ||
|
|
185
|
+
revealInputsChanged(tr.startState, tr.state)) {
|
|
186
|
+
return buildBlockDecorations(tr.state);
|
|
187
|
+
}
|
|
188
|
+
if (tr.selection)
|
|
189
|
+
return updateForSelection(decorations, tr.startState, tr.state);
|
|
190
|
+
return decorations;
|
|
191
|
+
},
|
|
192
|
+
provide: (field) => EditorView.decorations.from(field)
|
|
193
|
+
});
|
|
194
|
+
const blockTheme = EditorView.baseTheme({
|
|
195
|
+
'.cm-md-image': { display: 'inline-block', maxWidth: '100%', verticalAlign: 'bottom' },
|
|
196
|
+
'.cm-md-image img': { display: 'block', maxWidth: '100%', borderRadius: '4px' },
|
|
197
|
+
'.cm-md-image-error': {
|
|
198
|
+
display: 'inline-block',
|
|
199
|
+
padding: '0.2em 0.5em',
|
|
200
|
+
border: '1px dashed var(--md-border)',
|
|
201
|
+
borderRadius: '4px',
|
|
202
|
+
color: 'var(--md-muted)'
|
|
203
|
+
},
|
|
204
|
+
'.cm-md-task': { display: 'inline-block', verticalAlign: 'middle' },
|
|
205
|
+
'.cm-md-checkbox': {
|
|
206
|
+
margin: '0 0.25em 0 0',
|
|
207
|
+
cursor: 'pointer',
|
|
208
|
+
accentColor: 'var(--md-accent)'
|
|
209
|
+
},
|
|
210
|
+
'.cm-md-hr': {
|
|
211
|
+
display: 'inline-block',
|
|
212
|
+
width: '100%',
|
|
213
|
+
verticalAlign: 'middle',
|
|
214
|
+
borderTop: '1px solid var(--md-border)'
|
|
215
|
+
},
|
|
216
|
+
// Raw lines while editing: no wrapping; the editor scrolls sideways to the cursor.
|
|
217
|
+
// `width: 0; min-width: 100%` keeps a long line from widening the content (which
|
|
218
|
+
// would stop every paragraph from wrapping); the text overflows into the scroller.
|
|
219
|
+
// Same font size and line height as the rendered <pre>, so the block keeps its height.
|
|
220
|
+
'.cm-line.cm-md-codeblock': {
|
|
221
|
+
fontFamily: 'var(--md-font-mono)',
|
|
222
|
+
fontSize: '0.9em',
|
|
223
|
+
lineHeight: '1.5',
|
|
224
|
+
backgroundColor: 'var(--md-code-block-bg)',
|
|
225
|
+
padding: '0 0.75em',
|
|
226
|
+
boxSizing: 'border-box',
|
|
227
|
+
width: '0',
|
|
228
|
+
minWidth: '100%',
|
|
229
|
+
whiteSpace: 'pre',
|
|
230
|
+
wordBreak: 'normal',
|
|
231
|
+
overflowWrap: 'normal'
|
|
232
|
+
},
|
|
233
|
+
'.cm-md-codeblock-begin': { borderTopLeftRadius: '6px', borderTopRightRadius: '6px' },
|
|
234
|
+
'.cm-md-codeblock-end': { borderBottomLeftRadius: '6px', borderBottomRightRadius: '6px' },
|
|
235
|
+
// Rendered block: the <pre> scrolls on its own.
|
|
236
|
+
'.cm-md-codeblock-widget': {
|
|
237
|
+
// The editor content preserves whitespace; template whitespace here must not render.
|
|
238
|
+
whiteSpace: 'normal',
|
|
239
|
+
fontFamily: 'var(--md-font-mono)',
|
|
240
|
+
fontSize: '0.9em',
|
|
241
|
+
backgroundColor: 'var(--md-code-block-bg)',
|
|
242
|
+
borderRadius: '6px',
|
|
243
|
+
padding: '0.2em 0.75em'
|
|
244
|
+
},
|
|
245
|
+
'.cm-md-code-header': {
|
|
246
|
+
display: 'flex',
|
|
247
|
+
justifyContent: 'space-between',
|
|
248
|
+
alignItems: 'center',
|
|
249
|
+
fontSize: '0.85em',
|
|
250
|
+
color: 'var(--md-muted)'
|
|
251
|
+
},
|
|
252
|
+
'.cm-md-code-pre': {
|
|
253
|
+
margin: '0',
|
|
254
|
+
padding: '0.2em 0',
|
|
255
|
+
width: '0',
|
|
256
|
+
minWidth: '100%',
|
|
257
|
+
overflowX: 'auto',
|
|
258
|
+
whiteSpace: 'pre',
|
|
259
|
+
font: 'inherit',
|
|
260
|
+
lineHeight: '1.5',
|
|
261
|
+
outline: 'none',
|
|
262
|
+
cursor: 'text'
|
|
263
|
+
},
|
|
264
|
+
// Raw table rows while editing: monospace and unwrapped, like code lines.
|
|
265
|
+
'.cm-line.cm-md-table-line': {
|
|
266
|
+
fontFamily: 'var(--md-font-mono)',
|
|
267
|
+
fontSize: '0.9em',
|
|
268
|
+
boxSizing: 'border-box',
|
|
269
|
+
width: '0',
|
|
270
|
+
minWidth: '100%',
|
|
271
|
+
whiteSpace: 'pre',
|
|
272
|
+
wordBreak: 'normal',
|
|
273
|
+
overflowWrap: 'normal'
|
|
274
|
+
},
|
|
275
|
+
// The editor content breaks words anywhere (`overflow-wrap: anywhere`); inherited,
|
|
276
|
+
// that would crush a wide table into one-letter columns instead of scrolling it.
|
|
277
|
+
'.cm-md-table': {
|
|
278
|
+
whiteSpace: 'normal',
|
|
279
|
+
overflowWrap: 'normal',
|
|
280
|
+
wordBreak: 'normal',
|
|
281
|
+
padding: '0.25em 0'
|
|
282
|
+
},
|
|
283
|
+
'.cm-md-table-scroll': { width: '0', minWidth: '100%', overflowX: 'auto', outline: 'none' },
|
|
284
|
+
'.cm-md-table table': { borderCollapse: 'collapse' },
|
|
285
|
+
'.cm-md-table th, .cm-md-table td': {
|
|
286
|
+
border: '1px solid var(--md-border)',
|
|
287
|
+
padding: '0.3em 0.75em',
|
|
288
|
+
textAlign: 'left',
|
|
289
|
+
verticalAlign: 'top'
|
|
290
|
+
},
|
|
291
|
+
// Headers stay on one line, so many columns overflow into the scroll box; body
|
|
292
|
+
// cells still wrap between words.
|
|
293
|
+
'.cm-md-table th': {
|
|
294
|
+
fontWeight: '600',
|
|
295
|
+
whiteSpace: 'nowrap',
|
|
296
|
+
backgroundColor: 'var(--md-code-block-bg)'
|
|
297
|
+
},
|
|
298
|
+
// The UA `code { font-family: monospace }` would shrink the font (monospace quirk).
|
|
299
|
+
'.cm-md-code-pre code': { font: 'inherit' },
|
|
300
|
+
'.cm-md-code-copy': {
|
|
301
|
+
font: 'inherit',
|
|
302
|
+
color: 'inherit',
|
|
303
|
+
cursor: 'pointer',
|
|
304
|
+
background: 'transparent',
|
|
305
|
+
border: '1px solid var(--md-border)',
|
|
306
|
+
borderRadius: '4px',
|
|
307
|
+
padding: '0 0.5em'
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
/** Images, task checkboxes, horizontal rules, fenced code blocks and tables as widgets. */
|
|
311
|
+
export function blockWidgets() {
|
|
312
|
+
return [blockWidgetsField, blockTheme, Prec.high(keymap.of(codeBlockKeymap))];
|
|
313
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EditorState, Extension } from '@codemirror/state';
|
|
2
|
+
import { Decoration, type DecorationSet } from '@codemirror/view';
|
|
3
|
+
/** Replacement that hides a piece of Markdown syntax. */
|
|
4
|
+
export declare const hiddenMarkup: Decoration;
|
|
5
|
+
/**
|
|
6
|
+
* Inline live-preview decorations for the given ranges. Pure, so it can be tested
|
|
7
|
+
* against an `EditorState` without a DOM.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildInlineDecorations(state: EditorState, ranges: readonly {
|
|
10
|
+
from: number;
|
|
11
|
+
to: number;
|
|
12
|
+
}[]): DecorationSet;
|
|
13
|
+
/** Hides inline Markdown syntax (headings, emphasis, code, links) away from the cursor. */
|
|
14
|
+
export declare function inlineLivePreview(): Extension;
|