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