A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Complete live-preview rendering for core markdown node types

Restructure the Automerge editor's single livePreview.ts into per-node
CodeMirror extensions composed by livePreview(), mirroring the reams
reference. Extends coverage from 4 node types to the core CommonMark +
GFM set (no widgets):

- headings: ported as-is
- bold / italic / strikethrough: content classes + proximity-revealed
markers, registered atomic so cursor motion skips hidden markers
- inline code + fenced code blocks: content/line styling + fence hiding
- links: fix the broken handler (hide all brackets/paren/URL, style the
label) + cmd/ctrl-click to open externally via a new shell.openExternal
IPC bridge
- blockquotes, horizontal rules, lists: line decorations + prefix hiding

Splits the single cm-formatting-hidden class into idiom-correct hide
classes (per the CM6 cursor-skip contract) and activates the previously
dead CSS. Adds headless builder tests (pure over EditorState) covering
each node type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

authored by

Ethan Graf
Claude Opus 4.8
and committed by
Tangled
(Jul 19, 2026, 6:25 AM +0300) e45d0cbb 0d2903d1

+1095 -191
+1 -1
src/editors/automerge/automergeDocumentEditor.tsx
··· 134 134 addKeymap: false, 135 135 }), 136 136 // Live preview: hide markdown syntax markers, apply CSS classes 137 - livePreview, 137 + livePreview(), 138 138 // Read-only compartment 139 139 readOnlyCompartment.current.of(EditorState.readOnly.of(false)), 140 140 // Change listener
+50 -4
src/editors/automerge/automergeEditor.css
··· 55 55 line-height: 1.5; 56 56 } 57 57 58 - /* Bold / Italic */ 58 + /* Bold / Italic / Strikethrough */ 59 59 .automerge-editor-container .cm-strong { 60 60 font-weight: 700; 61 61 } 62 62 .automerge-editor-container .cm-em { 63 63 font-style: italic; 64 + } 65 + .automerge-editor-container .cm-strikethrough { 66 + text-decoration: line-through; 64 67 } 65 68 66 69 /* Links */ ··· 166 169 outline: none; 167 170 } 168 171 169 - /* Hidden formatting markers (live preview) */ 172 + /* 173 + * Hidden markdown syntax (live preview). Each class uses a specific idiom; 174 + * see hiddenClasses.ts. The idioms exist to preserve CM6 vertical cursor 175 + * motion (ArrowUp/ArrowDown resolve the caret from pixel geometry, which 176 + * ignores zero-geometry elements) — do not collapse a whole line's only 177 + * visible content with `display: none`. 178 + */ 179 + 180 + /* Heading prefix (`# `) — hybrid: zero WIDTH, non-zero HEIGHT so empty 181 + * heading lines stay caret-targetable and text starts at column 0. */ 170 182 .automerge-editor-container .cm-formatting-hidden { 171 - font-size: 0; 172 - display: inline; 183 + display: inline-block; 184 + width: 0; 185 + max-width: 0; 186 + height: 1em; 187 + line-height: 1em; 188 + overflow: hidden; 189 + color: transparent; 190 + pointer-events: none; 191 + user-select: none; 192 + } 193 + 194 + /* Inline markers (**, *, ~~, `, link brackets/paren/URL) — line always has 195 + * visible siblings, so plain display:none is safe and also removes the 196 + * marker from click hit-testing. */ 197 + .automerge-editor-container .cm-inline-formatting-hidden { 198 + display: none; 199 + } 200 + 201 + /* Blockquote prefix (`> `) — zero-width inline-block sentinel keeps a caret 202 + * landing spot when the marker is the line's first inline child. */ 203 + .automerge-editor-container .cm-blockquote-formatting-hidden { 204 + display: inline-block; 205 + width: 0; 206 + max-width: 0; 207 + height: 0; 208 + line-height: 0; 209 + overflow: hidden; 210 + opacity: 0; 211 + pointer-events: none; 212 + user-select: none; 213 + } 214 + 215 + /* Horizontal rule glyphs — whole-line hide; the line keeps natural geometry 216 + * (so caretRangeFromPoint finds it), only the glyphs are masked. The visible 217 + * rule is painted by the `.cm-hr` border. */ 218 + .automerge-editor-container .cm-hr-line-hidden { 173 219 color: transparent; 174 220 pointer-events: none; 175 221 user-select: none;
-185
src/editors/automerge/livePreview.ts
··· 1 - /** 2 - * Simplified live-preview CM6 extension for textile. 3 - * Hides markdown syntax markers and applies CSS classes for: 4 - * - Headings (hide hash prefix, apply heading classes) 5 - * - Bold/Italic (hide star/underscore markers, apply classes) 6 - * - Inline code (hide backtick markers, apply class) 7 - * - Links (hide bracket/paren syntax, show rendered link) 8 - */ 9 - import { syntaxTree } from '@codemirror/language'; 10 - import { RangeSetBuilder } from '@codemirror/state'; 11 - import { 12 - Decoration, 13 - type DecorationSet, 14 - EditorView, 15 - ViewPlugin, 16 - type ViewUpdate, 17 - } from '@codemirror/view'; 18 - 19 - const FORMATTING_HIDDEN = 'cm-formatting-hidden'; 20 - 21 - const hiddenMark = Decoration.mark({ class: FORMATTING_HIDDEN }); 22 - const HEADING_LINE_DECOS = [ 23 - Decoration.line({ class: 'cm-heading cm-heading-1' }), 24 - Decoration.line({ class: 'cm-heading cm-heading-2' }), 25 - Decoration.line({ class: 'cm-heading cm-heading-3' }), 26 - Decoration.line({ class: 'cm-heading cm-heading-4' }), 27 - Decoration.line({ class: 'cm-heading cm-heading-5' }), 28 - Decoration.line({ class: 'cm-heading cm-heading-6' }), 29 - ]; 30 - 31 - function buildDecorations(view: EditorView): DecorationSet { 32 - const builder = new RangeSetBuilder<Decoration>(); 33 - const cursorLine = view.state.doc.lineAt( 34 - view.state.selection.main.head, 35 - ).number; 36 - const decos: Array<{ from: number; to: number; deco: Decoration }> = []; 37 - 38 - const ranges = 39 - view.visibleRanges.length > 0 40 - ? view.visibleRanges 41 - : [{ from: 0, to: view.state.doc.length }]; 42 - 43 - for (const { from: rangeFrom, to: rangeTo } of ranges) { 44 - syntaxTree(view.state).iterate({ 45 - from: rangeFrom, 46 - to: rangeTo, 47 - enter(node) { 48 - // Headings 49 - const headingMatch = /^ATXHeading(\d)$/.exec(node.name); 50 - if (headingMatch) { 51 - const level = parseInt(headingMatch[1], 10); 52 - const line = view.state.doc.lineAt(node.from); 53 - decos.push({ 54 - from: line.from, 55 - to: line.from, 56 - deco: HEADING_LINE_DECOS[level - 1], 57 - }); 58 - if (line.number !== cursorLine) { 59 - const headerMark = node.node.getChild('HeaderMark'); 60 - if (headerMark) { 61 - const hideEnd = Math.min(headerMark.to + 1, line.to); 62 - decos.push({ 63 - from: headerMark.from, 64 - to: hideEnd, 65 - deco: hiddenMark, 66 - }); 67 - } 68 - } 69 - return; 70 - } 71 - 72 - // Bold emphasis 73 - if (node.name === 'StrongEmphasis') { 74 - const openDelim = node.node.getChild('EmphasisMark'); 75 - if (openDelim) { 76 - decos.push({ 77 - from: openDelim.from, 78 - to: openDelim.to, 79 - deco: hiddenMark, 80 - }); 81 - } 82 - // Find closing delimiter 83 - const children = node.node.getChildren('EmphasisMark'); 84 - if (children.length >= 2) { 85 - const lastMark = children[children.length - 1]; 86 - decos.push({ 87 - from: lastMark.from, 88 - to: lastMark.to, 89 - deco: hiddenMark, 90 - }); 91 - } 92 - return; 93 - } 94 - 95 - // Italic emphasis 96 - if (node.name === 'Emphasis') { 97 - const openDelim = node.node.getChild('EmphasisMark'); 98 - if (openDelim) { 99 - decos.push({ 100 - from: openDelim.from, 101 - to: openDelim.to, 102 - deco: hiddenMark, 103 - }); 104 - } 105 - const children = node.node.getChildren('EmphasisMark'); 106 - if (children.length >= 2) { 107 - const lastMark = children[children.length - 1]; 108 - decos.push({ 109 - from: lastMark.from, 110 - to: lastMark.to, 111 - deco: hiddenMark, 112 - }); 113 - } 114 - return; 115 - } 116 - 117 - // Inline code 118 - if (node.name === 'InlineCode') { 119 - const openDelim = node.node.getChild('CodeMark'); 120 - if (openDelim) { 121 - decos.push({ 122 - from: openDelim.from, 123 - to: openDelim.to, 124 - deco: hiddenMark, 125 - }); 126 - } 127 - const children = node.node.getChildren('CodeMark'); 128 - if (children.length >= 2) { 129 - const lastMark = children[children.length - 1]; 130 - decos.push({ 131 - from: lastMark.from, 132 - to: lastMark.to, 133 - deco: hiddenMark, 134 - }); 135 - } 136 - return; 137 - } 138 - 139 - // Links - hide the brackets and parens 140 - if (node.name === 'Link') { 141 - const linkMark = node.node.getChild('LinkMark'); 142 - if (linkMark) { 143 - // Hide opening [ 144 - decos.push({ 145 - from: linkMark.from, 146 - to: linkMark.to, 147 - deco: hiddenMark, 148 - }); 149 - } 150 - // Find closing ] and (url) 151 - const closeBracket = node.node.getChild('CloseBracket'); 152 - if (closeBracket) { 153 - decos.push({ 154 - from: closeBracket.from, 155 - to: closeBracket.to, 156 - deco: hiddenMark, 157 - }); 158 - } 159 - } 160 - }, 161 - }); 162 - } 163 - 164 - decos.sort((a, b) => a.from - b.from || a.to - b.to); 165 - for (const d of decos) { 166 - builder.add(d.from, d.to, d.deco); 167 - } 168 - 169 - return builder.finish(); 170 - } 171 - 172 - export const livePreview = ViewPlugin.fromClass( 173 - class { 174 - decorations: DecorationSet; 175 - constructor(view: EditorView) { 176 - this.decorations = buildDecorations(view); 177 - } 178 - update(update: ViewUpdate) { 179 - if (update.docChanged || update.selectionSet || update.viewportChanged) { 180 - this.decorations = buildDecorations(update.view); 181 - } 182 - } 183 - }, 184 - { decorations: (v) => v.decorations }, 185 - );
+68
src/editors/automerge/livePreview/blockquoteDecoration.ts
··· 1 + /** 2 + * Blockquotes (`> …`). 3 + * 4 + * Applies a `cm-quote` line decoration to each quoted line and hides the `>` 5 + * (plus trailing space) prefix when the cursor is not on that line. Handles a 6 + * single level of quoting via `QuoteMark` nodes; nested blockquotes reuse the 7 + * same line class (dedup keeps it flat) and their inner `>` markers are hidden 8 + * independently — deeper-nesting-specific styling is a follow-up. 9 + */ 10 + import { type EditorState } from '@codemirror/state'; 11 + import { Decoration } from '@codemirror/view'; 12 + import { syntaxTree } from '@codemirror/language'; 13 + 14 + import { 15 + type BuildResult, 16 + type Range, 17 + DecoCollector, 18 + cursorLineNumber, 19 + decorationExtension, 20 + fullRanges, 21 + } from './context'; 22 + import { blockquoteFormattingHiddenMark } from './hiddenClasses'; 23 + 24 + const quoteLine = Decoration.line({ class: 'cm-quote' }); 25 + 26 + export function buildBlockquoteDecorations( 27 + state: EditorState, 28 + ranges: readonly Range[] = fullRanges(state), 29 + ): BuildResult { 30 + const cursorLine = cursorLineNumber(state); 31 + const out = new DecoCollector(); 32 + 33 + for (const { from, to } of ranges) { 34 + syntaxTree(state).iterate({ 35 + from, 36 + to, 37 + enter(node) { 38 + if (node.name === 'Blockquote') { 39 + const endPos = Math.min(node.to, state.doc.length); 40 + const startLine = state.doc.lineAt(node.from).number; 41 + let endLine = state.doc.lineAt(endPos).number; 42 + if (endPos === state.doc.lineAt(endPos).from && endLine > startLine) { 43 + endLine--; 44 + } 45 + for (let l = startLine; l <= endLine; l++) { 46 + const line = state.doc.line(l); 47 + out.push(line.from, line.from, quoteLine); 48 + } 49 + return; 50 + } 51 + 52 + if (node.name === 'QuoteMark') { 53 + const line = state.doc.lineAt(node.from); 54 + if (line.number !== cursorLine) { 55 + const hideEnd = Math.min(node.to + 1, line.to); 56 + out.push(node.from, hideEnd, blockquoteFormattingHiddenMark); 57 + } 58 + } 59 + }, 60 + }); 61 + } 62 + 63 + return out.result(); 64 + } 65 + 66 + export const blockquoteDecoration = decorationExtension( 67 + buildBlockquoteDecorations, 68 + );
+102
src/editors/automerge/livePreview/codeDecoration.ts
··· 1 + /** 2 + * Code: inline code (`` `x` ``) and fenced code blocks (``` ``` ```). 3 + * 4 + * - Inline code: apply `cm-inline-code` to the content, hide the backtick 5 + * `CodeMark`s unless the cursor is near. Uses a pure distance check (no 6 + * same-line gate) because CommonMark code spans may cross newlines. 7 + * - Fenced code: apply a `cm-codeblock` line decoration to every line of the 8 + * block, and hide the opening/closing fence rows unless the cursor is on any 9 + * line within the block. The language label (`CodeInfo`) is left visible. 10 + * 11 + * Hidden marker ranges are registered as atomic. Line decorations are not. 12 + */ 13 + import { type EditorState } from '@codemirror/state'; 14 + import { Decoration } from '@codemirror/view'; 15 + import { syntaxTree } from '@codemirror/language'; 16 + 17 + import { 18 + type BuildResult, 19 + type Range, 20 + PROXIMITY_CHARS, 21 + DecoCollector, 22 + fullRanges, 23 + isCursorInRange, 24 + isCursorOnBlockLine, 25 + decorationExtension, 26 + } from './context'; 27 + import { inlineFormattingHiddenMark } from './hiddenClasses'; 28 + 29 + const inlineCodeMark = Decoration.mark({ class: 'cm-inline-code' }); 30 + const codeBlockLine = Decoration.line({ class: 'cm-codeblock' }); 31 + 32 + export function buildCodeDecorations( 33 + state: EditorState, 34 + ranges: readonly Range[] = fullRanges(state), 35 + ): BuildResult { 36 + const out = new DecoCollector(); 37 + 38 + for (const { from, to } of ranges) { 39 + syntaxTree(state).iterate({ 40 + from, 41 + to, 42 + enter(node) { 43 + if (node.name === 'InlineCode') { 44 + const near = isCursorInRange( 45 + state, 46 + node.from, 47 + node.to, 48 + PROXIMITY_CHARS, 49 + ); 50 + const first = node.node.firstChild; 51 + const last = node.node.lastChild; 52 + // Content class spans between the backticks (or the whole node if 53 + // markers are missing on malformed input). 54 + const contentFrom = first?.name === 'CodeMark' ? first.to : node.from; 55 + const contentTo = last?.name === 'CodeMark' ? last.from : node.to; 56 + if (contentTo > contentFrom) { 57 + out.push(contentFrom, contentTo, inlineCodeMark); 58 + } 59 + if (!near) { 60 + if (first?.name === 'CodeMark') { 61 + out.push(first.from, first.to, inlineFormattingHiddenMark, true); 62 + } 63 + if (last?.name === 'CodeMark') { 64 + out.push(last.from, last.to, inlineFormattingHiddenMark, true); 65 + } 66 + } 67 + return false; // don't descend into code text 68 + } 69 + 70 + if (node.name === 'FencedCode') { 71 + const endPos = Math.min(node.to, state.doc.length); 72 + const startLine = state.doc.lineAt(node.from).number; 73 + let endLine = state.doc.lineAt(endPos).number; 74 + // Trailing newline: don't paint the empty line after the block. 75 + if (endPos === state.doc.lineAt(endPos).from && endLine > startLine) { 76 + endLine--; 77 + } 78 + for (let l = startLine; l <= endLine; l++) { 79 + const line = state.doc.line(l); 80 + out.push(line.from, line.from, codeBlockLine); 81 + } 82 + 83 + if (!isCursorOnBlockLine(state, node.from, node.to)) { 84 + const first = node.node.firstChild; 85 + const last = node.node.lastChild; 86 + if (first?.name === 'CodeMark') { 87 + out.push(first.from, first.to, inlineFormattingHiddenMark, true); 88 + } 89 + if (last?.name === 'CodeMark') { 90 + out.push(last.from, last.to, inlineFormattingHiddenMark, true); 91 + } 92 + } 93 + return false; 94 + } 95 + }, 96 + }); 97 + } 98 + 99 + return out.result(); 100 + } 101 + 102 + export const codeDecoration = decorationExtension(buildCodeDecorations);
+196
src/editors/automerge/livePreview/context.ts
··· 1 + /** 2 + * Shared helpers for the split live-preview decoration extensions. 3 + * 4 + * Each per-node file exports a *pure builder* over `EditorState` (so it can be 5 + * unit-tested headlessly, with no DOM / EditorView) plus an `Extension` built 6 + * with `decorationExtension`, which wraps the builder in a `ViewPlugin` and, 7 + * when the builder returns an `atomic` set, registers those ranges via 8 + * `EditorView.atomicRanges` so cursor/click motion skips zero-geometry hidden 9 + * markers. 10 + * 11 + * NOTE (Fallback A / tables): when the GFM table grid lands, every builder 12 + * that emits a `Decoration.mark`/`Decoration.replace` must first skip nodes 13 + * inside a `TableCell` (an `isInsideTableCell` guard) so it doesn't split the 14 + * one-cell-equals-one-flex-item wrapper. Tables are out of scope here, so no 15 + * guard is wired yet — see the reams reference `markdown-context.ts`. 16 + */ 17 + import { 18 + type EditorState, 19 + RangeSetBuilder, 20 + type Extension, 21 + } from '@codemirror/state'; 22 + import { 23 + Decoration, 24 + type DecorationSet, 25 + EditorView, 26 + ViewPlugin, 27 + type ViewUpdate, 28 + } from '@codemirror/view'; 29 + import { syntaxTree } from '@codemirror/language'; 30 + 31 + /** How many characters away from an inline span still reveals its markers. */ 32 + export const PROXIMITY_CHARS = 2; 33 + 34 + export interface Range { 35 + from: number; 36 + to: number; 37 + } 38 + 39 + export interface DecoRange { 40 + from: number; 41 + to: number; 42 + deco: Decoration; 43 + } 44 + 45 + export interface BuildResult { 46 + /** Everything the plugin paints. */ 47 + decorations: DecorationSet; 48 + /** Subset registered with `EditorView.atomicRanges` (hidden markers). */ 49 + atomic?: DecorationSet; 50 + } 51 + 52 + export type DecoBuilder = ( 53 + state: EditorState, 54 + ranges?: readonly Range[], 55 + ) => BuildResult; 56 + 57 + /** Line number (1-based) the primary cursor sits on. */ 58 + export function cursorLineNumber(state: EditorState): number { 59 + return state.doc.lineAt(state.selection.main.head).number; 60 + } 61 + 62 + /** The whole document as a single range — the headless/test default. */ 63 + export function fullRanges(state: EditorState): Range[] { 64 + return [{ from: 0, to: state.doc.length }]; 65 + } 66 + 67 + /** 68 + * The view's visible ranges, or the full document when empty 69 + * (headless render / tests, where `visibleRanges` is `[]`). 70 + */ 71 + export function iterRanges(view: EditorView): readonly Range[] { 72 + return view.visibleRanges.length > 0 73 + ? view.visibleRanges 74 + : fullRanges(view.state); 75 + } 76 + 77 + /** 78 + * True iff the cursor is on the SAME LINE as the span [from, to] AND within 79 + * `slack` chars of its boundaries. Same-line gating stops proximity-reveal 80 + * from leaking onto neighbouring lines. Correct for bold/italic/strike/links, 81 + * which cannot cross newlines per the @lezer/markdown grammar. Inline code is 82 + * the exception (it may span newlines) — use `isCursorInRange` there. 83 + */ 84 + export function isCursorNearSameLine( 85 + state: EditorState, 86 + from: number, 87 + to: number, 88 + slack: number = PROXIMITY_CHARS, 89 + ): boolean { 90 + const cursor = state.selection.main.head; 91 + if (cursor < from - slack || cursor > to + slack) return false; 92 + return state.doc.lineAt(cursor).number === state.doc.lineAt(from).number; 93 + } 94 + 95 + /** Pure distance check (no same-line gate) — for spans that may cross lines. */ 96 + export function isCursorInRange( 97 + state: EditorState, 98 + from: number, 99 + to: number, 100 + slack = 0, 101 + ): boolean { 102 + const cursor = state.selection.main.head; 103 + return cursor >= from - slack && cursor <= to + slack; 104 + } 105 + 106 + /** True if the cursor's line falls anywhere within [blockFrom, blockTo]. */ 107 + export function isCursorOnBlockLine( 108 + state: EditorState, 109 + blockFrom: number, 110 + blockTo: number, 111 + ): boolean { 112 + const cursorLine = state.doc.lineAt(state.selection.main.head).number; 113 + const startLine = state.doc.lineAt(blockFrom).number; 114 + const endLine = state.doc.lineAt(Math.min(blockTo, state.doc.length)).number; 115 + return cursorLine >= startLine && cursorLine <= endLine; 116 + } 117 + 118 + /** 119 + * Sort a decoration list (`from` asc, then `to` asc — puts zero-length line 120 + * decorations before marks at the same offset) and pour it into a 121 + * `RangeSetBuilder`, whose `add` requires monotonic input. 122 + */ 123 + export function finishDecos(decos: DecoRange[]): DecorationSet { 124 + decos.sort((a, b) => a.from - b.from || a.to - b.to); 125 + const builder = new RangeSetBuilder<Decoration>(); 126 + for (const d of decos) builder.add(d.from, d.to, d.deco); 127 + return builder.finish(); 128 + } 129 + 130 + /** 131 + * Accumulator that dedupes decorations by `from:to:class` (overlapping visible 132 + * ranges can enter the same node twice) and tracks the atomic subset. 133 + */ 134 + export class DecoCollector { 135 + private readonly seen = new Set<string>(); 136 + readonly all: DecoRange[] = []; 137 + readonly atomic: DecoRange[] = []; 138 + 139 + push(from: number, to: number, deco: Decoration, isAtomic = false): void { 140 + // `Decoration.spec` is typed `any` upstream, so direct access is fine. 141 + const cls: string = deco.spec?.class ?? ''; 142 + const key = `${from}:${to}:${cls}`; 143 + if (this.seen.has(key)) return; 144 + this.seen.add(key); 145 + this.all.push({ from, to, deco }); 146 + if (isAtomic) this.atomic.push({ from, to, deco }); 147 + } 148 + 149 + result(): BuildResult { 150 + return { 151 + decorations: finishDecos(this.all), 152 + atomic: finishDecos(this.atomic), 153 + }; 154 + } 155 + } 156 + 157 + /** Rebuild triggers: doc/selection/viewport changes plus incremental parses. */ 158 + export function shouldRebuild(update: ViewUpdate): boolean { 159 + return ( 160 + update.docChanged || 161 + update.selectionSet || 162 + update.viewportChanged || 163 + syntaxTree(update.startState) !== syntaxTree(update.state) 164 + ); 165 + } 166 + 167 + /** 168 + * Wrap a pure builder in a ViewPlugin + optional atomicRanges provider. 169 + */ 170 + export function decorationExtension(build: DecoBuilder): Extension { 171 + const plugin = ViewPlugin.fromClass( 172 + class { 173 + decorations: DecorationSet; 174 + atomic: DecorationSet; 175 + constructor(view: EditorView) { 176 + const r = build(view.state, iterRanges(view)); 177 + this.decorations = r.decorations; 178 + this.atomic = r.atomic ?? Decoration.none; 179 + } 180 + update(update: ViewUpdate) { 181 + if (!shouldRebuild(update)) return; 182 + const r = build(update.view.state, iterRanges(update.view)); 183 + this.decorations = r.decorations; 184 + this.atomic = r.atomic ?? Decoration.none; 185 + } 186 + }, 187 + { decorations: (v) => v.decorations }, 188 + ); 189 + 190 + return [ 191 + plugin, 192 + EditorView.atomicRanges.of( 193 + (view) => view.plugin(plugin)?.atomic ?? Decoration.none, 194 + ), 195 + ]; 196 + }
+66
src/editors/automerge/livePreview/headingDecoration.ts
··· 1 + /** 2 + * ATX headings (`# ` … `###### `). 3 + * 4 + * Applies a `cm-heading cm-heading-{level}` line decoration and hides the 5 + * `HeaderMark` prefix (the `#`s + trailing space) when the cursor is not on 6 + * the heading line. Setext headings (`===`/`---` underlines) are not handled. 7 + */ 8 + import { type EditorState } from '@codemirror/state'; 9 + import { Decoration } from '@codemirror/view'; 10 + import { syntaxTree } from '@codemirror/language'; 11 + 12 + import { 13 + type BuildResult, 14 + type DecoRange, 15 + type Range, 16 + cursorLineNumber, 17 + decorationExtension, 18 + finishDecos, 19 + fullRanges, 20 + } from './context'; 21 + import { headingPrefixHiddenMark } from './hiddenClasses'; 22 + 23 + const HEADING_LINE_DECOS = [1, 2, 3, 4, 5, 6].map((level) => 24 + Decoration.line({ class: `cm-heading cm-heading-${level}` }), 25 + ); 26 + 27 + export function buildHeadingDecorations( 28 + state: EditorState, 29 + ranges: readonly Range[] = fullRanges(state), 30 + ): BuildResult { 31 + const cursorLine = cursorLineNumber(state); 32 + const decos: DecoRange[] = []; 33 + 34 + for (const { from, to } of ranges) { 35 + syntaxTree(state).iterate({ 36 + from, 37 + to, 38 + enter(node) { 39 + const match = /^ATXHeading(\d)$/.exec(node.name); 40 + if (!match) return; 41 + const level = parseInt(match[1], 10); 42 + const line = state.doc.lineAt(node.from); 43 + decos.push({ 44 + from: line.from, 45 + to: line.from, 46 + deco: HEADING_LINE_DECOS[level - 1], 47 + }); 48 + if (line.number !== cursorLine) { 49 + const headerMark = node.node.getChild('HeaderMark'); 50 + if (headerMark) { 51 + const hideEnd = Math.min(headerMark.to + 1, line.to); 52 + decos.push({ 53 + from: headerMark.from, 54 + to: hideEnd, 55 + deco: headingPrefixHiddenMark, 56 + }); 57 + } 58 + } 59 + }, 60 + }); 61 + } 62 + 63 + return { decorations: finishDecos(decos) }; 64 + } 65 + 66 + export const headingDecoration = decorationExtension(buildHeadingDecorations);
+51
src/editors/automerge/livePreview/hiddenClasses.ts
··· 1 + /** 2 + * Single source of truth for the CSS classes that HIDE markdown syntax. 3 + * 4 + * The CSS shape of each class is load-bearing — a naive `display:none` on a 5 + * marker that is the ONLY visible content of a line breaks CM6 vertical cursor 6 + * motion (ArrowUp/ArrowDown resolve caret position from pixel geometry via 7 + * `caretRangeFromPoint`, which ignores zero-geometry elements). Each class 8 + * below is paired with a specific idiom in `automergeEditor.css`; see the 9 + * comments there. Ported from the reams reference `hidden-text-classes.ts`. 10 + */ 11 + import { Decoration } from '@codemirror/view'; 12 + 13 + /** 14 + * HEADING PREFIX (`# `) — hybrid idiom. Zero visible WIDTH (heading text 15 + * starts at column 0) but non-zero HEIGHT (an empty `# ` line stays 16 + * caret-targetable). Kept as the historical class name for continuity. 17 + */ 18 + export const HEADING_PREFIX_HIDDEN = 'cm-formatting-hidden'; 19 + 20 + /** 21 + * INLINE markers (`**`, `*`, `~~`, `` ` ``, link brackets/paren/URL) — the 22 + * line always has visible text on at least one side, so plain `display:none` 23 + * is safe and also removes the marker from click hit-testing. Owning 24 + * extensions additionally register these ranges via `EditorView.atomicRanges`. 25 + */ 26 + export const INLINE_FORMATTING_HIDDEN = 'cm-inline-formatting-hidden'; 27 + 28 + /** 29 + * BLOCKQUOTE prefix (`> `) — appears at line start and may be the line's first 30 + * inline child, so it uses a zero-width inline-block sentinel (keeps a caret 31 + * landing spot) rather than `display:none`. 32 + */ 33 + export const BLOCKQUOTE_FORMATTING_HIDDEN = 'cm-blockquote-formatting-hidden'; 34 + 35 + /** 36 + * HORIZONTAL RULE text (`---`) — whole-line hide. The line must keep natural 37 + * geometry (so `caretRangeFromPoint` finds it); CSS only masks the glyphs 38 + * (`color: transparent`). The visible rule is painted by the `.cm-hr` border. 39 + */ 40 + export const HR_LINE_HIDDEN = 'cm-hr-line-hidden'; 41 + 42 + export const headingPrefixHiddenMark = Decoration.mark({ 43 + class: HEADING_PREFIX_HIDDEN, 44 + }); 45 + export const inlineFormattingHiddenMark = Decoration.mark({ 46 + class: INLINE_FORMATTING_HIDDEN, 47 + }); 48 + export const blockquoteFormattingHiddenMark = Decoration.mark({ 49 + class: BLOCKQUOTE_FORMATTING_HIDDEN, 50 + }); 51 + export const hrLineHiddenMark = Decoration.mark({ class: HR_LINE_HIDDEN });
+53
src/editors/automerge/livePreview/hrDecoration.ts
··· 1 + /** 2 + * Horizontal rules (`---`, `***`, `___`). 3 + * 4 + * Applies a `cm-hr` line decoration (CSS paints the rule via a border) and 5 + * hides the raw glyphs unless the cursor is on the rule's line. Lezer emits 6 + * `HorizontalRule` only for true rules — setext heading underlines parse as 7 + * `SetextHeading*`, so no disambiguation is needed. 8 + */ 9 + import { type EditorState } from '@codemirror/state'; 10 + import { Decoration } from '@codemirror/view'; 11 + import { syntaxTree } from '@codemirror/language'; 12 + 13 + import { 14 + type BuildResult, 15 + type DecoRange, 16 + type Range, 17 + cursorLineNumber, 18 + decorationExtension, 19 + finishDecos, 20 + fullRanges, 21 + } from './context'; 22 + import { hrLineHiddenMark } from './hiddenClasses'; 23 + 24 + const hrLine = Decoration.line({ class: 'cm-hr' }); 25 + 26 + export function buildHrDecorations( 27 + state: EditorState, 28 + ranges: readonly Range[] = fullRanges(state), 29 + ): BuildResult { 30 + const cursorLine = cursorLineNumber(state); 31 + const decos: DecoRange[] = []; 32 + 33 + for (const { from, to } of ranges) { 34 + syntaxTree(state).iterate({ 35 + from, 36 + to, 37 + enter(node) { 38 + if (node.name !== 'HorizontalRule') return; 39 + const line = state.doc.lineAt(node.from); 40 + decos.push({ from: line.from, to: line.from, deco: hrLine }); 41 + // Hide the whole line's text (including any leading whitespace) so 42 + // the border alone reads as the rule. 43 + if (line.number !== cursorLine && line.to > line.from) { 44 + decos.push({ from: line.from, to: line.to, deco: hrLineHiddenMark }); 45 + } 46 + }, 47 + }); 48 + } 49 + 50 + return { decorations: finishDecos(decos) }; 51 + } 52 + 53 + export const hrDecoration = decorationExtension(buildHrDecorations);
+60
src/editors/automerge/livePreview/index.ts
··· 1 + /** 2 + * Composite markdown live-preview for the Automerge editor. 3 + * 4 + * One extension per node concern (mirrors the reams reference structure), 5 + * composed into a single `Extension`. Also installs the cmd/ctrl-click handler 6 + * that opens links externally. 7 + * 8 + * Ordering note: these must be registered AFTER the `markdown(...)` language 9 + * extension so `syntaxTree(state)` is populated when the builders run. 10 + */ 11 + import { type Extension } from '@codemirror/state'; 12 + import { EditorView } from '@codemirror/view'; 13 + 14 + import { headingDecoration } from './headingDecoration'; 15 + import { inlineFormatDecoration } from './inlineFormatDecoration'; 16 + import { codeDecoration } from './codeDecoration'; 17 + import { linkDecoration, linkUrlAt } from './linkDecoration'; 18 + import { blockquoteDecoration } from './blockquoteDecoration'; 19 + import { hrDecoration } from './hrDecoration'; 20 + import { listDecoration } from './listDecoration'; 21 + 22 + /** Open a URL in the system browser, via the Electron bridge when present. */ 23 + function openExternal(url: string): void { 24 + const api = window.textile?.openExternal; 25 + if (api) { 26 + void api(url); 27 + } else { 28 + window.open(url, '_blank', 'noopener,noreferrer'); 29 + } 30 + } 31 + 32 + /** 33 + * cmd/ctrl-click a rendered link to open it; a plain click still positions the 34 + * cursor (revealing the raw markdown for editing via proximity). 35 + */ 36 + const linkClickHandler = EditorView.domEventHandlers({ 37 + mousedown(event, view) { 38 + if (!(event.metaKey || event.ctrlKey)) return false; 39 + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); 40 + if (pos == null) return false; 41 + const url = linkUrlAt(view.state, pos); 42 + if (!url) return false; 43 + event.preventDefault(); 44 + openExternal(url); 45 + return true; 46 + }, 47 + }); 48 + 49 + export function livePreview(): Extension { 50 + return [ 51 + headingDecoration, 52 + inlineFormatDecoration, 53 + codeDecoration, 54 + linkDecoration, 55 + blockquoteDecoration, 56 + hrDecoration, 57 + listDecoration, 58 + linkClickHandler, 59 + ]; 60 + }
+73
src/editors/automerge/livePreview/inlineFormatDecoration.ts
··· 1 + /** 2 + * Inline emphasis: bold (`**`/`__`), italic (`*`/`_`), strikethrough (`~~`). 3 + * 4 + * Applies a content class to the span and hides the surrounding markers unless 5 + * the cursor is near (same line, within PROXIMITY_CHARS). Both `Emphasis` and 6 + * `StrongEmphasis` use `EmphasisMark` for their markers — there is no 7 + * `StrongEmphasisMark` in @lezer/markdown; the parent node name disambiguates. 8 + * Hidden marker ranges are registered as atomic so cursor motion skips them. 9 + */ 10 + import { type EditorState } from '@codemirror/state'; 11 + import { Decoration } from '@codemirror/view'; 12 + import { syntaxTree } from '@codemirror/language'; 13 + 14 + import { 15 + type BuildResult, 16 + type Range, 17 + DecoCollector, 18 + fullRanges, 19 + isCursorNearSameLine, 20 + decorationExtension, 21 + } from './context'; 22 + import { inlineFormattingHiddenMark } from './hiddenClasses'; 23 + 24 + const boldMark = Decoration.mark({ class: 'cm-strong' }); 25 + const italicMark = Decoration.mark({ class: 'cm-em' }); 26 + const strikethroughMark = Decoration.mark({ class: 'cm-strikethrough' }); 27 + 28 + /** Lezer container node name → content decoration. */ 29 + const CONTENT_MARKS: Record<string, Decoration> = { 30 + StrongEmphasis: boldMark, 31 + Emphasis: italicMark, 32 + Strikethrough: strikethroughMark, 33 + }; 34 + const CONTAINER_NAMES = new Set(Object.keys(CONTENT_MARKS)); 35 + 36 + export function buildInlineFormatDecorations( 37 + state: EditorState, 38 + ranges: readonly Range[] = fullRanges(state), 39 + ): BuildResult { 40 + const out = new DecoCollector(); 41 + 42 + for (const { from, to } of ranges) { 43 + syntaxTree(state).iterate({ 44 + from, 45 + to, 46 + enter(node) { 47 + const contentMark = CONTENT_MARKS[node.name]; 48 + if (contentMark) { 49 + out.push(node.from, node.to, contentMark); 50 + return; // keep descending to reach the markers 51 + } 52 + 53 + if (node.name === 'EmphasisMark' || node.name === 'StrikethroughMark') { 54 + // Widen the proximity window to the whole formatted span when the 55 + // parent is a known container; otherwise fall back to the marker. 56 + const parent = node.node.parent; 57 + const scoped = parent && CONTAINER_NAMES.has(parent.name); 58 + const checkFrom = scoped ? parent.from : node.from; 59 + const checkTo = scoped ? parent.to : node.to; 60 + if (!isCursorNearSameLine(state, checkFrom, checkTo)) { 61 + out.push(node.from, node.to, inlineFormattingHiddenMark, true); 62 + } 63 + } 64 + }, 65 + }); 66 + } 67 + 68 + return out.result(); 69 + } 70 + 71 + export const inlineFormatDecoration = decorationExtension( 72 + buildInlineFormatDecorations, 73 + );
+91
src/editors/automerge/livePreview/linkDecoration.ts
··· 1 + /** 2 + * Inline links: `[label](url)`. 3 + * 4 + * Styles the label `cm-link` and hides the brackets, parens, URL and optional 5 + * title unless the cursor is near (same line). `linkUrlAt` resolves the URL of 6 + * the link enclosing a document offset — used by the cmd/ctrl-click handler in 7 + * `index.ts` to open links externally. Autolinks and images are left raw. 8 + */ 9 + import { type EditorState } from '@codemirror/state'; 10 + import { Decoration } from '@codemirror/view'; 11 + import { syntaxTree } from '@codemirror/language'; 12 + import type { SyntaxNode } from '@lezer/common'; 13 + 14 + import { 15 + type BuildResult, 16 + type Range, 17 + DecoCollector, 18 + fullRanges, 19 + isCursorNearSameLine, 20 + decorationExtension, 21 + } from './context'; 22 + import { inlineFormattingHiddenMark } from './hiddenClasses'; 23 + 24 + const linkLabelMark = Decoration.mark({ class: 'cm-link' }); 25 + 26 + /** Children of a Link node, grouped by role. */ 27 + function linkParts(node: SyntaxNode): { 28 + marks: SyntaxNode[]; 29 + url: SyntaxNode | null; 30 + title: SyntaxNode | null; 31 + } { 32 + const marks: SyntaxNode[] = []; 33 + let url: SyntaxNode | null = null; 34 + let title: SyntaxNode | null = null; 35 + for (let c = node.firstChild; c; c = c.nextSibling) { 36 + if (c.name === 'LinkMark') marks.push(c); 37 + else if (c.name === 'URL') url = c; 38 + else if (c.name === 'LinkTitle') title = c; 39 + } 40 + return { marks, url, title }; 41 + } 42 + 43 + export function buildLinkDecorations( 44 + state: EditorState, 45 + ranges: readonly Range[] = fullRanges(state), 46 + ): BuildResult { 47 + const out = new DecoCollector(); 48 + 49 + for (const { from, to } of ranges) { 50 + syntaxTree(state).iterate({ 51 + from, 52 + to, 53 + enter(node) { 54 + if (node.name !== 'Link') return; 55 + const { marks, url, title } = linkParts(node.node); 56 + 57 + // Style the label (between the first `[` and its matching `]`). 58 + if (marks.length >= 2 && marks[1].from > marks[0].to) { 59 + out.push(marks[0].to, marks[1].from, linkLabelMark); 60 + } 61 + 62 + if (isCursorNearSameLine(state, node.from, node.to)) { 63 + return; // reveal full source for editing 64 + } 65 + 66 + for (const m of marks) { 67 + out.push(m.from, m.to, inlineFormattingHiddenMark, true); 68 + } 69 + if (url) out.push(url.from, url.to, inlineFormattingHiddenMark, true); 70 + if (title) { 71 + out.push(title.from, title.to, inlineFormattingHiddenMark, true); 72 + } 73 + }, 74 + }); 75 + } 76 + 77 + return out.result(); 78 + } 79 + 80 + /** URL of the Link enclosing `pos`, or null. */ 81 + export function linkUrlAt(state: EditorState, pos: number): string | null { 82 + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); 83 + while (node && node.name !== 'Link') node = node.parent; 84 + if (!node) return null; 85 + const { url } = linkParts(node); 86 + if (!url) return null; 87 + const text = state.doc.sliceString(url.from, url.to).trim(); 88 + return text.length > 0 ? text : null; 89 + } 90 + 91 + export const linkDecoration = decorationExtension(buildLinkDecorations);
+58
src/editors/automerge/livePreview/listDecoration.ts
··· 1 + /** 2 + * Lists (bullet and ordered). 3 + * 4 + * Applies a `cm-list-{depth}` line decoration to each list item's marker line 5 + * (depth 1–3 by nesting), which the CSS turns into progressive indentation. 6 + * List markers themselves are left as literal source (no widget) — bullet 7 + * glyph rendering and task-list checkboxes are a later, widget-based phase, so 8 + * `TaskMarker` is deliberately untouched here. 9 + */ 10 + import { type EditorState } from '@codemirror/state'; 11 + import { Decoration } from '@codemirror/view'; 12 + import { syntaxTree } from '@codemirror/language'; 13 + import type { SyntaxNode } from '@lezer/common'; 14 + 15 + import { 16 + type BuildResult, 17 + type Range, 18 + DecoCollector, 19 + decorationExtension, 20 + fullRanges, 21 + } from './context'; 22 + 23 + const LIST_LINE_DECOS = [1, 2, 3].map((depth) => 24 + Decoration.line({ class: `cm-list-${depth}` }), 25 + ); 26 + 27 + /** Number of BulletList/OrderedList ancestors (nesting depth), min 1. */ 28 + function listDepth(node: SyntaxNode): number { 29 + let depth = 0; 30 + for (let p = node.parent; p; p = p.parent) { 31 + if (p.name === 'BulletList' || p.name === 'OrderedList') depth++; 32 + } 33 + return Math.max(1, depth); 34 + } 35 + 36 + export function buildListDecorations( 37 + state: EditorState, 38 + ranges: readonly Range[] = fullRanges(state), 39 + ): BuildResult { 40 + const out = new DecoCollector(); 41 + 42 + for (const { from, to } of ranges) { 43 + syntaxTree(state).iterate({ 44 + from, 45 + to, 46 + enter(node) { 47 + if (node.name !== 'ListMark') return; 48 + const depth = Math.min(listDepth(node.node), LIST_LINE_DECOS.length); 49 + const line = state.doc.lineAt(node.from); 50 + out.push(line.from, line.from, LIST_LINE_DECOS[depth - 1]); 51 + }, 52 + }); 53 + } 54 + 55 + return out.result(); 56 + } 57 + 58 + export const listDecoration = decorationExtension(buildListDecorations);
+212
src/editors/automerge/livePreview/livePreview.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { EditorState } from '@codemirror/state'; 3 + import { type DecorationSet } from '@codemirror/view'; 4 + import { ensureSyntaxTree } from '@codemirror/language'; 5 + import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; 6 + import { GFM } from '@lezer/markdown'; 7 + 8 + import { buildHeadingDecorations } from './headingDecoration'; 9 + import { buildInlineFormatDecorations } from './inlineFormatDecoration'; 10 + import { buildCodeDecorations } from './codeDecoration'; 11 + import { buildLinkDecorations, linkUrlAt } from './linkDecoration'; 12 + import { buildBlockquoteDecorations } from './blockquoteDecoration'; 13 + import { buildHrDecorations } from './hrDecoration'; 14 + import { buildListDecorations } from './listDecoration'; 15 + import { type BuildResult } from './context'; 16 + 17 + interface FlatDeco { 18 + from: number; 19 + to: number; 20 + cls: string; 21 + text: string; 22 + } 23 + 24 + function makeState(doc: string, cursor = 0): EditorState { 25 + const state = EditorState.create({ 26 + doc, 27 + selection: { anchor: cursor }, 28 + extensions: [markdown({ base: markdownLanguage, extensions: [GFM] })], 29 + }); 30 + // Force a complete parse so syntaxTree(state) is populated headlessly. 31 + ensureSyntaxTree(state, doc.length, 5000); 32 + return state; 33 + } 34 + 35 + function collect(state: EditorState, set: DecorationSet): FlatDeco[] { 36 + const out: FlatDeco[] = []; 37 + const iter = set.iter(); 38 + while (iter.value) { 39 + out.push({ 40 + from: iter.from, 41 + to: iter.to, 42 + // spec is typed `any` upstream. 43 + cls: (iter.value.spec as { class?: string }).class ?? '', 44 + text: state.doc.sliceString(iter.from, iter.to), 45 + }); 46 + iter.next(); 47 + } 48 + return out; 49 + } 50 + 51 + const decos = (state: EditorState, r: BuildResult) => 52 + collect(state, r.decorations); 53 + const atomic = (state: EditorState, r: BuildResult) => 54 + collect( 55 + state, 56 + r.atomic ?? 57 + (r.decorations.update({ filter: () => false }) as DecorationSet), 58 + ); 59 + const withClass = (list: FlatDeco[], cls: string) => 60 + list.filter((d) => d.cls.split(/\s+/).includes(cls)); 61 + 62 + describe('headingDecoration', () => { 63 + it('styles the line and hides the prefix when cursor is elsewhere', () => { 64 + const state = makeState('# Title\n\nbody', 11); // cursor in "body" 65 + const list = decos(state, buildHeadingDecorations(state)); 66 + expect(withClass(list, 'cm-heading-1')).toHaveLength(1); 67 + const hidden = withClass(list, 'cm-formatting-hidden'); 68 + expect(hidden).toHaveLength(1); 69 + expect(hidden[0].text).toBe('# '); 70 + }); 71 + 72 + it('reveals the prefix when the cursor is on the heading line', () => { 73 + const state = makeState('# Title\n\nbody', 3); // cursor in heading 74 + const list = decos(state, buildHeadingDecorations(state)); 75 + expect(withClass(list, 'cm-heading-1')).toHaveLength(1); 76 + expect(withClass(list, 'cm-formatting-hidden')).toHaveLength(0); 77 + }); 78 + 79 + it('emits the right level class', () => { 80 + const state = makeState('### Three\n\nx', 11); 81 + const list = decos(state, buildHeadingDecorations(state)); 82 + expect(withClass(list, 'cm-heading-3')).toHaveLength(1); 83 + }); 84 + }); 85 + 86 + describe('inlineFormatDecoration', () => { 87 + it('styles bold content and hides markers when cursor is far', () => { 88 + const state = makeState('hello world **bold** end', 0); 89 + const result = buildInlineFormatDecorations(state); 90 + const list = decos(state, result); 91 + // Content mark spans the whole StrongEmphasis node (markers are hidden 92 + // separately on top of it). 93 + const strong = withClass(list, 'cm-strong'); 94 + expect(strong).toHaveLength(1); 95 + expect(strong[0].text).toBe('**bold**'); 96 + const hidden = withClass(list, 'cm-inline-formatting-hidden'); 97 + expect(hidden.map((h) => h.text)).toEqual(['**', '**']); 98 + // Hidden markers are registered atomic. 99 + expect( 100 + withClass(atomic(state, result), 'cm-inline-formatting-hidden'), 101 + ).toHaveLength(2); 102 + }); 103 + 104 + it('reveals markers when the cursor is inside the span', () => { 105 + const state = makeState('hello world **bold** end', 16); // inside "bold" 106 + const list = decos(state, buildInlineFormatDecorations(state)); 107 + expect(withClass(list, 'cm-strong')).toHaveLength(1); 108 + expect(withClass(list, 'cm-inline-formatting-hidden')).toHaveLength(0); 109 + }); 110 + 111 + it('handles italic and strikethrough', () => { 112 + const italic = makeState('hello world *it* end', 0); 113 + expect( 114 + withClass(decos(italic, buildInlineFormatDecorations(italic)), 'cm-em'), 115 + ).toHaveLength(1); 116 + 117 + const strike = makeState('hello world ~~no~~ end', 0); 118 + const strikeList = decos(strike, buildInlineFormatDecorations(strike)); 119 + const st = withClass(strikeList, 'cm-strikethrough'); 120 + expect(st).toHaveLength(1); 121 + expect(st[0].text).toBe('~~no~~'); 122 + }); 123 + }); 124 + 125 + describe('codeDecoration', () => { 126 + it('styles inline code and hides backticks when far', () => { 127 + const state = makeState('hello world `code` end', 0); 128 + const list = decos(state, buildCodeDecorations(state)); 129 + const code = withClass(list, 'cm-inline-code'); 130 + expect(code).toHaveLength(1); 131 + expect(code[0].text).toBe('code'); 132 + expect( 133 + withClass(list, 'cm-inline-formatting-hidden').map((h) => h.text), 134 + ).toEqual(['`', '`']); 135 + }); 136 + 137 + it('paints codeblock lines and hides fences when cursor is outside', () => { 138 + const state = makeState('```js\ncode\n```\n\nafter', 18); // in "after" 139 + const list = decos(state, buildCodeDecorations(state)); 140 + expect(withClass(list, 'cm-codeblock').length).toBeGreaterThanOrEqual(3); 141 + // Opening and closing fence rows hidden. 142 + expect( 143 + withClass(list, 'cm-inline-formatting-hidden').length, 144 + ).toBeGreaterThanOrEqual(2); 145 + }); 146 + 147 + it('reveals fences when cursor is inside the block', () => { 148 + const state = makeState('```js\ncode\n```\n\nafter', 7); // inside "code" 149 + const list = decos(state, buildCodeDecorations(state)); 150 + expect(withClass(list, 'cm-codeblock').length).toBeGreaterThanOrEqual(3); 151 + expect(withClass(list, 'cm-inline-formatting-hidden')).toHaveLength(0); 152 + }); 153 + }); 154 + 155 + describe('linkDecoration', () => { 156 + it('styles the label and hides the target when far', () => { 157 + const state = makeState('see [a](http://x) end', 0); 158 + const list = decos(state, buildLinkDecorations(state)); 159 + const label = withClass(list, 'cm-link'); 160 + expect(label).toHaveLength(1); 161 + expect(label[0].text).toBe('a'); 162 + const hidden = withClass(list, 'cm-inline-formatting-hidden').map( 163 + (h) => h.text, 164 + ); 165 + expect(hidden).toContain('http://x'); 166 + expect(hidden).toContain('['); 167 + expect(hidden).toContain(']'); 168 + }); 169 + 170 + it('resolves the URL at a position', () => { 171 + const state = makeState('see [a](http://x) end', 0); 172 + expect(linkUrlAt(state, 5)).toBe('http://x'); // inside "a" 173 + expect(linkUrlAt(state, 0)).toBeNull(); // in "see " 174 + }); 175 + }); 176 + 177 + describe('blockquoteDecoration', () => { 178 + it('styles quote lines and hides the > prefix when cursor is elsewhere', () => { 179 + const state = makeState('> quote\n\nbody', 11); // in "body" 180 + const list = decos(state, buildBlockquoteDecorations(state)); 181 + expect(withClass(list, 'cm-quote').length).toBeGreaterThanOrEqual(1); 182 + const hidden = withClass(list, 'cm-blockquote-formatting-hidden'); 183 + expect(hidden).toHaveLength(1); 184 + expect(hidden[0].text).toBe('> '); 185 + }); 186 + }); 187 + 188 + describe('hrDecoration', () => { 189 + it('styles the rule line and hides glyphs when cursor is elsewhere', () => { 190 + const state = makeState('para\n\n---\n\nmore', 0); // in "para" 191 + const list = decos(state, buildHrDecorations(state)); 192 + expect(withClass(list, 'cm-hr')).toHaveLength(1); 193 + const hidden = withClass(list, 'cm-hr-line-hidden'); 194 + expect(hidden).toHaveLength(1); 195 + expect(hidden[0].text).toBe('---'); 196 + }); 197 + }); 198 + 199 + describe('listDecoration', () => { 200 + it('indents by nesting depth', () => { 201 + const state = makeState('- one\n- two', 0); 202 + const list = decos(state, buildListDecorations(state)); 203 + expect(withClass(list, 'cm-list-1')).toHaveLength(2); 204 + }); 205 + 206 + it('applies a deeper class to nested items', () => { 207 + const state = makeState('- a\n - b', 0); 208 + const list = decos(state, buildListDecorations(state)); 209 + expect(withClass(list, 'cm-list-1')).toHaveLength(1); 210 + expect(withClass(list, 'cm-list-2')).toHaveLength(1); 211 + }); 212 + });
+11 -1
src/main.ts
··· 1 - import { app, BrowserWindow, Menu, ipcMain, dialog } from 'electron'; 1 + import { app, BrowserWindow, Menu, ipcMain, dialog, shell } from 'electron'; 2 2 import path from 'node:path'; 3 3 import os from 'node:os'; 4 4 import fs from 'node:fs/promises'; ··· 351 351 ipcMain.handle('vault:copy-file', async (_event, { srcPath, destPath }) => { 352 352 await fs.copyFile(expandPath(srcPath), expandPath(destPath)); 353 353 }); 354 + 355 + ipcMain.handle( 356 + 'app:open-external', 357 + async (_event, { url }: { url: string }) => { 358 + // Only follow web/mail links — never file:// or app-internal schemes. 359 + if (/^(https?|mailto):/i.test(url)) { 360 + await shell.openExternal(url); 361 + } 362 + }, 363 + ); 354 364 355 365 ipcMain.handle( 356 366 'context-menu:show',
+2
src/preload.ts
··· 7 7 ipcRenderer.on('app:open-settings', listener); 8 8 return () => ipcRenderer.off('app:open-settings', listener); 9 9 }, 10 + openExternal: (url: string) => 11 + ipcRenderer.invoke('app:open-external', { url }), 10 12 startHabitatLogin: (url: string, redirectUri: string) => 11 13 ipcRenderer.invoke('auth:start-habitat-login', { redirectUri, url }), 12 14 contextMenu: {
+1
src/vite-env.d.ts
··· 5 5 textile?: { 6 6 platform: NodeJS.Platform; 7 7 onOpenSettings: (callback: () => void) => () => void; 8 + openExternal: (url: string) => Promise<void>; 8 9 startHabitatLogin: (url: string, redirectUri: string) => Promise<string>; 9 10 contextMenu: { 10 11 show: (