supamde 0.0.0-stage → 0.2.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 +1228 -2
- package/dist/commands/block.d.ts +20 -0
- package/dist/commands/code-indent.d.ts +38 -0
- package/dist/commands/history.d.ts +6 -0
- package/dist/commands/indent.d.ts +28 -0
- package/dist/commands/inline.d.ts +9 -0
- package/dist/commands/keymap.d.ts +8 -0
- package/dist/commands/link-image.d.ts +13 -0
- package/dist/commands/list.d.ts +20 -0
- package/dist/commands/prefixes.d.ts +29 -0
- package/dist/commands/queries.d.ts +25 -0
- package/dist/commands/table.d.ts +7 -0
- package/dist/commands/types.d.ts +17 -0
- package/dist/editor/extensions.d.ts +14 -0
- package/dist/editor/highlight.d.ts +11 -0
- package/dist/editor/link-click.d.ts +53 -0
- package/dist/editor/link-hover-cursor.d.ts +44 -0
- package/dist/editor/math.d.ts +8 -0
- package/dist/editor/setup.d.ts +26 -0
- package/dist/editor/theme.d.ts +7 -0
- package/dist/editor/tokens.d.ts +16 -0
- package/dist/editor/value.d.ts +10 -0
- package/dist/features/autosave.d.ts +31 -0
- package/dist/features/image-upload.d.ts +73 -0
- package/dist/features/storage.d.ts +30 -0
- package/dist/features/upload-dom.d.ts +20 -0
- package/dist/features/upload-placeholder.d.ts +35 -0
- package/dist/features/word-count.d.ts +5 -0
- package/dist/i18n/de.d.ts +3 -0
- package/dist/i18n/en.d.ts +3 -0
- package/dist/i18n/facet.d.ts +9 -0
- package/dist/i18n/translator.d.ts +17 -0
- package/dist/i18n/types.d.ts +82 -0
- package/dist/index.d.ts +133 -0
- package/dist/livepreview/index.d.ts +22 -0
- package/dist/livepreview/plugin.d.ts +3 -0
- package/dist/livepreview/ranges.d.ts +62 -0
- package/dist/markdown/katex-marked.d.ts +20 -0
- package/dist/markdown/parse.d.ts +27 -0
- package/dist/markdown/sanitize.d.ts +6 -0
- package/dist/options.d.ts +83 -0
- package/dist/supamde.css +2 -0
- package/dist/supamde.mjs +3617 -0
- package/dist/supamde.mjs.map +1 -0
- package/dist/ui/actions.d.ts +51 -0
- package/dist/ui/fullscreen.d.ts +16 -0
- package/dist/ui/icons.d.ts +4 -0
- package/dist/ui/inject-styles.d.ts +13 -0
- package/dist/ui/preview.d.ts +27 -0
- package/dist/ui/shortcut-label.d.ts +24 -0
- package/dist/ui/statusbar.d.ts +28 -0
- package/dist/ui/toolbar-config.d.ts +36 -0
- package/dist/ui/toolbar.d.ts +16 -0
- package/dist/ui/update-listener.d.ts +15 -0
- package/dist/util/text-format.d.ts +11 -0
- package/dist/utils/html.d.ts +2 -0
- package/dist/utils/text.d.ts +41 -0
- package/dist/version.d.ts +2 -0
- package/package.json +96 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1229 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SupaMDE
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A modern, embeddable Markdown editor built on **CodeMirror 6** — a modernized
|
|
4
|
+
rewrite of [easyMDE](https://github.com/Ionaru/easy-markdown-editor).
|
|
5
|
+
|
|
6
|
+
> **Status:** First public release (**0.1.x**). The API may still change before
|
|
7
|
+
> 1.0 — breaking changes come with a new minor version.
|
|
8
|
+
|
|
9
|
+
**[Live demo](https://svenho.github.io/SupaMDE/)**
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
> **Requirement:** Node.js ≥ 22.13 (or ≥ 24).
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install supamde
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Updating
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm update supamde
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`npm update` stays within the semver range from your `package.json` (by
|
|
26
|
+
default `^0.1.0`). As long as SupaMDE is in the `0.x` series, every minor
|
|
27
|
+
release may contain breaking changes — moving to a new minor version (e.g.
|
|
28
|
+
`0.2.0`) requires `npm install supamde@latest`.
|
|
29
|
+
|
|
30
|
+
### Alternative: straight from the Git repo
|
|
31
|
+
|
|
32
|
+
For unreleased states, SupaMDE can also be installed directly from the repo —
|
|
33
|
+
either from the default branch or from a tag:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# current state of the default branch
|
|
37
|
+
npm install git+https://github.com/svenho/SupaMDE.git
|
|
38
|
+
|
|
39
|
+
# fixed tag
|
|
40
|
+
npm install git+https://github.com/svenho/SupaMDE.git#v0.1.0
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Without a tag, npm resolves the URL to the current HEAD and writes the commit
|
|
44
|
+
hash into `package-lock.json`; `npm update` does **not** help then, only
|
|
45
|
+
re-running `npm install … --force` fetches the new state.
|
|
46
|
+
|
|
47
|
+
**What happens on a Git install:** `dist/` is not checked in but built during
|
|
48
|
+
installation — the `prepare` script triggers `npm run build`. npm clones the
|
|
49
|
+
repo for this and installs the build dependencies (Vite, TypeScript & co.).
|
|
50
|
+
The install therefore takes noticeably longer than with the published npm
|
|
51
|
+
package; the build itself takes only a few seconds.
|
|
52
|
+
|
|
53
|
+
> **Important for build dependencies:** Everything the build has to resolve at
|
|
54
|
+
> build time and that is not listed in `build.rollupOptions.external` belongs
|
|
55
|
+
> in `devDependencies` (example: `lucide`, whose icons are bundled and which
|
|
56
|
+
> is therefore neither a dependency nor a peer dependency). On a Git install,
|
|
57
|
+
> npm installs the `devDependencies` for `prepare`; if something is missing
|
|
58
|
+
> there, the build aborts with "failed to resolve import". Peer dependencies
|
|
59
|
+
> that the build resolves (e.g. for types) therefore also belong in
|
|
60
|
+
> `devDependencies`.
|
|
61
|
+
|
|
62
|
+
### CodeMirror 6 as peer dependencies
|
|
63
|
+
|
|
64
|
+
SupaMDE does **not** bundle CodeMirror 6 — the CM6/Lezer packages are peer
|
|
65
|
+
dependencies and must be installed in your project. This way SupaMDE shares
|
|
66
|
+
the same CM6 instance as the rest of your code (npm dedupes by version), and
|
|
67
|
+
no duplicate or incompatible CM6 copies end up in your bundle:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install \
|
|
71
|
+
@codemirror/view @codemirror/state @codemirror/commands \
|
|
72
|
+
@codemirror/language @codemirror/lang-markdown \
|
|
73
|
+
@lezer/common @lezer/highlight @lezer/markdown
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
SupaMDE is shipped as ESM and is meant to be used with a bundler (Vite,
|
|
77
|
+
esbuild, Rollup, webpack …) that resolves the bare imports.
|
|
78
|
+
|
|
79
|
+
### KaTeX (optional, for formulas in the preview)
|
|
80
|
+
|
|
81
|
+
The live preview renders LaTeX formulas (`$…$`, `$$…$$`, `\begin{align}` inside
|
|
82
|
+
`$$`) with **KaTeX**. KaTeX is an **optional** peer dependency — if it is not
|
|
83
|
+
installed, the preview shows plain Markdown and leaves formulas as text. To
|
|
84
|
+
enable it:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm install katex
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Also include the KaTeX CSS (including fonts) in the host page, e.g.:
|
|
91
|
+
|
|
92
|
+
```html
|
|
93
|
+
<link rel="stylesheet" href="/node_modules/katex/dist/katex.min.css" />
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Basic usage
|
|
97
|
+
|
|
98
|
+
```html
|
|
99
|
+
<textarea id="editor"># Hello **world**</textarea>
|
|
100
|
+
<script type="module">
|
|
101
|
+
import SupaMDE from 'supamde';
|
|
102
|
+
const editor = new SupaMDE({ element: document.getElementById('editor') });
|
|
103
|
+
</script>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Styles
|
|
107
|
+
|
|
108
|
+
Toolbar, status bar, preview panel and fullscreen need CSS. SupaMDE injects
|
|
109
|
+
these rules itself as a `<style>` tag into the head on the first constructor
|
|
110
|
+
call — normally there is **nothing else to do**. The tag carries the
|
|
111
|
+
`data-supamde-styles` attribute, is inserted as the first child of the head,
|
|
112
|
+
and is added only once per page (multiple instances share it).
|
|
113
|
+
|
|
114
|
+
The position at the very start of the head is intentional: your own
|
|
115
|
+
stylesheets come after it and therefore win at equal specificity — overrides
|
|
116
|
+
don't need `!important`.
|
|
117
|
+
|
|
118
|
+
If you want the styles to go through your own build pipeline (purging, order
|
|
119
|
+
control, custom theming), turn off the auto-inject and include the bundled
|
|
120
|
+
stylesheet yourself:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import SupaMDE from 'supamde';
|
|
124
|
+
import 'supamde/style.css';
|
|
125
|
+
|
|
126
|
+
new SupaMDE({ element: document.getElementById('editor'), injectStyles: false });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Without either — neither auto-inject nor a manual import — the editor appears
|
|
130
|
+
unstyled: the toolbar buttons show up as a bare row of icons.
|
|
131
|
+
|
|
132
|
+
Colors, border widths and radius are controlled via CSS variables on
|
|
133
|
+
`.supamde-container`, which you can override.
|
|
134
|
+
|
|
135
|
+
Every line can be controlled individually; there is deliberately no master
|
|
136
|
+
switch for all lines. `--supamde-border-width` only affects the outer border —
|
|
137
|
+
the dividers inside are not affected.
|
|
138
|
+
|
|
139
|
+
| Variable | Default | Effect |
|
|
140
|
+
| ----------------------------------- | ------------------------ | ---------------------------------------- |
|
|
141
|
+
| `--supamde-border-color` | `#d0d0d0` | Color of all borders and dividers. |
|
|
142
|
+
| `--supamde-border-width` | `1px` | Outer border, all four edges. |
|
|
143
|
+
| `--supamde-border-top-width` | `--supamde-border-width` | Outer border, top only. |
|
|
144
|
+
| `--supamde-border-right-width` | `--supamde-border-width` | Outer border, right only. |
|
|
145
|
+
| `--supamde-border-bottom-width` | `--supamde-border-width` | Outer border, bottom only. |
|
|
146
|
+
| `--supamde-border-left-width` | `--supamde-border-width` | Outer border, left only. |
|
|
147
|
+
| `--supamde-radius` | `4px` | Corner radius of the container. |
|
|
148
|
+
| `--supamde-divider-toolbar-width` | `1px` | Divider toolbar ↔ content. |
|
|
149
|
+
| `--supamde-divider-statusbar-width` | `1px` | Divider content ↔ status bar. |
|
|
150
|
+
| `--supamde-divider-preview-width` | `1px` | Divider editor ↔ preview (side by side). |
|
|
151
|
+
| `--supamde-toolbar-bg` | `#f7f7f7` | Toolbar background. |
|
|
152
|
+
| `--supamde-statusbar-bg` | `#f7f7f7` | Status bar background. |
|
|
153
|
+
| `--supamde-btn-hover` | `#e6e6e6` | Button hover. |
|
|
154
|
+
| `--supamde-btn-active` | `#d8e6ff` | Active button. |
|
|
155
|
+
| `--supamde-btn-text` | `#333` | Icon/text color of the buttons. |
|
|
156
|
+
|
|
157
|
+
**Turn off only the outer border**, keep the dividers inside:
|
|
158
|
+
|
|
159
|
+
```css
|
|
160
|
+
.supamde-container {
|
|
161
|
+
--supamde-border-width: 0;
|
|
162
|
+
--supamde-radius: 0;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Completely borderless** — every line turned off individually:
|
|
167
|
+
|
|
168
|
+
```css
|
|
169
|
+
.supamde-container {
|
|
170
|
+
--supamde-border-width: 0;
|
|
171
|
+
--supamde-radius: 0;
|
|
172
|
+
--supamde-divider-toolbar-width: 0;
|
|
173
|
+
--supamde-divider-statusbar-width: 0;
|
|
174
|
+
--supamde-divider-preview-width: 0;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Individual edges** — e.g. flush in a column, with a line only at the top and
|
|
179
|
+
bottom:
|
|
180
|
+
|
|
181
|
+
```css
|
|
182
|
+
.supamde-container {
|
|
183
|
+
--supamde-border-left-width: 0;
|
|
184
|
+
--supamde-border-right-width: 0;
|
|
185
|
+
--supamde-radius: 0;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
> If your editor still shows a border, it comes from the host project: SupaMDE
|
|
190
|
+
> sets no border on `.cm-editor` itself. Common sources are global resets or
|
|
191
|
+
> framework rules (e.g. Bootstrap's `.form-control`).
|
|
192
|
+
|
|
193
|
+
> **Breaking change:** The color variable used to be called `--supamde-border`.
|
|
194
|
+
> The name suggested a `border` shorthand but only accepted a color — hence
|
|
195
|
+
> `--supamde-border-color` now. The old name no longer has any effect.
|
|
196
|
+
|
|
197
|
+
## Options (core)
|
|
198
|
+
|
|
199
|
+
| Option | Type | Default | Meaning |
|
|
200
|
+
| ---------------- | ---------------------- | ---------------- | ---------------------------------------------------------------------------- |
|
|
201
|
+
| `element` | `HTMLTextAreaElement` | — | **Required.** The textarea to replace. |
|
|
202
|
+
| `lineWrapping` | `boolean` | `true` | Wrap lines instead of scrolling horizontally. |
|
|
203
|
+
| `placeholder` | `string` | — | Placeholder text in the empty editor. |
|
|
204
|
+
| `autofocus` | `boolean` | `false` | Focuses the editor after creation. |
|
|
205
|
+
| `tabSize` | `number` | `2` | Tab width in columns. |
|
|
206
|
+
| `indentUnit` | `number` | `2` | Indentation depth in spaces. |
|
|
207
|
+
| `codeIndentUnit` | `number \| 'tab'` | `indentUnit` | Indentation inside code blocks: spaces, or `'tab'` for a real tab character. |
|
|
208
|
+
| `initialValue` | `string` | textarea content | Initial value (overrides the textarea). |
|
|
209
|
+
| `extraKeys` | `KeyBinding[]` | `[]` | Custom CM6 key bindings; take precedence over defaults. |
|
|
210
|
+
| `autosave` | `AutosaveOptions` | — | Autosave, see [Autosave](#autosave). |
|
|
211
|
+
| `uploadImage` | `UploadImageOptions` | — | Image upload, see [Image upload](#image-upload). |
|
|
212
|
+
| `locale` | `Locale` | `en` | UI language, see [Localization](#localization). |
|
|
213
|
+
| `texts` | `Partial<LocaleTexts>` | — | Overrides individual UI texts; takes precedence over `locale`. |
|
|
214
|
+
|
|
215
|
+
## Toolbar & status bar
|
|
216
|
+
|
|
217
|
+
| Option | Type | Default | Meaning |
|
|
218
|
+
| --------- | -------------------------------------------- | ------------------------------ | ----------------------------------- |
|
|
219
|
+
| `toolbar` | `false \| Array<string \| CustomButton>` | default toolbar | Toolbar layout. `false` hides it. |
|
|
220
|
+
| `status` | `false \| Array<string \| CustomStatusItem>` | `['lines', 'words', 'cursor']` | Status bar items. `false` hides it. |
|
|
221
|
+
|
|
222
|
+
**Built-in toolbar buttons:** `bold`, `italic`, `strikethrough`, `code`,
|
|
223
|
+
`heading-smaller`, `heading-bigger`, `heading-1`…`heading-6`, `quote`, `code-block`,
|
|
224
|
+
`horizontal-rule`, `clean-block`, `unordered-list`, `ordered-list`, `check-list`,
|
|
225
|
+
`link`, `image`, `table`, `undo`, `redo`, `preview-fullscreen`, `side-by-side`,
|
|
226
|
+
`fullscreen`, `editor-mode`, `upload-image` (only useful with image upload
|
|
227
|
+
enabled; not in the default). `'|'` inserts a separator.
|
|
228
|
+
|
|
229
|
+
**View buttons:** `preview-fullscreen` toggles the side-by-side preview and
|
|
230
|
+
fullscreen **together** — one click is enough for the "preview in fullscreen"
|
|
231
|
+
working mode. The button counts as active when both modes are on; from a
|
|
232
|
+
partial state (only preview or only fullscreen) a click turns both on. It is
|
|
233
|
+
part of the default toolbar. The individual buttons `side-by-side` and
|
|
234
|
+
`fullscreen` remain available but are **no longer** in the default — if you
|
|
235
|
+
still want them separately, add them explicitly to the `toolbar` option:
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
new SupaMDE({
|
|
239
|
+
element: document.querySelector('#editor'),
|
|
240
|
+
toolbar: ['bold', 'italic', '|', 'side-by-side', 'fullscreen'],
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Custom buttons** keep the easyMDE signature:
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
{
|
|
248
|
+
name: 'shout',
|
|
249
|
+
title: 'Uppercase',
|
|
250
|
+
className: 'fa fa-bullhorn', // optional custom icon class
|
|
251
|
+
action: (editor) => editor.setValue(editor.getValue().toUpperCase()),
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Status bar items:** `lines`, `words`, `cursor`, `autosave`, `upload-image`.
|
|
256
|
+
`autosave` and `upload-image` are **not** part of `DEFAULT_STATUS` — add them
|
|
257
|
+
to the `status` option if you want them (see [Autosave](#autosave) and
|
|
258
|
+
[Image upload](#image-upload)). Custom items via
|
|
259
|
+
`{ className, defaultValue, onUpdate, onActivity }`.
|
|
260
|
+
|
|
261
|
+
**Icons:** The built-in buttons use bundled [Lucide](https://lucide.dev) SVG
|
|
262
|
+
icons — **no** icon font needs to be included. Custom buttons can still use
|
|
263
|
+
their own icon fonts (e.g. FontAwesome) via `className`.
|
|
264
|
+
|
|
265
|
+
## Options (preview & fullscreen)
|
|
266
|
+
|
|
267
|
+
| Option | Type | Default | Meaning |
|
|
268
|
+
| ---------------------------------- | -------------------- | ------- | --------------------------------------------------------------- |
|
|
269
|
+
| `previewRender` | `(text) => string` | — | Replaces the built-in Markdown renderer entirely. |
|
|
270
|
+
| `previewClass` | `string \| string[]` | — | Additional CSS classes on the preview panel. Multiple as array. |
|
|
271
|
+
| `renderingConfig.singleLineBreaks` | `boolean` | `true` | Single line break → `<br>`. |
|
|
272
|
+
| `syncSideBySidePreviewScroll` | `boolean` | `true` | Bidirectional scroll sync in side-by-side mode. |
|
|
273
|
+
| `onToggleFullScreen` | `(active) => void` | — | Callback when fullscreen is toggled. |
|
|
274
|
+
| `injectStyles` | `boolean` | `true` | Injects the SupaMDE styles into the head automatically. |
|
|
275
|
+
|
|
276
|
+
### Styling the preview
|
|
277
|
+
|
|
278
|
+
For the preview, the bundled CSS defines **only the layout**: half width, its
|
|
279
|
+
own scrolling, divider, padding. For the content — `h1`, `p`, `ul`, `pre`,
|
|
280
|
+
`table` — SupaMDE deliberately ships **no** rules.
|
|
281
|
+
|
|
282
|
+
This is intentional: the preview should look like your final result, not like
|
|
283
|
+
a foreign object. An editor package that dumps its own typography into your
|
|
284
|
+
page unasked would clash with it.
|
|
285
|
+
|
|
286
|
+
The consequence: the preview inherits your page's typography. If your project
|
|
287
|
+
uses a CSS reset or Tailwind's Preflight, `h1` and `ul` are flattened there —
|
|
288
|
+
the preview then looks unformatted even though the editor looks correct. One
|
|
289
|
+
of the following three approaches fixes that.
|
|
290
|
+
|
|
291
|
+
#### Approach 1 — attach an existing typography class (recommended)
|
|
292
|
+
|
|
293
|
+
`previewClass` adds arbitrary classes to the preview panel. Use the class your
|
|
294
|
+
project already uses to display rendered content — then the preview shows
|
|
295
|
+
exactly the final result:
|
|
296
|
+
|
|
297
|
+
```js
|
|
298
|
+
new SupaMDE({
|
|
299
|
+
element: document.getElementById('editor'),
|
|
300
|
+
// Tailwind + @tailwindcss/typography
|
|
301
|
+
previewClass: ['prose', 'max-w-none'],
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
> **Pass multiple classes as an array, not as one string with spaces.**
|
|
306
|
+
> `previewClass: 'prose max-w-none'` throws an `InvalidCharacterError` and
|
|
307
|
+
> prevents the editor from starting — the classes are passed to
|
|
308
|
+
> `classList.add()`, which does not allow spaces within a single token. A
|
|
309
|
+
> single class as a string is fine.
|
|
310
|
+
|
|
311
|
+
`max-w-none` makes sense with `prose`, because the class otherwise limits the
|
|
312
|
+
width to about 65 characters, which looks unnecessarily narrow in the
|
|
313
|
+
half-width panel. In dark mode, add `dark:prose-invert`.
|
|
314
|
+
|
|
315
|
+
Without Tailwind, the same works with your own content class:
|
|
316
|
+
`previewClass: 'content'`.
|
|
317
|
+
|
|
318
|
+
#### Approach 2 — your own stylesheet against `.supamde-preview-side`
|
|
319
|
+
|
|
320
|
+
If your project has no typography system, style the **children** of the
|
|
321
|
+
panel. Don't redefine the panel class itself — it carries the layout:
|
|
322
|
+
|
|
323
|
+
```css
|
|
324
|
+
/* Don't push the first heading down */
|
|
325
|
+
.supamde-preview-side > :first-child {
|
|
326
|
+
margin-top: 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.supamde-preview-side h1 {
|
|
330
|
+
font-size: 1.75em;
|
|
331
|
+
margin: 0.6em 0 0.4em;
|
|
332
|
+
}
|
|
333
|
+
.supamde-preview-side h2 {
|
|
334
|
+
font-size: 1.4em;
|
|
335
|
+
margin: 0.6em 0 0.4em;
|
|
336
|
+
}
|
|
337
|
+
.supamde-preview-side p {
|
|
338
|
+
margin: 0 0 1em;
|
|
339
|
+
line-height: 1.6;
|
|
340
|
+
}
|
|
341
|
+
.supamde-preview-side ul,
|
|
342
|
+
.supamde-preview-side ol {
|
|
343
|
+
padding-left: 1.5em;
|
|
344
|
+
margin: 0 0 1em;
|
|
345
|
+
}
|
|
346
|
+
.supamde-preview-side blockquote {
|
|
347
|
+
margin: 0 0 1em;
|
|
348
|
+
padding-left: 1em;
|
|
349
|
+
border-left: 3px solid var(--supamde-border-color, #d0d0d0);
|
|
350
|
+
color: #555;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* overflow-x keeps a long code block from breaking the 50/50 layout */
|
|
354
|
+
.supamde-preview-side pre {
|
|
355
|
+
background: #f6f8fa;
|
|
356
|
+
padding: 0.75em 1em;
|
|
357
|
+
border-radius: 4px;
|
|
358
|
+
overflow-x: auto;
|
|
359
|
+
}
|
|
360
|
+
.supamde-preview-side :not(pre) > code {
|
|
361
|
+
background: #f6f8fa;
|
|
362
|
+
padding: 0.15em 0.35em;
|
|
363
|
+
border-radius: 3px;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.supamde-preview-side table {
|
|
367
|
+
border-collapse: collapse;
|
|
368
|
+
margin: 0 0 1em;
|
|
369
|
+
}
|
|
370
|
+
.supamde-preview-side th,
|
|
371
|
+
.supamde-preview-side td {
|
|
372
|
+
border: 1px solid var(--supamde-border-color, #d0d0d0);
|
|
373
|
+
padding: 0.3em 0.6em;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* Keep images from overflowing the panel width */
|
|
377
|
+
.supamde-preview-side img {
|
|
378
|
+
max-width: 100%;
|
|
379
|
+
height: auto;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* Checklists: marked renders <input type="checkbox"> inside the <li> */
|
|
383
|
+
.supamde-preview-side li:has(input[type='checkbox']) {
|
|
384
|
+
list-style: none;
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Include this stylesheet in your page as usual. It wins without `!important`,
|
|
389
|
+
because the SupaMDE styles sit as the first child of the head (see
|
|
390
|
+
[Styles](#styles)).
|
|
391
|
+
|
|
392
|
+
#### Approach 3 — replace the renderer
|
|
393
|
+
|
|
394
|
+
If you need your own markup (different classes, syntax highlighting, your own
|
|
395
|
+
sanitizer), `previewRender` replaces the built-in renderer entirely:
|
|
396
|
+
|
|
397
|
+
```js
|
|
398
|
+
new SupaMDE({
|
|
399
|
+
element: document.getElementById('editor'),
|
|
400
|
+
previewRender: (text) => myRenderer(text), // returns finished HTML
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The return value is written into the panel as HTML — escaping and
|
|
405
|
+
sanitization are then your responsibility.
|
|
406
|
+
|
|
407
|
+
#### Which HTML is produced
|
|
408
|
+
|
|
409
|
+
For writing your own rules — the built-in renderer (`marked`) produces plain
|
|
410
|
+
HTML without classes of its own:
|
|
411
|
+
|
|
412
|
+
| Markdown | HTML |
|
|
413
|
+
| --------------- | ------------------------------------------------------- |
|
|
414
|
+
| Headings | `<h1>` … `<h6>` |
|
|
415
|
+
| Paragraph | `<p>` |
|
|
416
|
+
| Lists | `<ul>` / `<ol>` with `<li>` |
|
|
417
|
+
| Checklist | `<li>` with `<input type="checkbox" disabled>` |
|
|
418
|
+
| Quote | `<blockquote>` |
|
|
419
|
+
| Code block | `<pre><code class="language-js">` |
|
|
420
|
+
| Inline code | `<code>` |
|
|
421
|
+
| Table | `<table>`, `<thead>`, `<tbody>`, `<th>`, `<td>` |
|
|
422
|
+
| Link | `<a target="_blank" rel="noopener noreferrer">` |
|
|
423
|
+
| Horizontal rule | `<hr>` |
|
|
424
|
+
| LaTeX (KaTeX) | `<span class="katex">`, or `.katex-display` for `$$…$$` |
|
|
425
|
+
|
|
426
|
+
Two things that are easy to be surprised by:
|
|
427
|
+
|
|
428
|
+
- **Tables need `renderingConfig.singleLineBreaks: false`.** By default every
|
|
429
|
+
single line break becomes `<br>`, so the rows of a Markdown table arrive as
|
|
430
|
+
separate paragraphs instead of a `<table>`.
|
|
431
|
+
- **LaTeX formulas additionally need the KaTeX CSS** in the host page (see
|
|
432
|
+
[KaTeX](#katex-optional-for-formulas-in-the-preview)). Without this
|
|
433
|
+
stylesheet the formula stays unformatted, even if KaTeX is installed.
|
|
434
|
+
|
|
435
|
+
## Editor mode (live preview)
|
|
436
|
+
|
|
437
|
+
SupaMDE has two display modes:
|
|
438
|
+
|
|
439
|
+
| Mode | Behavior |
|
|
440
|
+
| -------------------- | --------------------------------------------------------------------------- |
|
|
441
|
+
| `'source'` (default) | The Markdown markup stays visible and is formatted live. |
|
|
442
|
+
| `'live'` | The markup is hidden and only appears where the cursor is (Obsidian style). |
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
const editor = new SupaMDE({
|
|
446
|
+
element: document.querySelector('#editor'),
|
|
447
|
+
editorMode: 'live',
|
|
448
|
+
});
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
| Option | Type | Default | Description |
|
|
452
|
+
| ------------ | -------------------- | ---------- | ------------------------ |
|
|
453
|
+
| `editorMode` | `'source' \| 'live'` | `'source'` | Display mode at startup. |
|
|
454
|
+
|
|
455
|
+
**Switching at runtime:**
|
|
456
|
+
|
|
457
|
+
| Method | Description |
|
|
458
|
+
| --------------------- | ---------------------------- |
|
|
459
|
+
| `getEditorMode()` | Returns the current mode. |
|
|
460
|
+
| `setEditorMode(mode)` | Sets the mode. Idempotent. |
|
|
461
|
+
| `toggleEditorMode()` | Switches between both modes. |
|
|
462
|
+
|
|
463
|
+
Switching preserves document, cursor, selection, undo history and scroll
|
|
464
|
+
position.
|
|
465
|
+
|
|
466
|
+
**What is hidden in live mode:** the markers of bold, italic, strikethrough,
|
|
467
|
+
inline code, ATX headings (`#` … `######`) and blockquotes. Fenced code blocks
|
|
468
|
+
and setext headings (underlined with `=`/`-`) stay fully visible, as do list
|
|
469
|
+
markers and link syntax.
|
|
470
|
+
|
|
471
|
+
In both modes the text remains editable Markdown source — copied text always
|
|
472
|
+
contains the full markup.
|
|
473
|
+
|
|
474
|
+
**Toolbar button:** The `'editor-mode'` action is deliberately **not** part of
|
|
475
|
+
the default toolbar. If you want it, add it to your own `toolbar` list:
|
|
476
|
+
|
|
477
|
+
```js
|
|
478
|
+
new SupaMDE({
|
|
479
|
+
element: document.querySelector('#editor'),
|
|
480
|
+
toolbar: ['bold', 'italic', '|', 'editor-mode'],
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## Autosave
|
|
485
|
+
|
|
486
|
+
Autosave stores the content in a pluggable storage — the draft survives
|
|
487
|
+
crashes, accidental closing and reloads. **Off** by default.
|
|
488
|
+
|
|
489
|
+
The minimum is two lines:
|
|
490
|
+
|
|
491
|
+
```js
|
|
492
|
+
const editor = new SupaMDE({
|
|
493
|
+
element: document.querySelector('#editor'),
|
|
494
|
+
autosave: { enabled: true, key: 'article-42' },
|
|
495
|
+
});
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
With a status display and a notice when a draft was restored:
|
|
499
|
+
|
|
500
|
+
```js
|
|
501
|
+
const editor = new SupaMDE({
|
|
502
|
+
element: document.querySelector('#editor'),
|
|
503
|
+
autosave: {
|
|
504
|
+
enabled: true,
|
|
505
|
+
key: 'article-42',
|
|
506
|
+
delay: 1000,
|
|
507
|
+
onRestore: (draft) => {
|
|
508
|
+
showNotice(`An unsaved draft was restored (${draft.length} characters).`);
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
status: ['lines', 'words', 'cursor', 'autosave'],
|
|
512
|
+
});
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
| Option | Type | Default | Description |
|
|
516
|
+
| ----------- | ------------------------- | ------------ | ----------------------------------------------------- |
|
|
517
|
+
| `enabled` | `boolean` | `false` | Enables autosave. |
|
|
518
|
+
| `key` | `string` | — | **Required.** Identifies the document in the storage. |
|
|
519
|
+
| `delay` | `number` | `1000` | Debounce after the last change, in ms. |
|
|
520
|
+
| `storage` | `SupaStorage` | localStorage | Custom storage (see below). |
|
|
521
|
+
| `onRestore` | `(saved: string) => void` | — | Called when a draft was loaded at startup. |
|
|
522
|
+
|
|
523
|
+
**Choose the `key` carefully.** It is the only thing that distinguishes two
|
|
524
|
+
documents. Two editors with the same `key` overwrite each other — so in
|
|
525
|
+
practice include the document ID, not just `'editor'`:
|
|
526
|
+
|
|
527
|
+
```js
|
|
528
|
+
autosave: { enabled: true, key: `article-${articleId}` }
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
In localStorage the entry is stored under `supamde:<key>`.
|
|
532
|
+
|
|
533
|
+
**When a draft is restored.** At startup SupaMDE reads the saved state. If it
|
|
534
|
+
is not empty **and** differs from the current document, it wins over the
|
|
535
|
+
textarea content and `onRestore` is called. If both match, nothing happens —
|
|
536
|
+
there is no draft to restore if it equals the initial content.
|
|
537
|
+
|
|
538
|
+
**A `setValue()` right after construction wins.** Reading the storage is
|
|
539
|
+
asynchronous; there is at least one tick between `new SupaMDE(...)` and the
|
|
540
|
+
restore. If you set content yourself in that window — prefilling a form,
|
|
541
|
+
loading content — you keep it: SupaMDE only restores if the document has been
|
|
542
|
+
untouched since construction. The draft stays saved and is a candidate again
|
|
543
|
+
the next time the editor opens.
|
|
544
|
+
|
|
545
|
+
```js
|
|
546
|
+
const editor = new SupaMDE({ element: el, autosave: { enabled: true, key: 'article-42' } });
|
|
547
|
+
// Wins over a saved draft — the host knows more about its own case.
|
|
548
|
+
editor.value(await loadContent());
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
SupaMDE shows **no UI of its own** for this: `onRestore` lets the host display
|
|
552
|
+
its own notice, with or without a "Discard" button.
|
|
553
|
+
|
|
554
|
+
**Call `clearAutosavedValue()` after the real save.** This is the point that's
|
|
555
|
+
easy to overlook: after a successful save to your own backend, the local draft
|
|
556
|
+
is obsolete. If it is not cleared, the editor brings back the old state the
|
|
557
|
+
next time it opens and thereby overwrites the freshly saved version.
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
async function save() {
|
|
561
|
+
await fetch('/api/articles/42', {
|
|
562
|
+
method: 'PUT',
|
|
563
|
+
body: JSON.stringify({ content: editor.value() }),
|
|
564
|
+
});
|
|
565
|
+
// Only AFTER the successful save — otherwise the draft is gone
|
|
566
|
+
// even though the server never received it.
|
|
567
|
+
await editor.clearAutosavedValue();
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`clearAutosavedValue()` also stops the running debounce timer. Without that,
|
|
572
|
+
the next change would immediately write back the entry that was just deleted.
|
|
573
|
+
|
|
574
|
+
**Status bar.** The `'autosave'` item shows `Saved: HH:MM` after every save.
|
|
575
|
+
The text comes from the `status.autosaved` key and the time is formatted for
|
|
576
|
+
the locale's `code` (see [Localization](#localization)). It is **not** part of
|
|
577
|
+
`DEFAULT_STATUS` — add it to the `status` option if you want it (see the
|
|
578
|
+
example above).
|
|
579
|
+
|
|
580
|
+
**Custom storage.** The `SupaStorage` contract is intentionally narrow and
|
|
581
|
+
async-capable, so a server backend or IndexedDB fits without an extra layer:
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
interface SupaStorage {
|
|
585
|
+
load(key: string): string | null | Promise<string | null>;
|
|
586
|
+
save(key: string, value: string): void | Promise<void>;
|
|
587
|
+
clear(key: string): void | Promise<void>;
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
A draft endpoint on your own backend, in full:
|
|
592
|
+
|
|
593
|
+
```js
|
|
594
|
+
const serverStorage = {
|
|
595
|
+
async load(key) {
|
|
596
|
+
const res = await fetch(`/api/drafts/${encodeURIComponent(key)}`);
|
|
597
|
+
if (res.status === 404) return null;
|
|
598
|
+
if (!res.ok) throw new Error(`Loading draft failed: ${res.status}`);
|
|
599
|
+
const data = await res.json();
|
|
600
|
+
return data.content;
|
|
601
|
+
},
|
|
602
|
+
async save(key, value) {
|
|
603
|
+
const res = await fetch(`/api/drafts/${encodeURIComponent(key)}`, {
|
|
604
|
+
method: 'PUT',
|
|
605
|
+
headers: { 'Content-Type': 'application/json' },
|
|
606
|
+
body: JSON.stringify({ content: value }),
|
|
607
|
+
});
|
|
608
|
+
if (!res.ok) throw new Error(`Saving draft failed: ${res.status}`);
|
|
609
|
+
},
|
|
610
|
+
async clear(key) {
|
|
611
|
+
await fetch(`/api/drafts/${encodeURIComponent(key)}`, { method: 'DELETE' });
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
new SupaMDE({
|
|
616
|
+
element: document.querySelector('#editor'),
|
|
617
|
+
autosave: { enabled: true, key: 'article-42', storage: serverStorage },
|
|
618
|
+
});
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
**When the storage fails** — quota exceeded, private mode, server unreachable
|
|
622
|
+
— SupaMDE warns **once** on the console and silently disables autosave. Not
|
|
623
|
+
again on every keystroke. `isAutosaveActive()` then returns `false`.
|
|
624
|
+
|
|
625
|
+
**No conflict resolution.** The saved state wins over the initial content;
|
|
626
|
+
SupaMDE does not reconcile it with a server state that changed in parallel.
|
|
627
|
+
`onRestore` is the place where the host can decide that itself.
|
|
628
|
+
|
|
629
|
+
**On teardown.** `toTextArea()` clears the running timer but leaves the saved
|
|
630
|
+
value in place — closing the editor is not a signal to discard the draft.
|
|
631
|
+
|
|
632
|
+
| Method | Description |
|
|
633
|
+
| ----------------------- | --------------------------------------------------------------------- |
|
|
634
|
+
| `clearAutosavedValue()` | Stops the timer **and** deletes the entry. `Promise<void>`. |
|
|
635
|
+
| `isAutosaveActive()` | Whether autosave is active (enabled, valid `key`, storage available). |
|
|
636
|
+
|
|
637
|
+
## Image upload
|
|
638
|
+
|
|
639
|
+
Images get into the document via drag & drop, pasting from the clipboard, or
|
|
640
|
+
the file picker. **Off** by default.
|
|
641
|
+
|
|
642
|
+
### How it works
|
|
643
|
+
|
|
644
|
+
1. **Validation** — size against `maxSize`, MIME type against `accept`. If a
|
|
645
|
+
file is rejected, **nothing** happens in the document: only the status bar
|
|
646
|
+
and `onError`.
|
|
647
|
+
2. **Placeholder** — `![Uploading foo.png…]()` is inserted at the cursor
|
|
648
|
+
position and tracked in the document from then on. If you keep typing
|
|
649
|
+
before it, it moves along.
|
|
650
|
+
3. **`upload(file)`** — your function uploads the file and returns the URL.
|
|
651
|
+
4. **Replacement** — the placeholder is replaced by `` at its
|
|
652
|
+
_current_ position, not the original one.
|
|
653
|
+
|
|
654
|
+
You can keep typing freely between steps 2 and 4; the image still ends up in
|
|
655
|
+
the right place. If you delete the placeholder manually or `setValue()`
|
|
656
|
+
replaces the document, **nothing** is inserted — an image jumping into what is
|
|
657
|
+
by now a different document would be worse than a lost upload.
|
|
658
|
+
|
|
659
|
+
### The `upload` contract
|
|
660
|
+
|
|
661
|
+
```ts
|
|
662
|
+
upload: (file: File) => Promise<string>;
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
File in, URL out, **throws on error**. That is the entire interface to the
|
|
666
|
+
outside world. SupaMDE ships **no** HTTP client, no fixed response format, no
|
|
667
|
+
CSRF options and no endpoint option: auth, error formats and upload flows
|
|
668
|
+
(direct, presigned, SDK) differ so much between projects that any built-in
|
|
669
|
+
variant would be wrong for most of them.
|
|
670
|
+
|
|
671
|
+
```js
|
|
672
|
+
const editor = new SupaMDE({
|
|
673
|
+
element: document.querySelector('#editor'),
|
|
674
|
+
uploadImage: {
|
|
675
|
+
enabled: true,
|
|
676
|
+
upload: async (file) => {
|
|
677
|
+
const data = new FormData();
|
|
678
|
+
data.append('file', file);
|
|
679
|
+
const res = await fetch('/api/images', { method: 'POST', body: data });
|
|
680
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
|
681
|
+
const { url } = await res.json();
|
|
682
|
+
return url;
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
toolbar: ['bold', 'italic', '|', 'image', 'upload-image'],
|
|
686
|
+
status: ['lines', 'words', 'cursor', 'upload-image'],
|
|
687
|
+
});
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
| Option | Type | Default | Description |
|
|
691
|
+
| --------- | --------------------------------- | ------------------------------- | -------------------------------------------------------- |
|
|
692
|
+
| `enabled` | `boolean` | `false` | Enables image upload. |
|
|
693
|
+
| `upload` | `(file: File) => Promise<string>` | — | **Required.** Uploads, returns the URL, throws on error. |
|
|
694
|
+
| `maxSize` | `number` | `2097152` (2 MB) | Maximum file size in bytes. |
|
|
695
|
+
| `accept` | `string[]` | PNG, JPEG, GIF, WebP, AVIF, SVG | Allowed MIME types. |
|
|
696
|
+
| `onError` | `(error: UploadError) => void` | — | Called on every error. |
|
|
697
|
+
|
|
698
|
+
**Toolbar button and status bar item** are both called `'upload-image'` and
|
|
699
|
+
are **not** part of the defaults — add them to the respective option if you
|
|
700
|
+
want them (see the example above). The button opens the file picker.
|
|
701
|
+
`openBrowseFileWindow()` also works with `toolbar: false`, because the file
|
|
702
|
+
input is created on demand and not parked in the toolbar.
|
|
703
|
+
|
|
704
|
+
> **Without the status bar item and without `onError`, the upload is silent.**
|
|
705
|
+
> Feedback goes exclusively to the `'upload-image'` status bar item and to
|
|
706
|
+
> `onError`. If both are missing, uploads run invisibly — errors included.
|
|
707
|
+
> SupaMDE warns once on the console in that case. At least one of the two
|
|
708
|
+
> belongs in your configuration:
|
|
709
|
+
>
|
|
710
|
+
> ```js
|
|
711
|
+
> status: ['lines', 'words', 'cursor', 'upload-image'], // visible progress
|
|
712
|
+
> // and/or
|
|
713
|
+
> uploadImage: { enabled: true, upload: myUpload, onError: showToast },
|
|
714
|
+
> ```
|
|
715
|
+
|
|
716
|
+
**Multiple files** upload in parallel, each with its own placeholder. The
|
|
717
|
+
mapping stays correct even if the second upload finishes before the first. If
|
|
718
|
+
a selection contains valid and invalid files, the valid ones are uploaded and
|
|
719
|
+
the invalid ones are reported individually.
|
|
720
|
+
|
|
721
|
+
**Non-image files** are rejected (`type-not-allowed`), not inserted as a link.
|
|
722
|
+
The feature is called image upload.
|
|
723
|
+
|
|
724
|
+
### Backend examples
|
|
725
|
+
|
|
726
|
+
**1. `fetch` against your own endpoint** — the standard case, with a CSRF
|
|
727
|
+
header and evaluated error status:
|
|
728
|
+
|
|
729
|
+
```js
|
|
730
|
+
upload: async (file) => {
|
|
731
|
+
const data = new FormData();
|
|
732
|
+
data.append('file', file);
|
|
733
|
+
|
|
734
|
+
const res = await fetch('/api/images', {
|
|
735
|
+
method: 'POST',
|
|
736
|
+
body: data,
|
|
737
|
+
credentials: 'same-origin',
|
|
738
|
+
headers: {
|
|
739
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
if (!res.ok) {
|
|
744
|
+
// The server's error text is often the most useful information —
|
|
745
|
+
// it reaches the host via onError.
|
|
746
|
+
const text = await res.text().catch(() => '');
|
|
747
|
+
throw new Error(`Upload failed (${res.status}): ${text}`);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const { url } = await res.json();
|
|
751
|
+
return url;
|
|
752
|
+
};
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
**2. Presigned upload (S3 or compatible)** — get a signature from your own
|
|
756
|
+
backend, upload directly to the storage, return the public URL:
|
|
757
|
+
|
|
758
|
+
```js
|
|
759
|
+
upload: async (file) => {
|
|
760
|
+
// Step 1: your own backend signs the upload. Auth is only needed here.
|
|
761
|
+
const signRes = await fetch('/api/uploads/sign', {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
headers: { 'Content-Type': 'application/json' },
|
|
764
|
+
body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
|
|
765
|
+
});
|
|
766
|
+
if (!signRes.ok) throw new Error(`Signing failed: ${signRes.status}`);
|
|
767
|
+
const { uploadUrl, publicUrl } = await signRes.json();
|
|
768
|
+
|
|
769
|
+
// Step 2: straight to the storage — the file never touches your own backend.
|
|
770
|
+
const putRes = await fetch(uploadUrl, {
|
|
771
|
+
method: 'PUT',
|
|
772
|
+
body: file,
|
|
773
|
+
headers: { 'Content-Type': file.type },
|
|
774
|
+
});
|
|
775
|
+
if (!putRes.ok) throw new Error(`Storage upload failed: ${putRes.status}`);
|
|
776
|
+
|
|
777
|
+
return publicUrl;
|
|
778
|
+
};
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
**3. Supabase Storage** — in a few lines:
|
|
782
|
+
|
|
783
|
+
```js
|
|
784
|
+
import { createClient } from '@supabase/supabase-js';
|
|
785
|
+
|
|
786
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
787
|
+
|
|
788
|
+
upload: async (file) => {
|
|
789
|
+
const path = `images/${Date.now()}-${file.name}`;
|
|
790
|
+
const { error } = await supabase.storage.from('media').upload(path, file, {
|
|
791
|
+
contentType: file.type,
|
|
792
|
+
});
|
|
793
|
+
if (error) throw error;
|
|
794
|
+
|
|
795
|
+
const { data } = supabase.storage.from('media').getPublicUrl(path);
|
|
796
|
+
return data.publicUrl;
|
|
797
|
+
};
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
### What the endpoint must do
|
|
801
|
+
|
|
802
|
+
Client-side validation is **convenience, not security** — it can be bypassed
|
|
803
|
+
with two lines in the console. The endpoint must check for itself:
|
|
804
|
+
|
|
805
|
+
- **Limit the size on the server**, independently of `maxSize`.
|
|
806
|
+
- **Check the type on the server**, based on the content (magic bytes), not on
|
|
807
|
+
the `Content-Type` sent by the client.
|
|
808
|
+
- **Don't accept file names unchecked.** `../../etc/passwd` is a valid file
|
|
809
|
+
name. Best assign your own name and store the original name only as
|
|
810
|
+
metadata.
|
|
811
|
+
- **Be careful with SVG.** SVG can contain scripts. If you allow it, sanitize
|
|
812
|
+
it on the server or serve it from a separate domain. If you don't need it,
|
|
813
|
+
remove `'image/svg+xml'` from the `accept` list.
|
|
814
|
+
- **Return meaningful status codes**: `413` for too large, `415` for wrong
|
|
815
|
+
type, `401`/`403` for missing permission. The error text reaches the host via
|
|
816
|
+
`onError` and can be displayed there.
|
|
817
|
+
|
|
818
|
+
### Error handling
|
|
819
|
+
|
|
820
|
+
Errors are reported **structured**, not preformatted — the host can display
|
|
821
|
+
and translate them itself:
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
interface UploadError {
|
|
825
|
+
kind: 'too-large' | 'type-not-allowed' | 'upload-failed';
|
|
826
|
+
file: File;
|
|
827
|
+
/** The original error from upload(), when kind === 'upload-failed'. */
|
|
828
|
+
cause?: unknown;
|
|
829
|
+
}
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
```js
|
|
833
|
+
uploadImage: {
|
|
834
|
+
enabled: true,
|
|
835
|
+
upload: myUpload,
|
|
836
|
+
onError: (error) => {
|
|
837
|
+
switch (error.kind) {
|
|
838
|
+
case 'too-large':
|
|
839
|
+
toast.error(`${error.file.name} is larger than 2 MB.`);
|
|
840
|
+
break;
|
|
841
|
+
case 'type-not-allowed':
|
|
842
|
+
toast.error(`${error.file.name}: only PNG, JPEG, GIF, WebP, AVIF and SVG.`);
|
|
843
|
+
break;
|
|
844
|
+
case 'upload-failed':
|
|
845
|
+
toast.error('The upload failed. Please try again.');
|
|
846
|
+
console.error(error.cause);
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
**No `alert()`.** SupaMDE does not pop up a blocking browser dialog on an
|
|
854
|
+
upload error. The default is the status bar message; if you want more, use
|
|
855
|
+
`onError`.
|
|
856
|
+
|
|
857
|
+
**Timeouts belong in your `upload` function.** An `upload()` that never
|
|
858
|
+
resolves leaves the placeholder in place. That is intentional: SupaMDE doesn't
|
|
859
|
+
know your latencies, you do.
|
|
860
|
+
|
|
861
|
+
```js
|
|
862
|
+
upload: async (file) => {
|
|
863
|
+
const abort = new AbortController();
|
|
864
|
+
const timer = setTimeout(() => abort.abort(), 30_000);
|
|
865
|
+
try {
|
|
866
|
+
const data = new FormData();
|
|
867
|
+
data.append('file', file);
|
|
868
|
+
const res = await fetch('/api/images', {
|
|
869
|
+
method: 'POST',
|
|
870
|
+
body: data,
|
|
871
|
+
signal: abort.signal,
|
|
872
|
+
});
|
|
873
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
|
874
|
+
return (await res.json()).url;
|
|
875
|
+
} finally {
|
|
876
|
+
clearTimeout(timer);
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
### Customizing display texts
|
|
882
|
+
|
|
883
|
+
The upload texts are part of the [localization](#localization). Override them
|
|
884
|
+
via the top-level `texts` option with the `upload.*` keys — not inside
|
|
885
|
+
`uploadImage`:
|
|
886
|
+
|
|
887
|
+
```js
|
|
888
|
+
new SupaMDE({
|
|
889
|
+
element,
|
|
890
|
+
texts: {
|
|
891
|
+
'upload.statusInit': 'Drop an image here',
|
|
892
|
+
'upload.errorTooLarge': '{name} exceeds {maxSize}.',
|
|
893
|
+
},
|
|
894
|
+
uploadImage: { enabled: true, upload: myUpload },
|
|
895
|
+
});
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
After a success, the status display falls back to `upload.statusInit` after about
|
|
899
|
+
2 s, after an error after about 6 s. If several uploads run at the same time,
|
|
900
|
+
the fallback happens only once no upload is pending anymore.
|
|
901
|
+
|
|
902
|
+
### Not included
|
|
903
|
+
|
|
904
|
+
- **No progress in percent** — the promise API provides no progress events.
|
|
905
|
+
- **No image preview in the editor** — that belongs in the live-preview
|
|
906
|
+
feature, not in an upload feature.
|
|
907
|
+
- **No image editing** (resizing, cropping, converting). If you need that, do
|
|
908
|
+
it in your `upload` function before it uploads.
|
|
909
|
+
|
|
910
|
+
| Method | Description |
|
|
911
|
+
| ------------------------ | ----------------------------------------------- |
|
|
912
|
+
| `uploadImages(files)` | Starts the upload for a `FileList` or `File[]`. |
|
|
913
|
+
| `openBrowseFileWindow()` | Opens the file picker. |
|
|
914
|
+
|
|
915
|
+
## Localization
|
|
916
|
+
|
|
917
|
+
All texts users see — toolbar tooltips (and their `aria-label`), the status bar
|
|
918
|
+
labels, the autosave status, the image-upload messages, the link/image prompts
|
|
919
|
+
and the column headers of the inserted table template — are **English** by default. German ships with
|
|
920
|
+
the package:
|
|
921
|
+
|
|
922
|
+
```js
|
|
923
|
+
import SupaMDE, { de } from 'supamde';
|
|
924
|
+
|
|
925
|
+
new SupaMDE({ element, locale: de });
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
The language is fixed when the editor is constructed. Custom toolbar buttons
|
|
929
|
+
and custom status bar items bring their own texts.
|
|
930
|
+
|
|
931
|
+
### Overriding individual texts
|
|
932
|
+
|
|
933
|
+
`texts` overrides single keys without writing a whole locale and takes
|
|
934
|
+
precedence over `locale`:
|
|
935
|
+
|
|
936
|
+
```js
|
|
937
|
+
new SupaMDE({
|
|
938
|
+
element,
|
|
939
|
+
locale: de,
|
|
940
|
+
texts: { 'toolbar.bold': 'Fettdruck', 'status.autosaved': 'Zuletzt gesichert: {time}' },
|
|
941
|
+
});
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
For every key, SupaMDE looks in `texts`, then in `locale.texts`, then in the
|
|
945
|
+
built-in English texts.
|
|
946
|
+
|
|
947
|
+
### Keys
|
|
948
|
+
|
|
949
|
+
| Key | Placeholders | Used for |
|
|
950
|
+
| ------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
951
|
+
| `toolbar.<action>` | — | Tooltip and `aria-label` of a built-in button, one key per action (`toolbar.bold`, `toolbar.heading-1`, …). |
|
|
952
|
+
| `status.lines` | `{count}` | Status bar item `lines` (plural text). |
|
|
953
|
+
| `status.words` | `{count}` | Status bar item `words` (plural text). |
|
|
954
|
+
| `status.autosaved` | `{time}` | Status bar item `autosave`. |
|
|
955
|
+
| `upload.placeholder` | `{name}` | Placeholder inserted into the document while a file uploads. |
|
|
956
|
+
| `upload.statusInit` | — | Idle text of the `upload-image` status item. |
|
|
957
|
+
| `upload.statusUploading` | `{name}` | Upload in progress. |
|
|
958
|
+
| `upload.statusDone` | `{name}` | Upload finished. |
|
|
959
|
+
| `upload.errorTooLarge` | `{name}`, `{maxSize}` | File exceeds `maxSize`. |
|
|
960
|
+
| `upload.errorType` | `{name}` | MIME type not in `accept`. |
|
|
961
|
+
| `upload.errorFailed` | `{name}` | `upload()` threw or rejected. |
|
|
962
|
+
| `prompt.linkUrl` | — | Prompt of the link action (`window.prompt`). |
|
|
963
|
+
| `prompt.imageUrl` | — | Prompt of the image action (`window.prompt`). |
|
|
964
|
+
| `table.column` | `{n}` | Column header of the table template (`Column 1`, `Column 2`). |
|
|
965
|
+
|
|
966
|
+
Placeholders are named and written in curly braces; all occurrences are
|
|
967
|
+
replaced, unknown ones stay as they are.
|
|
968
|
+
|
|
969
|
+
### Plural texts
|
|
970
|
+
|
|
971
|
+
Count-dependent texts are objects with [CLDR plural
|
|
972
|
+
forms](https://cldr.unicode.org/index/cldr-spec/plural-rules) (`zero`, `one`,
|
|
973
|
+
`two`, `few`, `many`, `other`). `other` is required and used for every missing
|
|
974
|
+
form:
|
|
975
|
+
|
|
976
|
+
```js
|
|
977
|
+
texts: { 'status.words': { one: '{count} word', other: '{count} words' } }
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
### Your own locale
|
|
981
|
+
|
|
982
|
+
A locale is a plain object with a BCP 47 `code` and the texts. The `Locale`
|
|
983
|
+
type requires every key, so TypeScript reports a forgotten text; at runtime,
|
|
984
|
+
missing keys fall back to English.
|
|
985
|
+
|
|
986
|
+
```ts
|
|
987
|
+
import SupaMDE, { type Locale } from 'supamde';
|
|
988
|
+
|
|
989
|
+
const fr: Locale = {
|
|
990
|
+
code: 'fr',
|
|
991
|
+
texts: {
|
|
992
|
+
'toolbar.bold': 'Gras',
|
|
993
|
+
'status.lines': { one: '{count} ligne', other: '{count} lignes' },
|
|
994
|
+
// … all other keys
|
|
995
|
+
},
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
new SupaMDE({ element, locale: fr });
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
`code` selects the plural rules (`Intl.PluralRules`) and the time format of the
|
|
1002
|
+
autosave status (`en`: `Saved: 02:05 PM`, `de`: `Gespeichert: 14:05`). An
|
|
1003
|
+
invalid code logs one warning and falls back to `'en'` for both; the texts stay
|
|
1004
|
+
as given.
|
|
1005
|
+
|
|
1006
|
+
### Locale from a JSON file
|
|
1007
|
+
|
|
1008
|
+
A locale is plain data, so it can live in a JSON file that your bundler
|
|
1009
|
+
(Vite, webpack, esbuild, …) imports. Abbreviated here — the file needs all keys:
|
|
1010
|
+
|
|
1011
|
+
```json
|
|
1012
|
+
{
|
|
1013
|
+
"code": "fr",
|
|
1014
|
+
"texts": {
|
|
1015
|
+
"toolbar.bold": "Gras",
|
|
1016
|
+
"status.lines": { "one": "{count} ligne", "other": "{count} lignes" }
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
```js
|
|
1022
|
+
import SupaMDE from 'supamde';
|
|
1023
|
+
import fr from './locales/fr.json';
|
|
1024
|
+
|
|
1025
|
+
new SupaMDE({ element, locale: fr });
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
A file with only some keys (and no `code`) works as an override file via
|
|
1029
|
+
`texts`:
|
|
1030
|
+
|
|
1031
|
+
```js
|
|
1032
|
+
import overrides from './locales/overrides.json';
|
|
1033
|
+
|
|
1034
|
+
new SupaMDE({ element, locale: de, texts: overrides });
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
Copy the full key list from [`en.ts`](https://github.com/svenho/SupaMDE/blob/main/src/i18n/en.ts)
|
|
1038
|
+
as a template. In TypeScript, enable `"resolveJsonModule": true`; the imported
|
|
1039
|
+
file is then checked against `Locale`, so a missing key is reported at compile
|
|
1040
|
+
time. The file is read at build time and passed as an object — SupaMDE does not
|
|
1041
|
+
load locale files from a URL at runtime.
|
|
1042
|
+
|
|
1043
|
+
## API
|
|
1044
|
+
|
|
1045
|
+
| Method | Description |
|
|
1046
|
+
| -------------------------------- | ---------------------------------------------------- |
|
|
1047
|
+
| `value()` / `getValue()` | Read the current content as a string. |
|
|
1048
|
+
| `value(val)` / `setValue(val)` | Replace the entire content. |
|
|
1049
|
+
| `updateStatusBar(name, content)` | Set the content of a status bar item. |
|
|
1050
|
+
| `toTextArea()` | Tear down the editor, restore the original textarea. |
|
|
1051
|
+
| `codemirror` | The underlying CodeMirror 6 `EditorView`. |
|
|
1052
|
+
| `toggleSideBySide()` | Side-by-side preview on/off. |
|
|
1053
|
+
| `isSideBySideActive()` | `true` if side-by-side is active. |
|
|
1054
|
+
| `toggleFullScreen()` | Fullscreen mode on/off. |
|
|
1055
|
+
| `isFullscreenActive()` | `true` if fullscreen is active. |
|
|
1056
|
+
| `markdown(text)` | Render text as Markdown with KaTeX. |
|
|
1057
|
+
| `getEditorMode()` | Current editor mode (`'source'` or `'live'`). |
|
|
1058
|
+
| `setEditorMode(mode)` | Set the editor mode. |
|
|
1059
|
+
| `toggleEditorMode()` | Switch between both editor modes. |
|
|
1060
|
+
| `clearAutosavedValue()` | Delete the draft and stop the timer. |
|
|
1061
|
+
| `isAutosaveActive()` | `true` if autosave is active. |
|
|
1062
|
+
| `uploadImages(files)` | Start the upload for `FileList`/`File[]`. |
|
|
1063
|
+
| `openBrowseFileWindow()` | Open the file picker. |
|
|
1064
|
+
|
|
1065
|
+
## Keyboard shortcuts
|
|
1066
|
+
|
|
1067
|
+
All formatting actions are implemented as CodeMirror 6 commands and reachable
|
|
1068
|
+
via keyboard shortcuts (`Mod` = `Cmd` on macOS, `Ctrl` elsewhere). All actions
|
|
1069
|
+
are also available by clicking in the toolbar.
|
|
1070
|
+
|
|
1071
|
+
| Shortcut | Action |
|
|
1072
|
+
| ------------------------------------- | ------------------------------------------ |
|
|
1073
|
+
| `Mod-B` | Bold |
|
|
1074
|
+
| `Mod-I` | Italic |
|
|
1075
|
+
| `Mod-K` | Link |
|
|
1076
|
+
| `Mod-H` / `Shift-Mod-H` | Heading smaller / bigger |
|
|
1077
|
+
| `Ctrl-Alt-1` … `Ctrl-Alt-6` | Heading H1 … H6 |
|
|
1078
|
+
| `Mod-'` / `Ctrl-Alt-Q` | Blockquote |
|
|
1079
|
+
| `Mod-L` / `Mod-Alt-L` / `Shift-Mod-L` | List (`- `) / numbered / checklist |
|
|
1080
|
+
| `Shift-Alt-Mod-L` | List with asterisks (`* `) |
|
|
1081
|
+
| `Mod-Alt-C` | Code block |
|
|
1082
|
+
| `Mod-Alt-I` | Insert image |
|
|
1083
|
+
| `Mod-E` | Remove block formatting |
|
|
1084
|
+
| `Mod-Z` / `Mod-Y` | Undo / redo |
|
|
1085
|
+
| `Tab` / `Shift-Tab` | Indent / outdent line |
|
|
1086
|
+
| `F8` | Preview **and** fullscreen on/off together |
|
|
1087
|
+
| `F9` | Side-by-side preview on/off |
|
|
1088
|
+
| `F10` | Toggle editor mode (source ↔ live preview) |
|
|
1089
|
+
| `F11` / `Mod-Shift-F` | Fullscreen mode on/off |
|
|
1090
|
+
|
|
1091
|
+
**Fullscreen on macOS:** `F11` is taken system-wide there (Mission Control or
|
|
1092
|
+
"Show Desktop") and, depending on the system settings, may not reach the page
|
|
1093
|
+
at all. That's why fullscreen mode also listens to `Cmd`+`Shift`+`F` (or
|
|
1094
|
+
`Ctrl`+`Shift`+`F` on Windows/Linux); accordingly, the toolbar button shows
|
|
1095
|
+
`⌘⇧F` as its shortcut on macOS.
|
|
1096
|
+
|
|
1097
|
+
**Opening links:** `Cmd`+click (macOS) or `Ctrl`+click opens the link under the
|
|
1098
|
+
pointer in a new tab — in both editor modes. This works for Markdown links
|
|
1099
|
+
(`[text](url)`), autolinks (`<url>`) and bare URLs that GFM recognizes
|
|
1100
|
+
automatically (`https://…`, `http://…`, `www.…` and email addresses such as
|
|
1101
|
+
`foo@example.com`). `https://` is added to `www.` addresses and `mailto:` to
|
|
1102
|
+
email addresses — in each case only if no scheme is present in the text yet.
|
|
1103
|
+
Only `http://`, `https://` and (after this completion) `mailto:` URLs are
|
|
1104
|
+
opened; `https:`/`http:` without the two slashes do NOT count as a valid
|
|
1105
|
+
scheme. If an email-like string appears as part of a larger URL in the text
|
|
1106
|
+
(e.g. the user part in `https://admin@github.com/…`), it is NOT normalized to
|
|
1107
|
+
`mailto:`. Cmd/Ctrl+click on such an address then opens nothing — never the
|
|
1108
|
+
mail client unintentionally. Note on a parser limitation: GFM only recognizes
|
|
1109
|
+
bare URLs/`www.` addresses in lowercase — `HTTPS://EXAMPLE.COM` in running
|
|
1110
|
+
text is not recognized (Markdown links and autolinks are not affected).
|
|
1111
|
+
|
|
1112
|
+
When the mouse pointer is over a clickable link while `Cmd`/`Ctrl` is held, it
|
|
1113
|
+
turns into a pointing hand (`cursor: pointer`) — like in VS Code.
|
|
1114
|
+
|
|
1115
|
+
`Enter` in a list line continues the list; in an empty list line it ends the
|
|
1116
|
+
list. Strikethrough, inline code, horizontal rule and table are available by
|
|
1117
|
+
clicking in the toolbar.
|
|
1118
|
+
|
|
1119
|
+
`Tab` indents the current line by one `indentUnit`, `Shift-Tab` outdents it —
|
|
1120
|
+
regardless of where the cursor is in the line. With a selection, this applies
|
|
1121
|
+
to all touched lines. This is how lists are nested: `- item` becomes
|
|
1122
|
+
` - item`. Inside code blocks, `codeIndentUnit` is used instead (with `'tab'`, a
|
|
1123
|
+
real tab character is inserted); fence lines count as normal text. In a code block
|
|
1124
|
+
inside a blockquote, the indentation goes after the `>` so the quote stays intact.
|
|
1125
|
+
|
|
1126
|
+
> **Note (accessibility):** `Tab` is always captured by the editor and does not
|
|
1127
|
+
> leave it. To leave the editor via keyboard, you currently have to use other
|
|
1128
|
+
> navigation.
|
|
1129
|
+
|
|
1130
|
+
> **Note (German Mac keyboard):** `Mod-'` (blockquote) is on `Cmd+Shift+#`
|
|
1131
|
+
> there and, depending on the browser, is not recognized reliably. Use the
|
|
1132
|
+
> layout-independent `Ctrl-Alt-Q` instead.
|
|
1133
|
+
|
|
1134
|
+
### Custom key bindings
|
|
1135
|
+
|
|
1136
|
+
`extraKeys` lets you add any CodeMirror 6 `KeyBinding`s. CM6 evaluates key
|
|
1137
|
+
bindings in registration order — the first matching entry wins. `extraKeys`
|
|
1138
|
+
comes **before** the SupaMDE defaults, so new shortcuts and overrides of
|
|
1139
|
+
existing defaults behave the same way:
|
|
1140
|
+
|
|
1141
|
+
```ts
|
|
1142
|
+
import SupaMDE, { type KeyBinding } from 'supamde';
|
|
1143
|
+
import { insertNewlineAndIndent } from '@codemirror/commands';
|
|
1144
|
+
|
|
1145
|
+
const extraKeys: KeyBinding[] = [
|
|
1146
|
+
// Override: replaces the built-in Mod-B (bold)
|
|
1147
|
+
{
|
|
1148
|
+
key: 'Mod-b',
|
|
1149
|
+
run: (view) => {
|
|
1150
|
+
/* custom action */ return true;
|
|
1151
|
+
},
|
|
1152
|
+
},
|
|
1153
|
+
// New: previously unbound key
|
|
1154
|
+
{ key: 'Mod-Enter', run: insertNewlineAndIndent },
|
|
1155
|
+
];
|
|
1156
|
+
|
|
1157
|
+
const editor = new SupaMDE({
|
|
1158
|
+
element: document.getElementById('editor'),
|
|
1159
|
+
extraKeys,
|
|
1160
|
+
});
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
## Customizing the formatting
|
|
1164
|
+
|
|
1165
|
+
The editor formats the Markdown source live (easyMDE-style "quasi-WYSIWYG":
|
|
1166
|
+
the characters stay visible but are styled). The rendering is **tag-based**:
|
|
1167
|
+
the Lezer parser assigns a syntax tag to each element (`heading1`, `strong`,
|
|
1168
|
+
`emphasis`, `link` …), and a `HighlightStyle` assigns CSS properties to each
|
|
1169
|
+
tag. The rules live in [`src/editor/highlight.ts`](src/editor/highlight.ts),
|
|
1170
|
+
the color values centrally in [`src/editor/tokens.ts`](src/editor/tokens.ts).
|
|
1171
|
+
|
|
1172
|
+
A highlight rule is an object of CSS properties (camelCase):
|
|
1173
|
+
|
|
1174
|
+
```typescript
|
|
1175
|
+
{ tag: t.heading2, fontSize: '1.4em', fontWeight: 'bold' }
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
**Example: all second-level headings (`## …`) in red.** First add the color
|
|
1179
|
+
value in `tokens.ts` (one source for all colors):
|
|
1180
|
+
|
|
1181
|
+
```typescript
|
|
1182
|
+
export const colors = {
|
|
1183
|
+
quote: '#6a737d',
|
|
1184
|
+
link: '#0366d6',
|
|
1185
|
+
border: '#ddd',
|
|
1186
|
+
heading2: '#d73a49', // new
|
|
1187
|
+
} as const;
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
Then extend the `heading2` rule in `highlight.ts` with `color`:
|
|
1191
|
+
|
|
1192
|
+
```typescript
|
|
1193
|
+
{ tag: t.heading2, fontSize: '1.4em', fontWeight: 'bold', color: colors.heading2 },
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
After `npm run build` (or in a running `npm run dev`), every `## ` line is
|
|
1197
|
+
shown in red. All other tags can be adjusted the same way — e.g. `t.strong`
|
|
1198
|
+
(bold), `t.emphasis` (italic) or `t.link`.
|
|
1199
|
+
|
|
1200
|
+
> **Tag-based, not position-based:** `t.heading2` matches **every**
|
|
1201
|
+
> second-level heading, not "the second heading in the document". Position-based
|
|
1202
|
+
> formatting (e.g. only the second heading regardless of level) would not be a
|
|
1203
|
+
> highlight rule but would need its own CodeMirror decoration.
|
|
1204
|
+
|
|
1205
|
+
## Development
|
|
1206
|
+
|
|
1207
|
+
```bash
|
|
1208
|
+
npm install
|
|
1209
|
+
npm run dev # Vite dev server (example/)
|
|
1210
|
+
npm run test:run # unit tests in jsdom (fast, no browser)
|
|
1211
|
+
npm run test:browser # E2E tests in Chromium (needs a browser binary)
|
|
1212
|
+
npm run test:all # both test levels (needs a browser binary)
|
|
1213
|
+
npm run build # library build (ESM-only) + type declarations
|
|
1214
|
+
npm run lint # ESLint
|
|
1215
|
+
npm run typecheck # TypeScript without emit (src + test)
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
The browser tests check what jsdom can't — layout, scroll geometry and the
|
|
1219
|
+
native drag & drop APIs. They need a Chromium binary:
|
|
1220
|
+
|
|
1221
|
+
```bash
|
|
1222
|
+
npx playwright install chromium
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
`npm test` deliberately runs without a browser so the fast run stays fast.
|
|
1226
|
+
|
|
1227
|
+
## License
|
|
1228
|
+
|
|
1229
|
+
MIT © svenho
|