@14ch/svelte-md-outliner
v0.0.15
Published
An outliner-style Markdown editor for Svelte 5. Headings form a real section tree you can fold, focus, and re-nest from the keyboard.
Maintainers
Readme
@14ch/svelte-md-outliner
An outline editor built on Markdown, for Svelte 5. Headings form a real nested hierarchy you can fold, focus, and rearrange with Tab. What you see is the document — there is no separate preview.
日本語版: README.ja.md
<script lang="ts">
import { MarkdownEditor } from '@14ch/svelte-md-outliner';
import '@14ch/svelte-md-outliner/styles/editor.css';
let value = $state('# Getting started\n\nSome text\n');
</script>
<MarkdownEditor bind:value />What it does
- Build the document out of headings —
Tab/Shift-Tabre-nest a heading together with everything under it;Mod-↑/Mod-↓swap siblings - Fold to see the shape — per-heading folding, or collapse to a chosen level (
Mod-Alt-1…6) - Focus one section — zoom into a single branch, with a breadcrumb to get back
- Markup converts as you type —
#,-,1.,[ ],>,**bold**,`code` - Tables, Mermaid diagrams, task lists and front matter
- Built-in context menu and shortcut sheet, both of which you can turn off
- Replaceable strings and theme — English and Japanese bundled, colors and sizes as CSS custom properties
How it differs from a plain Markdown editor
Being an outline editor, the heading hierarchy is the document structure rather than decoration. That produces three behaviours worth knowing:
Tabon a heading moves it and everything under it one level deeper — it does not indent text.- Heading levels come from structural depth.
# Afollowed by### Bsaves as## B: the parent/child relationship survives, the skipped level does not. - Folding, focus and the breadcrumb all operate on that hierarchy.
Install
npm install @14ch/svelte-md-outlinersvelte@^5 is a peer dependency. Import the stylesheet yourself — it already pulls in the default theme:
import '@14ch/svelte-md-outliner/styles/editor.css';Props
<MarkdownEditor bind:value bind:viewState label="Meeting notes" maxWidth="42rem" />| | Type | Default | |
| ------------------- | ----------------------------------------- | -------- | ----------------------------------------------------------- |
| value | string | '' | Markdown source (bindable) |
| onChange | (markdown: string) => void | — | Not needed when you use bind:value |
| viewState | ViewState | — | Which headings are folded, which section is zoomed |
| onViewStateChange | (viewState: ViewState) => void | — | Not needed when you use bind:viewState |
| label | string | built-in | Accessible name for the editing surface |
| readonly | boolean | false | Rejects typing, commands and pastes |
| autofocus | boolean | false | Focus on mount |
| class | string | '' | Extra class names |
| padding | string | token | Written exactly like CSS padding |
| maxWidth | string | token | Line length. The clickable surface keeps the full width |
| lang | 'en' \| 'ja' | 'en' | Bundled UI language |
| messages | EditorMessagesOverride | — | Replace individual strings, or supply a whole language |
| shortcutHelp | boolean | true | Built-in shortcut sheet (Mod-Alt-/) |
| contextMenu | boolean | true | Built-in context menu (right click / Mod-/) |
| uploadImage | (file: File) => Promise<string \| null> | — | Takes a pasted image, returns the URL to display |
viewState is how you are looking at the document, not its content. This module stores nothing, so persist it wherever you like — or not at all.
Images
 is rendered as a real image. Paste an image and uploadImage receives the raw File; the URL you return is inserted as image notation at the paste position. Where the bytes go, which formats and sizes you accept, and how you sanitize them (SVG, for instance) are all yours — this module only checks whether the clipboard holds an image file, and never stores anything itself.
