diff --git a/src/editors/automerge/automergeDocumentEditor.tsx b/src/editors/automerge/automergeDocumentEditor.tsx index 2f93c66..1c18c70 100644 --- a/src/editors/automerge/automergeDocumentEditor.tsx +++ b/src/editors/automerge/automergeDocumentEditor.tsx @@ -134,7 +134,7 @@ export function AutomergeDocumentEditor({ addKeymap: false, }), // Live preview: hide markdown syntax markers, apply CSS classes - livePreview, + livePreview(), // Read-only compartment readOnlyCompartment.current.of(EditorState.readOnly.of(false)), // Change listener diff --git a/src/editors/automerge/automergeEditor.css b/src/editors/automerge/automergeEditor.css index 8716fd7..f6cb13a 100644 --- a/src/editors/automerge/automergeEditor.css +++ b/src/editors/automerge/automergeEditor.css @@ -55,13 +55,16 @@ line-height: 1.5; } -/* Bold / Italic */ +/* Bold / Italic / Strikethrough */ .automerge-editor-container .cm-strong { font-weight: 700; } .automerge-editor-container .cm-em { font-style: italic; } +.automerge-editor-container .cm-strikethrough { + text-decoration: line-through; +} /* Links */ .automerge-editor-container .cm-link { @@ -166,10 +169,53 @@ outline: none; } -/* Hidden formatting markers (live preview) */ +/* + * Hidden markdown syntax (live preview). Each class uses a specific idiom; + * see hiddenClasses.ts. The idioms exist to preserve CM6 vertical cursor + * motion (ArrowUp/ArrowDown resolve the caret from pixel geometry, which + * ignores zero-geometry elements) — do not collapse a whole line's only + * visible content with `display: none`. + */ + +/* Heading prefix (`# `) — hybrid: zero WIDTH, non-zero HEIGHT so empty + * heading lines stay caret-targetable and text starts at column 0. */ .automerge-editor-container .cm-formatting-hidden { - font-size: 0; - display: inline; + display: inline-block; + width: 0; + max-width: 0; + height: 1em; + line-height: 1em; + overflow: hidden; + color: transparent; + pointer-events: none; + user-select: none; +} + +/* Inline markers (**, *, ~~, `, link brackets/paren/URL) — line always has + * visible siblings, so plain display:none is safe and also removes the + * marker from click hit-testing. */ +.automerge-editor-container .cm-inline-formatting-hidden { + display: none; +} + +/* Blockquote prefix (`> `) — zero-width inline-block sentinel keeps a caret + * landing spot when the marker is the line's first inline child. */ +.automerge-editor-container .cm-blockquote-formatting-hidden { + display: inline-block; + width: 0; + max-width: 0; + height: 0; + line-height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + user-select: none; +} + +/* Horizontal rule glyphs — whole-line hide; the line keeps natural geometry + * (so caretRangeFromPoint finds it), only the glyphs are masked. The visible + * rule is painted by the `.cm-hr` border. */ +.automerge-editor-container .cm-hr-line-hidden { color: transparent; pointer-events: none; user-select: none; diff --git a/src/editors/automerge/livePreview.ts b/src/editors/automerge/livePreview.ts deleted file mode 100644 index 15aaeaf..0000000 --- a/src/editors/automerge/livePreview.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Simplified live-preview CM6 extension for textile. - * Hides markdown syntax markers and applies CSS classes for: - * - Headings (hide hash prefix, apply heading classes) - * - Bold/Italic (hide star/underscore markers, apply classes) - * - Inline code (hide backtick markers, apply class) - * - Links (hide bracket/paren syntax, show rendered link) - */ -import { syntaxTree } from '@codemirror/language'; -import { RangeSetBuilder } from '@codemirror/state'; -import { - Decoration, - type DecorationSet, - EditorView, - ViewPlugin, - type ViewUpdate, -} from '@codemirror/view'; - -const FORMATTING_HIDDEN = 'cm-formatting-hidden'; - -const hiddenMark = Decoration.mark({ class: FORMATTING_HIDDEN }); -const HEADING_LINE_DECOS = [ - Decoration.line({ class: 'cm-heading cm-heading-1' }), - Decoration.line({ class: 'cm-heading cm-heading-2' }), - Decoration.line({ class: 'cm-heading cm-heading-3' }), - Decoration.line({ class: 'cm-heading cm-heading-4' }), - Decoration.line({ class: 'cm-heading cm-heading-5' }), - Decoration.line({ class: 'cm-heading cm-heading-6' }), -]; - -function buildDecorations(view: EditorView): DecorationSet { - const builder = new RangeSetBuilder(); - const cursorLine = view.state.doc.lineAt( - view.state.selection.main.head, - ).number; - const decos: Array<{ from: number; to: number; deco: Decoration }> = []; - - const ranges = - view.visibleRanges.length > 0 - ? view.visibleRanges - : [{ from: 0, to: view.state.doc.length }]; - - for (const { from: rangeFrom, to: rangeTo } of ranges) { - syntaxTree(view.state).iterate({ - from: rangeFrom, - to: rangeTo, - enter(node) { - // Headings - const headingMatch = /^ATXHeading(\d)$/.exec(node.name); - if (headingMatch) { - const level = parseInt(headingMatch[1], 10); - const line = view.state.doc.lineAt(node.from); - decos.push({ - from: line.from, - to: line.from, - deco: HEADING_LINE_DECOS[level - 1], - }); - if (line.number !== cursorLine) { - const headerMark = node.node.getChild('HeaderMark'); - if (headerMark) { - const hideEnd = Math.min(headerMark.to + 1, line.to); - decos.push({ - from: headerMark.from, - to: hideEnd, - deco: hiddenMark, - }); - } - } - return; - } - - // Bold emphasis - if (node.name === 'StrongEmphasis') { - const openDelim = node.node.getChild('EmphasisMark'); - if (openDelim) { - decos.push({ - from: openDelim.from, - to: openDelim.to, - deco: hiddenMark, - }); - } - // Find closing delimiter - const children = node.node.getChildren('EmphasisMark'); - if (children.length >= 2) { - const lastMark = children[children.length - 1]; - decos.push({ - from: lastMark.from, - to: lastMark.to, - deco: hiddenMark, - }); - } - return; - } - - // Italic emphasis - if (node.name === 'Emphasis') { - const openDelim = node.node.getChild('EmphasisMark'); - if (openDelim) { - decos.push({ - from: openDelim.from, - to: openDelim.to, - deco: hiddenMark, - }); - } - const children = node.node.getChildren('EmphasisMark'); - if (children.length >= 2) { - const lastMark = children[children.length - 1]; - decos.push({ - from: lastMark.from, - to: lastMark.to, - deco: hiddenMark, - }); - } - return; - } - - // Inline code - if (node.name === 'InlineCode') { - const openDelim = node.node.getChild('CodeMark'); - if (openDelim) { - decos.push({ - from: openDelim.from, - to: openDelim.to, - deco: hiddenMark, - }); - } - const children = node.node.getChildren('CodeMark'); - if (children.length >= 2) { - const lastMark = children[children.length - 1]; - decos.push({ - from: lastMark.from, - to: lastMark.to, - deco: hiddenMark, - }); - } - return; - } - - // Links - hide the brackets and parens - if (node.name === 'Link') { - const linkMark = node.node.getChild('LinkMark'); - if (linkMark) { - // Hide opening [ - decos.push({ - from: linkMark.from, - to: linkMark.to, - deco: hiddenMark, - }); - } - // Find closing ] and (url) - const closeBracket = node.node.getChild('CloseBracket'); - if (closeBracket) { - decos.push({ - from: closeBracket.from, - to: closeBracket.to, - deco: hiddenMark, - }); - } - } - }, - }); - } - - decos.sort((a, b) => a.from - b.from || a.to - b.to); - for (const d of decos) { - builder.add(d.from, d.to, d.deco); - } - - return builder.finish(); -} - -export const livePreview = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = buildDecorations(view); - } - update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet || update.viewportChanged) { - this.decorations = buildDecorations(update.view); - } - } - }, - { decorations: (v) => v.decorations }, -); diff --git a/src/editors/automerge/livePreview/blockquoteDecoration.ts b/src/editors/automerge/livePreview/blockquoteDecoration.ts new file mode 100644 index 0000000..44683d7 --- /dev/null +++ b/src/editors/automerge/livePreview/blockquoteDecoration.ts @@ -0,0 +1,68 @@ +/** + * Blockquotes (`> …`). + * + * Applies a `cm-quote` line decoration to each quoted line and hides the `>` + * (plus trailing space) prefix when the cursor is not on that line. Handles a + * single level of quoting via `QuoteMark` nodes; nested blockquotes reuse the + * same line class (dedup keeps it flat) and their inner `>` markers are hidden + * independently — deeper-nesting-specific styling is a follow-up. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +import { + type BuildResult, + type Range, + DecoCollector, + cursorLineNumber, + decorationExtension, + fullRanges, +} from './context'; +import { blockquoteFormattingHiddenMark } from './hiddenClasses'; + +const quoteLine = Decoration.line({ class: 'cm-quote' }); + +export function buildBlockquoteDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const cursorLine = cursorLineNumber(state); + const out = new DecoCollector(); + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + if (node.name === 'Blockquote') { + const endPos = Math.min(node.to, state.doc.length); + const startLine = state.doc.lineAt(node.from).number; + let endLine = state.doc.lineAt(endPos).number; + if (endPos === state.doc.lineAt(endPos).from && endLine > startLine) { + endLine--; + } + for (let l = startLine; l <= endLine; l++) { + const line = state.doc.line(l); + out.push(line.from, line.from, quoteLine); + } + return; + } + + if (node.name === 'QuoteMark') { + const line = state.doc.lineAt(node.from); + if (line.number !== cursorLine) { + const hideEnd = Math.min(node.to + 1, line.to); + out.push(node.from, hideEnd, blockquoteFormattingHiddenMark); + } + } + }, + }); + } + + return out.result(); +} + +export const blockquoteDecoration = decorationExtension( + buildBlockquoteDecorations, +); diff --git a/src/editors/automerge/livePreview/codeDecoration.ts b/src/editors/automerge/livePreview/codeDecoration.ts new file mode 100644 index 0000000..45c42cb --- /dev/null +++ b/src/editors/automerge/livePreview/codeDecoration.ts @@ -0,0 +1,102 @@ +/** + * Code: inline code (`` `x` ``) and fenced code blocks (``` ``` ```). + * + * - Inline code: apply `cm-inline-code` to the content, hide the backtick + * `CodeMark`s unless the cursor is near. Uses a pure distance check (no + * same-line gate) because CommonMark code spans may cross newlines. + * - Fenced code: apply a `cm-codeblock` line decoration to every line of the + * block, and hide the opening/closing fence rows unless the cursor is on any + * line within the block. The language label (`CodeInfo`) is left visible. + * + * Hidden marker ranges are registered as atomic. Line decorations are not. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +import { + type BuildResult, + type Range, + PROXIMITY_CHARS, + DecoCollector, + fullRanges, + isCursorInRange, + isCursorOnBlockLine, + decorationExtension, +} from './context'; +import { inlineFormattingHiddenMark } from './hiddenClasses'; + +const inlineCodeMark = Decoration.mark({ class: 'cm-inline-code' }); +const codeBlockLine = Decoration.line({ class: 'cm-codeblock' }); + +export function buildCodeDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const out = new DecoCollector(); + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + if (node.name === 'InlineCode') { + const near = isCursorInRange( + state, + node.from, + node.to, + PROXIMITY_CHARS, + ); + const first = node.node.firstChild; + const last = node.node.lastChild; + // Content class spans between the backticks (or the whole node if + // markers are missing on malformed input). + const contentFrom = first?.name === 'CodeMark' ? first.to : node.from; + const contentTo = last?.name === 'CodeMark' ? last.from : node.to; + if (contentTo > contentFrom) { + out.push(contentFrom, contentTo, inlineCodeMark); + } + if (!near) { + if (first?.name === 'CodeMark') { + out.push(first.from, first.to, inlineFormattingHiddenMark, true); + } + if (last?.name === 'CodeMark') { + out.push(last.from, last.to, inlineFormattingHiddenMark, true); + } + } + return false; // don't descend into code text + } + + if (node.name === 'FencedCode') { + const endPos = Math.min(node.to, state.doc.length); + const startLine = state.doc.lineAt(node.from).number; + let endLine = state.doc.lineAt(endPos).number; + // Trailing newline: don't paint the empty line after the block. + if (endPos === state.doc.lineAt(endPos).from && endLine > startLine) { + endLine--; + } + for (let l = startLine; l <= endLine; l++) { + const line = state.doc.line(l); + out.push(line.from, line.from, codeBlockLine); + } + + if (!isCursorOnBlockLine(state, node.from, node.to)) { + const first = node.node.firstChild; + const last = node.node.lastChild; + if (first?.name === 'CodeMark') { + out.push(first.from, first.to, inlineFormattingHiddenMark, true); + } + if (last?.name === 'CodeMark') { + out.push(last.from, last.to, inlineFormattingHiddenMark, true); + } + } + return false; + } + }, + }); + } + + return out.result(); +} + +export const codeDecoration = decorationExtension(buildCodeDecorations); diff --git a/src/editors/automerge/livePreview/context.ts b/src/editors/automerge/livePreview/context.ts new file mode 100644 index 0000000..c0c9342 --- /dev/null +++ b/src/editors/automerge/livePreview/context.ts @@ -0,0 +1,196 @@ +/** + * Shared helpers for the split live-preview decoration extensions. + * + * Each per-node file exports a *pure builder* over `EditorState` (so it can be + * unit-tested headlessly, with no DOM / EditorView) plus an `Extension` built + * with `decorationExtension`, which wraps the builder in a `ViewPlugin` and, + * when the builder returns an `atomic` set, registers those ranges via + * `EditorView.atomicRanges` so cursor/click motion skips zero-geometry hidden + * markers. + * + * NOTE (Fallback A / tables): when the GFM table grid lands, every builder + * that emits a `Decoration.mark`/`Decoration.replace` must first skip nodes + * inside a `TableCell` (an `isInsideTableCell` guard) so it doesn't split the + * one-cell-equals-one-flex-item wrapper. Tables are out of scope here, so no + * guard is wired yet — see the reams reference `markdown-context.ts`. + */ +import { + type EditorState, + RangeSetBuilder, + type Extension, +} from '@codemirror/state'; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, +} from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +/** How many characters away from an inline span still reveals its markers. */ +export const PROXIMITY_CHARS = 2; + +export interface Range { + from: number; + to: number; +} + +export interface DecoRange { + from: number; + to: number; + deco: Decoration; +} + +export interface BuildResult { + /** Everything the plugin paints. */ + decorations: DecorationSet; + /** Subset registered with `EditorView.atomicRanges` (hidden markers). */ + atomic?: DecorationSet; +} + +export type DecoBuilder = ( + state: EditorState, + ranges?: readonly Range[], +) => BuildResult; + +/** Line number (1-based) the primary cursor sits on. */ +export function cursorLineNumber(state: EditorState): number { + return state.doc.lineAt(state.selection.main.head).number; +} + +/** The whole document as a single range — the headless/test default. */ +export function fullRanges(state: EditorState): Range[] { + return [{ from: 0, to: state.doc.length }]; +} + +/** + * The view's visible ranges, or the full document when empty + * (headless render / tests, where `visibleRanges` is `[]`). + */ +export function iterRanges(view: EditorView): readonly Range[] { + return view.visibleRanges.length > 0 + ? view.visibleRanges + : fullRanges(view.state); +} + +/** + * True iff the cursor is on the SAME LINE as the span [from, to] AND within + * `slack` chars of its boundaries. Same-line gating stops proximity-reveal + * from leaking onto neighbouring lines. Correct for bold/italic/strike/links, + * which cannot cross newlines per the @lezer/markdown grammar. Inline code is + * the exception (it may span newlines) — use `isCursorInRange` there. + */ +export function isCursorNearSameLine( + state: EditorState, + from: number, + to: number, + slack: number = PROXIMITY_CHARS, +): boolean { + const cursor = state.selection.main.head; + if (cursor < from - slack || cursor > to + slack) return false; + return state.doc.lineAt(cursor).number === state.doc.lineAt(from).number; +} + +/** Pure distance check (no same-line gate) — for spans that may cross lines. */ +export function isCursorInRange( + state: EditorState, + from: number, + to: number, + slack = 0, +): boolean { + const cursor = state.selection.main.head; + return cursor >= from - slack && cursor <= to + slack; +} + +/** True if the cursor's line falls anywhere within [blockFrom, blockTo]. */ +export function isCursorOnBlockLine( + state: EditorState, + blockFrom: number, + blockTo: number, +): boolean { + const cursorLine = state.doc.lineAt(state.selection.main.head).number; + const startLine = state.doc.lineAt(blockFrom).number; + const endLine = state.doc.lineAt(Math.min(blockTo, state.doc.length)).number; + return cursorLine >= startLine && cursorLine <= endLine; +} + +/** + * Sort a decoration list (`from` asc, then `to` asc — puts zero-length line + * decorations before marks at the same offset) and pour it into a + * `RangeSetBuilder`, whose `add` requires monotonic input. + */ +export function finishDecos(decos: DecoRange[]): DecorationSet { + decos.sort((a, b) => a.from - b.from || a.to - b.to); + const builder = new RangeSetBuilder(); + for (const d of decos) builder.add(d.from, d.to, d.deco); + return builder.finish(); +} + +/** + * Accumulator that dedupes decorations by `from:to:class` (overlapping visible + * ranges can enter the same node twice) and tracks the atomic subset. + */ +export class DecoCollector { + private readonly seen = new Set(); + readonly all: DecoRange[] = []; + readonly atomic: DecoRange[] = []; + + push(from: number, to: number, deco: Decoration, isAtomic = false): void { + // `Decoration.spec` is typed `any` upstream, so direct access is fine. + const cls: string = deco.spec?.class ?? ''; + const key = `${from}:${to}:${cls}`; + if (this.seen.has(key)) return; + this.seen.add(key); + this.all.push({ from, to, deco }); + if (isAtomic) this.atomic.push({ from, to, deco }); + } + + result(): BuildResult { + return { + decorations: finishDecos(this.all), + atomic: finishDecos(this.atomic), + }; + } +} + +/** Rebuild triggers: doc/selection/viewport changes plus incremental parses. */ +export function shouldRebuild(update: ViewUpdate): boolean { + return ( + update.docChanged || + update.selectionSet || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ); +} + +/** + * Wrap a pure builder in a ViewPlugin + optional atomicRanges provider. + */ +export function decorationExtension(build: DecoBuilder): Extension { + const plugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + atomic: DecorationSet; + constructor(view: EditorView) { + const r = build(view.state, iterRanges(view)); + this.decorations = r.decorations; + this.atomic = r.atomic ?? Decoration.none; + } + update(update: ViewUpdate) { + if (!shouldRebuild(update)) return; + const r = build(update.view.state, iterRanges(update.view)); + this.decorations = r.decorations; + this.atomic = r.atomic ?? Decoration.none; + } + }, + { decorations: (v) => v.decorations }, + ); + + return [ + plugin, + EditorView.atomicRanges.of( + (view) => view.plugin(plugin)?.atomic ?? Decoration.none, + ), + ]; +} diff --git a/src/editors/automerge/livePreview/headingDecoration.ts b/src/editors/automerge/livePreview/headingDecoration.ts new file mode 100644 index 0000000..d31c52d --- /dev/null +++ b/src/editors/automerge/livePreview/headingDecoration.ts @@ -0,0 +1,66 @@ +/** + * ATX headings (`# ` … `###### `). + * + * Applies a `cm-heading cm-heading-{level}` line decoration and hides the + * `HeaderMark` prefix (the `#`s + trailing space) when the cursor is not on + * the heading line. Setext headings (`===`/`---` underlines) are not handled. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +import { + type BuildResult, + type DecoRange, + type Range, + cursorLineNumber, + decorationExtension, + finishDecos, + fullRanges, +} from './context'; +import { headingPrefixHiddenMark } from './hiddenClasses'; + +const HEADING_LINE_DECOS = [1, 2, 3, 4, 5, 6].map((level) => + Decoration.line({ class: `cm-heading cm-heading-${level}` }), +); + +export function buildHeadingDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const cursorLine = cursorLineNumber(state); + const decos: DecoRange[] = []; + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + const match = /^ATXHeading(\d)$/.exec(node.name); + if (!match) return; + const level = parseInt(match[1], 10); + const line = state.doc.lineAt(node.from); + decos.push({ + from: line.from, + to: line.from, + deco: HEADING_LINE_DECOS[level - 1], + }); + if (line.number !== cursorLine) { + const headerMark = node.node.getChild('HeaderMark'); + if (headerMark) { + const hideEnd = Math.min(headerMark.to + 1, line.to); + decos.push({ + from: headerMark.from, + to: hideEnd, + deco: headingPrefixHiddenMark, + }); + } + } + }, + }); + } + + return { decorations: finishDecos(decos) }; +} + +export const headingDecoration = decorationExtension(buildHeadingDecorations); diff --git a/src/editors/automerge/livePreview/hiddenClasses.ts b/src/editors/automerge/livePreview/hiddenClasses.ts new file mode 100644 index 0000000..2cb88c8 --- /dev/null +++ b/src/editors/automerge/livePreview/hiddenClasses.ts @@ -0,0 +1,51 @@ +/** + * Single source of truth for the CSS classes that HIDE markdown syntax. + * + * The CSS shape of each class is load-bearing — a naive `display:none` on a + * marker that is the ONLY visible content of a line breaks CM6 vertical cursor + * motion (ArrowUp/ArrowDown resolve caret position from pixel geometry via + * `caretRangeFromPoint`, which ignores zero-geometry elements). Each class + * below is paired with a specific idiom in `automergeEditor.css`; see the + * comments there. Ported from the reams reference `hidden-text-classes.ts`. + */ +import { Decoration } from '@codemirror/view'; + +/** + * HEADING PREFIX (`# `) — hybrid idiom. Zero visible WIDTH (heading text + * starts at column 0) but non-zero HEIGHT (an empty `# ` line stays + * caret-targetable). Kept as the historical class name for continuity. + */ +export const HEADING_PREFIX_HIDDEN = 'cm-formatting-hidden'; + +/** + * INLINE markers (`**`, `*`, `~~`, `` ` ``, link brackets/paren/URL) — the + * line always has visible text on at least one side, so plain `display:none` + * is safe and also removes the marker from click hit-testing. Owning + * extensions additionally register these ranges via `EditorView.atomicRanges`. + */ +export const INLINE_FORMATTING_HIDDEN = 'cm-inline-formatting-hidden'; + +/** + * BLOCKQUOTE prefix (`> `) — appears at line start and may be the line's first + * inline child, so it uses a zero-width inline-block sentinel (keeps a caret + * landing spot) rather than `display:none`. + */ +export const BLOCKQUOTE_FORMATTING_HIDDEN = 'cm-blockquote-formatting-hidden'; + +/** + * HORIZONTAL RULE text (`---`) — whole-line hide. The line must keep natural + * geometry (so `caretRangeFromPoint` finds it); CSS only masks the glyphs + * (`color: transparent`). The visible rule is painted by the `.cm-hr` border. + */ +export const HR_LINE_HIDDEN = 'cm-hr-line-hidden'; + +export const headingPrefixHiddenMark = Decoration.mark({ + class: HEADING_PREFIX_HIDDEN, +}); +export const inlineFormattingHiddenMark = Decoration.mark({ + class: INLINE_FORMATTING_HIDDEN, +}); +export const blockquoteFormattingHiddenMark = Decoration.mark({ + class: BLOCKQUOTE_FORMATTING_HIDDEN, +}); +export const hrLineHiddenMark = Decoration.mark({ class: HR_LINE_HIDDEN }); diff --git a/src/editors/automerge/livePreview/hrDecoration.ts b/src/editors/automerge/livePreview/hrDecoration.ts new file mode 100644 index 0000000..dca4157 --- /dev/null +++ b/src/editors/automerge/livePreview/hrDecoration.ts @@ -0,0 +1,53 @@ +/** + * Horizontal rules (`---`, `***`, `___`). + * + * Applies a `cm-hr` line decoration (CSS paints the rule via a border) and + * hides the raw glyphs unless the cursor is on the rule's line. Lezer emits + * `HorizontalRule` only for true rules — setext heading underlines parse as + * `SetextHeading*`, so no disambiguation is needed. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +import { + type BuildResult, + type DecoRange, + type Range, + cursorLineNumber, + decorationExtension, + finishDecos, + fullRanges, +} from './context'; +import { hrLineHiddenMark } from './hiddenClasses'; + +const hrLine = Decoration.line({ class: 'cm-hr' }); + +export function buildHrDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const cursorLine = cursorLineNumber(state); + const decos: DecoRange[] = []; + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + if (node.name !== 'HorizontalRule') return; + const line = state.doc.lineAt(node.from); + decos.push({ from: line.from, to: line.from, deco: hrLine }); + // Hide the whole line's text (including any leading whitespace) so + // the border alone reads as the rule. + if (line.number !== cursorLine && line.to > line.from) { + decos.push({ from: line.from, to: line.to, deco: hrLineHiddenMark }); + } + }, + }); + } + + return { decorations: finishDecos(decos) }; +} + +export const hrDecoration = decorationExtension(buildHrDecorations); diff --git a/src/editors/automerge/livePreview/index.ts b/src/editors/automerge/livePreview/index.ts new file mode 100644 index 0000000..827a8c7 --- /dev/null +++ b/src/editors/automerge/livePreview/index.ts @@ -0,0 +1,60 @@ +/** + * Composite markdown live-preview for the Automerge editor. + * + * One extension per node concern (mirrors the reams reference structure), + * composed into a single `Extension`. Also installs the cmd/ctrl-click handler + * that opens links externally. + * + * Ordering note: these must be registered AFTER the `markdown(...)` language + * extension so `syntaxTree(state)` is populated when the builders run. + */ +import { type Extension } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +import { headingDecoration } from './headingDecoration'; +import { inlineFormatDecoration } from './inlineFormatDecoration'; +import { codeDecoration } from './codeDecoration'; +import { linkDecoration, linkUrlAt } from './linkDecoration'; +import { blockquoteDecoration } from './blockquoteDecoration'; +import { hrDecoration } from './hrDecoration'; +import { listDecoration } from './listDecoration'; + +/** Open a URL in the system browser, via the Electron bridge when present. */ +function openExternal(url: string): void { + const api = window.textile?.openExternal; + if (api) { + void api(url); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } +} + +/** + * cmd/ctrl-click a rendered link to open it; a plain click still positions the + * cursor (revealing the raw markdown for editing via proximity). + */ +const linkClickHandler = EditorView.domEventHandlers({ + mousedown(event, view) { + if (!(event.metaKey || event.ctrlKey)) return false; + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos == null) return false; + const url = linkUrlAt(view.state, pos); + if (!url) return false; + event.preventDefault(); + openExternal(url); + return true; + }, +}); + +export function livePreview(): Extension { + return [ + headingDecoration, + inlineFormatDecoration, + codeDecoration, + linkDecoration, + blockquoteDecoration, + hrDecoration, + listDecoration, + linkClickHandler, + ]; +} diff --git a/src/editors/automerge/livePreview/inlineFormatDecoration.ts b/src/editors/automerge/livePreview/inlineFormatDecoration.ts new file mode 100644 index 0000000..c327cb6 --- /dev/null +++ b/src/editors/automerge/livePreview/inlineFormatDecoration.ts @@ -0,0 +1,73 @@ +/** + * Inline emphasis: bold (`**`/`__`), italic (`*`/`_`), strikethrough (`~~`). + * + * Applies a content class to the span and hides the surrounding markers unless + * the cursor is near (same line, within PROXIMITY_CHARS). Both `Emphasis` and + * `StrongEmphasis` use `EmphasisMark` for their markers — there is no + * `StrongEmphasisMark` in @lezer/markdown; the parent node name disambiguates. + * Hidden marker ranges are registered as atomic so cursor motion skips them. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; + +import { + type BuildResult, + type Range, + DecoCollector, + fullRanges, + isCursorNearSameLine, + decorationExtension, +} from './context'; +import { inlineFormattingHiddenMark } from './hiddenClasses'; + +const boldMark = Decoration.mark({ class: 'cm-strong' }); +const italicMark = Decoration.mark({ class: 'cm-em' }); +const strikethroughMark = Decoration.mark({ class: 'cm-strikethrough' }); + +/** Lezer container node name → content decoration. */ +const CONTENT_MARKS: Record = { + StrongEmphasis: boldMark, + Emphasis: italicMark, + Strikethrough: strikethroughMark, +}; +const CONTAINER_NAMES = new Set(Object.keys(CONTENT_MARKS)); + +export function buildInlineFormatDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const out = new DecoCollector(); + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + const contentMark = CONTENT_MARKS[node.name]; + if (contentMark) { + out.push(node.from, node.to, contentMark); + return; // keep descending to reach the markers + } + + if (node.name === 'EmphasisMark' || node.name === 'StrikethroughMark') { + // Widen the proximity window to the whole formatted span when the + // parent is a known container; otherwise fall back to the marker. + const parent = node.node.parent; + const scoped = parent && CONTAINER_NAMES.has(parent.name); + const checkFrom = scoped ? parent.from : node.from; + const checkTo = scoped ? parent.to : node.to; + if (!isCursorNearSameLine(state, checkFrom, checkTo)) { + out.push(node.from, node.to, inlineFormattingHiddenMark, true); + } + } + }, + }); + } + + return out.result(); +} + +export const inlineFormatDecoration = decorationExtension( + buildInlineFormatDecorations, +); diff --git a/src/editors/automerge/livePreview/linkDecoration.ts b/src/editors/automerge/livePreview/linkDecoration.ts new file mode 100644 index 0000000..a3cfc97 --- /dev/null +++ b/src/editors/automerge/livePreview/linkDecoration.ts @@ -0,0 +1,91 @@ +/** + * Inline links: `[label](url)`. + * + * Styles the label `cm-link` and hides the brackets, parens, URL and optional + * title unless the cursor is near (same line). `linkUrlAt` resolves the URL of + * the link enclosing a document offset — used by the cmd/ctrl-click handler in + * `index.ts` to open links externally. Autolinks and images are left raw. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import type { SyntaxNode } from '@lezer/common'; + +import { + type BuildResult, + type Range, + DecoCollector, + fullRanges, + isCursorNearSameLine, + decorationExtension, +} from './context'; +import { inlineFormattingHiddenMark } from './hiddenClasses'; + +const linkLabelMark = Decoration.mark({ class: 'cm-link' }); + +/** Children of a Link node, grouped by role. */ +function linkParts(node: SyntaxNode): { + marks: SyntaxNode[]; + url: SyntaxNode | null; + title: SyntaxNode | null; +} { + const marks: SyntaxNode[] = []; + let url: SyntaxNode | null = null; + let title: SyntaxNode | null = null; + for (let c = node.firstChild; c; c = c.nextSibling) { + if (c.name === 'LinkMark') marks.push(c); + else if (c.name === 'URL') url = c; + else if (c.name === 'LinkTitle') title = c; + } + return { marks, url, title }; +} + +export function buildLinkDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const out = new DecoCollector(); + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + if (node.name !== 'Link') return; + const { marks, url, title } = linkParts(node.node); + + // Style the label (between the first `[` and its matching `]`). + if (marks.length >= 2 && marks[1].from > marks[0].to) { + out.push(marks[0].to, marks[1].from, linkLabelMark); + } + + if (isCursorNearSameLine(state, node.from, node.to)) { + return; // reveal full source for editing + } + + for (const m of marks) { + out.push(m.from, m.to, inlineFormattingHiddenMark, true); + } + if (url) out.push(url.from, url.to, inlineFormattingHiddenMark, true); + if (title) { + out.push(title.from, title.to, inlineFormattingHiddenMark, true); + } + }, + }); + } + + return out.result(); +} + +/** URL of the Link enclosing `pos`, or null. */ +export function linkUrlAt(state: EditorState, pos: number): string | null { + let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); + while (node && node.name !== 'Link') node = node.parent; + if (!node) return null; + const { url } = linkParts(node); + if (!url) return null; + const text = state.doc.sliceString(url.from, url.to).trim(); + return text.length > 0 ? text : null; +} + +export const linkDecoration = decorationExtension(buildLinkDecorations); diff --git a/src/editors/automerge/livePreview/listDecoration.ts b/src/editors/automerge/livePreview/listDecoration.ts new file mode 100644 index 0000000..63dd3b9 --- /dev/null +++ b/src/editors/automerge/livePreview/listDecoration.ts @@ -0,0 +1,58 @@ +/** + * Lists (bullet and ordered). + * + * Applies a `cm-list-{depth}` line decoration to each list item's marker line + * (depth 1–3 by nesting), which the CSS turns into progressive indentation. + * List markers themselves are left as literal source (no widget) — bullet + * glyph rendering and task-list checkboxes are a later, widget-based phase, so + * `TaskMarker` is deliberately untouched here. + */ +import { type EditorState } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import type { SyntaxNode } from '@lezer/common'; + +import { + type BuildResult, + type Range, + DecoCollector, + decorationExtension, + fullRanges, +} from './context'; + +const LIST_LINE_DECOS = [1, 2, 3].map((depth) => + Decoration.line({ class: `cm-list-${depth}` }), +); + +/** Number of BulletList/OrderedList ancestors (nesting depth), min 1. */ +function listDepth(node: SyntaxNode): number { + let depth = 0; + for (let p = node.parent; p; p = p.parent) { + if (p.name === 'BulletList' || p.name === 'OrderedList') depth++; + } + return Math.max(1, depth); +} + +export function buildListDecorations( + state: EditorState, + ranges: readonly Range[] = fullRanges(state), +): BuildResult { + const out = new DecoCollector(); + + for (const { from, to } of ranges) { + syntaxTree(state).iterate({ + from, + to, + enter(node) { + if (node.name !== 'ListMark') return; + const depth = Math.min(listDepth(node.node), LIST_LINE_DECOS.length); + const line = state.doc.lineAt(node.from); + out.push(line.from, line.from, LIST_LINE_DECOS[depth - 1]); + }, + }); + } + + return out.result(); +} + +export const listDecoration = decorationExtension(buildListDecorations); diff --git a/src/editors/automerge/livePreview/livePreview.test.ts b/src/editors/automerge/livePreview/livePreview.test.ts new file mode 100644 index 0000000..3b100d8 --- /dev/null +++ b/src/editors/automerge/livePreview/livePreview.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { type DecorationSet } from '@codemirror/view'; +import { ensureSyntaxTree } from '@codemirror/language'; +import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; +import { GFM } from '@lezer/markdown'; + +import { buildHeadingDecorations } from './headingDecoration'; +import { buildInlineFormatDecorations } from './inlineFormatDecoration'; +import { buildCodeDecorations } from './codeDecoration'; +import { buildLinkDecorations, linkUrlAt } from './linkDecoration'; +import { buildBlockquoteDecorations } from './blockquoteDecoration'; +import { buildHrDecorations } from './hrDecoration'; +import { buildListDecorations } from './listDecoration'; +import { type BuildResult } from './context'; + +interface FlatDeco { + from: number; + to: number; + cls: string; + text: string; +} + +function makeState(doc: string, cursor = 0): EditorState { + const state = EditorState.create({ + doc, + selection: { anchor: cursor }, + extensions: [markdown({ base: markdownLanguage, extensions: [GFM] })], + }); + // Force a complete parse so syntaxTree(state) is populated headlessly. + ensureSyntaxTree(state, doc.length, 5000); + return state; +} + +function collect(state: EditorState, set: DecorationSet): FlatDeco[] { + const out: FlatDeco[] = []; + const iter = set.iter(); + while (iter.value) { + out.push({ + from: iter.from, + to: iter.to, + // spec is typed `any` upstream. + cls: (iter.value.spec as { class?: string }).class ?? '', + text: state.doc.sliceString(iter.from, iter.to), + }); + iter.next(); + } + return out; +} + +const decos = (state: EditorState, r: BuildResult) => + collect(state, r.decorations); +const atomic = (state: EditorState, r: BuildResult) => + collect( + state, + r.atomic ?? + (r.decorations.update({ filter: () => false }) as DecorationSet), + ); +const withClass = (list: FlatDeco[], cls: string) => + list.filter((d) => d.cls.split(/\s+/).includes(cls)); + +describe('headingDecoration', () => { + it('styles the line and hides the prefix when cursor is elsewhere', () => { + const state = makeState('# Title\n\nbody', 11); // cursor in "body" + const list = decos(state, buildHeadingDecorations(state)); + expect(withClass(list, 'cm-heading-1')).toHaveLength(1); + const hidden = withClass(list, 'cm-formatting-hidden'); + expect(hidden).toHaveLength(1); + expect(hidden[0].text).toBe('# '); + }); + + it('reveals the prefix when the cursor is on the heading line', () => { + const state = makeState('# Title\n\nbody', 3); // cursor in heading + const list = decos(state, buildHeadingDecorations(state)); + expect(withClass(list, 'cm-heading-1')).toHaveLength(1); + expect(withClass(list, 'cm-formatting-hidden')).toHaveLength(0); + }); + + it('emits the right level class', () => { + const state = makeState('### Three\n\nx', 11); + const list = decos(state, buildHeadingDecorations(state)); + expect(withClass(list, 'cm-heading-3')).toHaveLength(1); + }); +}); + +describe('inlineFormatDecoration', () => { + it('styles bold content and hides markers when cursor is far', () => { + const state = makeState('hello world **bold** end', 0); + const result = buildInlineFormatDecorations(state); + const list = decos(state, result); + // Content mark spans the whole StrongEmphasis node (markers are hidden + // separately on top of it). + const strong = withClass(list, 'cm-strong'); + expect(strong).toHaveLength(1); + expect(strong[0].text).toBe('**bold**'); + const hidden = withClass(list, 'cm-inline-formatting-hidden'); + expect(hidden.map((h) => h.text)).toEqual(['**', '**']); + // Hidden markers are registered atomic. + expect( + withClass(atomic(state, result), 'cm-inline-formatting-hidden'), + ).toHaveLength(2); + }); + + it('reveals markers when the cursor is inside the span', () => { + const state = makeState('hello world **bold** end', 16); // inside "bold" + const list = decos(state, buildInlineFormatDecorations(state)); + expect(withClass(list, 'cm-strong')).toHaveLength(1); + expect(withClass(list, 'cm-inline-formatting-hidden')).toHaveLength(0); + }); + + it('handles italic and strikethrough', () => { + const italic = makeState('hello world *it* end', 0); + expect( + withClass(decos(italic, buildInlineFormatDecorations(italic)), 'cm-em'), + ).toHaveLength(1); + + const strike = makeState('hello world ~~no~~ end', 0); + const strikeList = decos(strike, buildInlineFormatDecorations(strike)); + const st = withClass(strikeList, 'cm-strikethrough'); + expect(st).toHaveLength(1); + expect(st[0].text).toBe('~~no~~'); + }); +}); + +describe('codeDecoration', () => { + it('styles inline code and hides backticks when far', () => { + const state = makeState('hello world `code` end', 0); + const list = decos(state, buildCodeDecorations(state)); + const code = withClass(list, 'cm-inline-code'); + expect(code).toHaveLength(1); + expect(code[0].text).toBe('code'); + expect( + withClass(list, 'cm-inline-formatting-hidden').map((h) => h.text), + ).toEqual(['`', '`']); + }); + + it('paints codeblock lines and hides fences when cursor is outside', () => { + const state = makeState('```js\ncode\n```\n\nafter', 18); // in "after" + const list = decos(state, buildCodeDecorations(state)); + expect(withClass(list, 'cm-codeblock').length).toBeGreaterThanOrEqual(3); + // Opening and closing fence rows hidden. + expect( + withClass(list, 'cm-inline-formatting-hidden').length, + ).toBeGreaterThanOrEqual(2); + }); + + it('reveals fences when cursor is inside the block', () => { + const state = makeState('```js\ncode\n```\n\nafter', 7); // inside "code" + const list = decos(state, buildCodeDecorations(state)); + expect(withClass(list, 'cm-codeblock').length).toBeGreaterThanOrEqual(3); + expect(withClass(list, 'cm-inline-formatting-hidden')).toHaveLength(0); + }); +}); + +describe('linkDecoration', () => { + it('styles the label and hides the target when far', () => { + const state = makeState('see [a](http://x) end', 0); + const list = decos(state, buildLinkDecorations(state)); + const label = withClass(list, 'cm-link'); + expect(label).toHaveLength(1); + expect(label[0].text).toBe('a'); + const hidden = withClass(list, 'cm-inline-formatting-hidden').map( + (h) => h.text, + ); + expect(hidden).toContain('http://x'); + expect(hidden).toContain('['); + expect(hidden).toContain(']'); + }); + + it('resolves the URL at a position', () => { + const state = makeState('see [a](http://x) end', 0); + expect(linkUrlAt(state, 5)).toBe('http://x'); // inside "a" + expect(linkUrlAt(state, 0)).toBeNull(); // in "see " + }); +}); + +describe('blockquoteDecoration', () => { + it('styles quote lines and hides the > prefix when cursor is elsewhere', () => { + const state = makeState('> quote\n\nbody', 11); // in "body" + const list = decos(state, buildBlockquoteDecorations(state)); + expect(withClass(list, 'cm-quote').length).toBeGreaterThanOrEqual(1); + const hidden = withClass(list, 'cm-blockquote-formatting-hidden'); + expect(hidden).toHaveLength(1); + expect(hidden[0].text).toBe('> '); + }); +}); + +describe('hrDecoration', () => { + it('styles the rule line and hides glyphs when cursor is elsewhere', () => { + const state = makeState('para\n\n---\n\nmore', 0); // in "para" + const list = decos(state, buildHrDecorations(state)); + expect(withClass(list, 'cm-hr')).toHaveLength(1); + const hidden = withClass(list, 'cm-hr-line-hidden'); + expect(hidden).toHaveLength(1); + expect(hidden[0].text).toBe('---'); + }); +}); + +describe('listDecoration', () => { + it('indents by nesting depth', () => { + const state = makeState('- one\n- two', 0); + const list = decos(state, buildListDecorations(state)); + expect(withClass(list, 'cm-list-1')).toHaveLength(2); + }); + + it('applies a deeper class to nested items', () => { + const state = makeState('- a\n - b', 0); + const list = decos(state, buildListDecorations(state)); + expect(withClass(list, 'cm-list-1')).toHaveLength(1); + expect(withClass(list, 'cm-list-2')).toHaveLength(1); + }); +}); diff --git a/src/main.ts b/src/main.ts index 0425f5e..9071129 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, ipcMain, dialog } from 'electron'; +import { app, BrowserWindow, Menu, ipcMain, dialog, shell } from 'electron'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; @@ -352,6 +352,16 @@ ipcMain.handle('vault:copy-file', async (_event, { srcPath, destPath }) => { await fs.copyFile(expandPath(srcPath), expandPath(destPath)); }); +ipcMain.handle( + 'app:open-external', + async (_event, { url }: { url: string }) => { + // Only follow web/mail links — never file:// or app-internal schemes. + if (/^(https?|mailto):/i.test(url)) { + await shell.openExternal(url); + } + }, +); + ipcMain.handle( 'context-menu:show', async ( diff --git a/src/preload.ts b/src/preload.ts index 6898f49..3ed6947 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -7,6 +7,8 @@ contextBridge.exposeInMainWorld('textile', { ipcRenderer.on('app:open-settings', listener); return () => ipcRenderer.off('app:open-settings', listener); }, + openExternal: (url: string) => + ipcRenderer.invoke('app:open-external', { url }), startHabitatLogin: (url: string, redirectUri: string) => ipcRenderer.invoke('auth:start-habitat-login', { redirectUri, url }), contextMenu: { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index eb6106a..7a8948e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ declare global { textile?: { platform: NodeJS.Platform; onOpenSettings: (callback: () => void) => () => void; + openExternal: (url: string) => Promise; startHabitatLogin: (url: string, redirectUri: string) => Promise; contextMenu: { show: (