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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1228 -2
  3. package/dist/commands/block.d.ts +20 -0
  4. package/dist/commands/code-indent.d.ts +38 -0
  5. package/dist/commands/history.d.ts +6 -0
  6. package/dist/commands/indent.d.ts +28 -0
  7. package/dist/commands/inline.d.ts +9 -0
  8. package/dist/commands/keymap.d.ts +8 -0
  9. package/dist/commands/link-image.d.ts +13 -0
  10. package/dist/commands/list.d.ts +20 -0
  11. package/dist/commands/prefixes.d.ts +29 -0
  12. package/dist/commands/queries.d.ts +25 -0
  13. package/dist/commands/table.d.ts +7 -0
  14. package/dist/commands/types.d.ts +17 -0
  15. package/dist/editor/extensions.d.ts +14 -0
  16. package/dist/editor/highlight.d.ts +11 -0
  17. package/dist/editor/link-click.d.ts +53 -0
  18. package/dist/editor/link-hover-cursor.d.ts +44 -0
  19. package/dist/editor/math.d.ts +8 -0
  20. package/dist/editor/setup.d.ts +26 -0
  21. package/dist/editor/theme.d.ts +7 -0
  22. package/dist/editor/tokens.d.ts +16 -0
  23. package/dist/editor/value.d.ts +10 -0
  24. package/dist/features/autosave.d.ts +31 -0
  25. package/dist/features/image-upload.d.ts +73 -0
  26. package/dist/features/storage.d.ts +30 -0
  27. package/dist/features/upload-dom.d.ts +20 -0
  28. package/dist/features/upload-placeholder.d.ts +35 -0
  29. package/dist/features/word-count.d.ts +5 -0
  30. package/dist/i18n/de.d.ts +3 -0
  31. package/dist/i18n/en.d.ts +3 -0
  32. package/dist/i18n/facet.d.ts +9 -0
  33. package/dist/i18n/translator.d.ts +17 -0
  34. package/dist/i18n/types.d.ts +82 -0
  35. package/dist/index.d.ts +133 -0
  36. package/dist/livepreview/index.d.ts +22 -0
  37. package/dist/livepreview/plugin.d.ts +3 -0
  38. package/dist/livepreview/ranges.d.ts +62 -0
  39. package/dist/markdown/katex-marked.d.ts +20 -0
  40. package/dist/markdown/parse.d.ts +27 -0
  41. package/dist/markdown/sanitize.d.ts +6 -0
  42. package/dist/options.d.ts +83 -0
  43. package/dist/supamde.css +2 -0
  44. package/dist/supamde.mjs +3617 -0
  45. package/dist/supamde.mjs.map +1 -0
  46. package/dist/ui/actions.d.ts +51 -0
  47. package/dist/ui/fullscreen.d.ts +16 -0
  48. package/dist/ui/icons.d.ts +4 -0
  49. package/dist/ui/inject-styles.d.ts +13 -0
  50. package/dist/ui/preview.d.ts +27 -0
  51. package/dist/ui/shortcut-label.d.ts +24 -0
  52. package/dist/ui/statusbar.d.ts +28 -0
  53. package/dist/ui/toolbar-config.d.ts +36 -0
  54. package/dist/ui/toolbar.d.ts +16 -0
  55. package/dist/ui/update-listener.d.ts +15 -0
  56. package/dist/util/text-format.d.ts +11 -0
  57. package/dist/utils/html.d.ts +2 -0
  58. package/dist/utils/text.d.ts +41 -0
  59. package/dist/version.d.ts +2 -0
  60. package/package.json +96 -4
package/README.md CHANGED
@@ -1,3 +1,1229 @@
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
+ | `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 `![foo.png](url)` 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