const uploadImage = async (file: File) => {
const url = await myStorage.put(file); // or return null to reject the paste
return url;
};While it waits, a marker is shown at the paste position and the document is not touched; a failure removes the marker and leaves the document exactly as it was. Without uploadImage the paste falls through to the ordinary one. Pastes are ignored entirely while readonly.
Rendering an image fetches it. A document that names an external URL will request it from that host every time it is shown — that is what drawing image notation means.
For a language that is not bundled, start from a bundled set:
import { en, type EditorMessages } from '@14ch/svelte-md-outliner';
const fr: EditorMessages = { ...en, editorLabel: 'Éditeur' /* … */ };Modifier symbols (⌘ and friends) follow the platform, not the language.
Component API
<MarkdownEditor bind:this={editor} />
<button onclick={() => editor?.resetDocument('# Another document\n')}>Open another</button>| | |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| getMarkdown() | Current Markdown |
| getViewState() | Current view state |
| resetDocument(markdown) | Replace the document and clear undo history |
| writeFrontMatterFields(fields) | Write front matter without touching undo history, cursor, selection or body. Skips while the user edits that entry |
| focus() | Focus the editing surface |
Keyboard
Mod is ⌘ on macOS, Ctrl elsewhere. Mod-Alt-/ opens the full list.
| | |
| ------------------------- | ----------------------------------------------------- |
| Tab / Shift-Tab | Move the target one level deeper / shallower |
| Mod-↑ / Mod-↓ | Swap with the previous / next sibling |
| Mod-Enter | Heading ⇄ list item |
| Mod-Shift-D | Duplicate |
| Mod-Shift-Backspace | Delete |
| Mod-. | Fold / unfold |
| Mod-Alt-1…6 | Show down to that heading level |
| Mod-Alt-↑ / Mod-Alt-↓ | Previous / next heading |
| Mod-A | Grow the selection (content → block → whole document) |
| Mod-/ | Context menu |
Markup converts as you type (# , - , 1. , [ ] , > , ```, ---, **bold**, `code`). Backspace right after a conversion undoes it.
Markdown support
Headings, paragraphs, lists (bullet / ordered / task), block quotes, code blocks, Mermaid diagrams, tables, rules, hard breaks and front matter — plus bold, italic, strikethrough, inline code and links.
Opening and saving never changes what your markup means. Only spelling is normalized: * bullets become -, setext headings become #, table delimiters become ---.
Images, inline HTML and footnotes keep their source text exactly, but the editor does not display them as such. An image stays on screen as  rather than becoming a picture; a footnote marker is drawn as a superscript but does not link to its note, and notes are not collected at the end. Open the file in another tool and both read correctly — this editor simply does not render them.
Theming
Every color, font and heading scale is a CSS custom property, declared at zero specificity (:where()) so your own rules always win. Copy the bundled theme.css, keep the tokens you want to change, and load it after:
import '@14ch/svelte-md-outliner/styles/editor.css';
import './my-theme.css';:where(:root) {
--markdown-editor-accent: #2563eb;
--markdown-editor-heading-scale-1: 1.75;
}
:where([data-theme='dark']) {
--markdown-editor-surface: #16181d;
}theme.css holds colors and typography. Spacing and line width live in editor.css and override the same way.
Reading Markdown without an editor
For list views, migration scripts and server-side code, a separate entry point exports pure functions — it never reaches for .svelte, so plain Node can import it.
import { readBody, readBodyBlocks, readFrontMatterField } from '@14ch/svelte-md-outliner/markdown';
const title = readFrontMatterField(markdown, 'title');
// Block offsets are relative to the body, so strip the front matter first
const blocks = readBodyBlocks(readBody(markdown));| | |
| ------------------------------------------ | -------------------------------------------------------------------- |
| readFrontMatterField(markdown, key) | One front matter value |
| readFrontMatterFields(markdown) | Every front matter entry |
| readBody(markdown) | The body, front matter removed |
| readBodyBlocks(body) | Top-level blocks: kind, offsets within the body, heading path |
| readPlainText(markdown) | Readable text with inline markup stripped |
| writeFrontMatterFields(markdown, fields) | Write entries; delimiters, quoting and placement are handled for you |
Values come back as strings and are never coerced. That boundary keeps this module from turning into a YAML parser.
Constraints worth knowing
- No singleton state. Several editors on one page never interfere.
- No side effects on import, so an SSR build is safe. The editor itself is client-side: rendering it on the server logs a warning and shows a placeholder.
- No persistence. Document and view state go through the public API; storage is yours.
- Small public surface. The ProseMirror schema, plugins and commands stay internal.
Development
npm run dev # demo app (src/routes/)
npm run test # unit, component and end-to-end
npm run check # types
npm run lint # formatting and static analysisSpecifications live in .kiro/specs/, conventions in .kiro/steering/ (Japanese).
