diff --git a/src/editors/automerge/automergeEditor.css b/src/editors/automerge/automergeEditor.css index c7a6c3f..e7a70e6 100644 --- a/src/editors/automerge/automergeEditor.css +++ b/src/editors/automerge/automergeEditor.css @@ -121,6 +121,99 @@ border-bottom-right-radius: 6px; } +/* + * Syntax colors for code (fenced blocks and inline code). `classHighlighter` + * from @lezer/highlight tags tokens with `tok-*` classes; without these rules + * code renders monochrome. Values are GitHub-ish; the dark set follows the + * app's `:root[data-theme='dark']` theme switch. + */ +.automerge-editor-container .tok-keyword { + color: #cf222e; +} +.automerge-editor-container .tok-controlKeyword, +.automerge-editor-container .tok-moduleKeyword { + color: #cf222e; +} +.automerge-editor-container .tok-string, +.automerge-editor-container .tok-string2, +.automerge-editor-container .tok-special { + color: #0a3069; +} +.automerge-editor-container .tok-comment, +.automerge-editor-container .tok-lineComment, +.automerge-editor-container .tok-blockComment { + color: #6e7781; + font-style: italic; +} +.automerge-editor-container .tok-number, +.automerge-editor-container .tok-bool, +.automerge-editor-container .tok-atom { + color: #0550ae; +} +.automerge-editor-container .tok-variableName { + color: #24292f; +} +.automerge-editor-container .tok-function, +.automerge-editor-container .tok-definition { + color: #8250df; +} +.automerge-editor-container .tok-typeName, +.automerge-editor-container .tok-className, +.automerge-editor-container .tok-namespace { + color: #953800; +} +.automerge-editor-container .tok-propertyName { + color: #0550ae; +} +.automerge-editor-container .tok-operator, +.automerge-editor-container .tok-punctuation, +.automerge-editor-container .tok-separator, +.automerge-editor-container .tok-bracket { + color: #57606a; +} + +:root[data-theme='dark'] .automerge-editor-container .tok-keyword, +:root[data-theme='dark'] .automerge-editor-container .tok-controlKeyword, +:root[data-theme='dark'] .automerge-editor-container .tok-moduleKeyword { + color: #ff7b72; +} +:root[data-theme='dark'] .automerge-editor-container .tok-string, +:root[data-theme='dark'] .automerge-editor-container .tok-string2, +:root[data-theme='dark'] .automerge-editor-container .tok-special { + color: #a5d6ff; +} +:root[data-theme='dark'] .automerge-editor-container .tok-comment, +:root[data-theme='dark'] .automerge-editor-container .tok-lineComment, +:root[data-theme='dark'] .automerge-editor-container .tok-blockComment { + color: #8b949e; +} +:root[data-theme='dark'] .automerge-editor-container .tok-number, +:root[data-theme='dark'] .automerge-editor-container .tok-bool, +:root[data-theme='dark'] .automerge-editor-container .tok-atom { + color: #79c0ff; +} +:root[data-theme='dark'] .automerge-editor-container .tok-variableName { + color: #e6edf3; +} +:root[data-theme='dark'] .automerge-editor-container .tok-function, +:root[data-theme='dark'] .automerge-editor-container .tok-definition { + color: #d2a8ff; +} +:root[data-theme='dark'] .automerge-editor-container .tok-typeName, +:root[data-theme='dark'] .automerge-editor-container .tok-className, +:root[data-theme='dark'] .automerge-editor-container .tok-namespace { + color: #ffa657; +} +:root[data-theme='dark'] .automerge-editor-container .tok-propertyName { + color: #79c0ff; +} +:root[data-theme='dark'] .automerge-editor-container .tok-operator, +:root[data-theme='dark'] .automerge-editor-container .tok-punctuation, +:root[data-theme='dark'] .automerge-editor-container .tok-separator, +:root[data-theme='dark'] .automerge-editor-container .tok-bracket { + color: #8b949e; +} + /* Lists */ .automerge-editor-container .cm-list-1 { padding-left: 1.5em; @@ -132,12 +225,29 @@ padding-left: 3.5em; } -/* Blockquotes */ +/* Blockquotes — the depth class carries the bar + indent so nested quotes + * (`> >`) read as progressively deeper. */ .automerge-editor-container .cm-quote { color: #656d76; + margin-left: 0; +} +.automerge-editor-container .cm-quote-1 { border-left: 3px solid #d0d7de; padding-left: 1em; - margin-left: 0; +} +.automerge-editor-container .cm-quote-2 { + border-left: 6px double #d0d7de; + padding-left: 1.5em; +} +.automerge-editor-container .cm-quote-3 { + border-left: 9px double #d0d7de; + padding-left: 2em; +} + +/* Reference-link definition line (`[ref]: url`) — muted, it is plumbing. */ +.automerge-editor-container .cm-link-def { + color: #8c959f; + font-size: 0.9em; } /* Task lists */ diff --git a/src/editors/automerge/livePreview/blockquoteDecoration.ts b/src/editors/automerge/livePreview/blockquoteDecoration.ts index 44683d7..53f9077 100644 --- a/src/editors/automerge/livePreview/blockquoteDecoration.ts +++ b/src/editors/automerge/livePreview/blockquoteDecoration.ts @@ -1,11 +1,10 @@ /** * 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. + * Applies a `cm-quote cm-quote-{depth}` line decoration to each quoted line and + * hides the `>` (plus trailing space) prefix when the cursor is not on that + * line. Nesting depth is the number of `>` markers on the line (`> >` → 2), so + * nested quotes indent/tint progressively rather than rendering flat. */ import { type EditorState } from '@codemirror/state'; import { Decoration } from '@codemirror/view'; @@ -21,7 +20,10 @@ import { } from './context'; import { blockquoteFormattingHiddenMark } from './hiddenClasses'; -const quoteLine = Decoration.line({ class: 'cm-quote' }); +const MAX_DEPTH = 3; +const quoteLines = [1, 2, 3].map((depth) => + Decoration.line({ class: `cm-quote cm-quote-${depth}` }), +); export function buildBlockquoteDecorations( state: EditorState, @@ -29,37 +31,30 @@ export function buildBlockquoteDecorations( ): BuildResult { const cursorLine = cursorLineNumber(state); const out = new DecoCollector(); + // Nesting depth per line = number of `>` markers on that line. + const depthByLine = new Map(); 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); - } + if (node.name !== 'QuoteMark') return; + const line = state.doc.lineAt(node.from); + depthByLine.set(line.number, (depthByLine.get(line.number) ?? 0) + 1); + if (line.number !== cursorLine) { + const hideEnd = Math.min(node.to + 1, line.to); + out.push(node.from, hideEnd, blockquoteFormattingHiddenMark); } }, }); } + for (const [lineNo, depth] of depthByLine) { + const line = state.doc.line(lineNo); + out.push(line.from, line.from, quoteLines[Math.min(depth, MAX_DEPTH) - 1]); + } + return out.result(); } diff --git a/src/editors/automerge/livePreview/codeDecoration.ts b/src/editors/automerge/livePreview/codeDecoration.ts index ad01186..e56c2e3 100644 --- a/src/editors/automerge/livePreview/codeDecoration.ts +++ b/src/editors/automerge/livePreview/codeDecoration.ts @@ -111,6 +111,12 @@ export function buildCodeDecorations( if (last?.name === 'CodeMark') { out.push(last.from, last.to, inlineFormattingHiddenMark, true); } + // Hide the language label too, so the opening fence line does not + // leave a stray `js` floating above the block. + const info = node.node.getChild('CodeInfo'); + if (info) { + out.push(info.from, info.to, inlineFormattingHiddenMark, true); + } } return false; } diff --git a/src/editors/automerge/livePreview/escapeDecoration.ts b/src/editors/automerge/livePreview/escapeDecoration.ts new file mode 100644 index 0000000..e6ad792 --- /dev/null +++ b/src/editors/automerge/livePreview/escapeDecoration.ts @@ -0,0 +1,54 @@ +/** + * Backslash markers: character escapes (`\*`) and the hard-line-break + * backslash (`text\` + newline). + * + * - `Escape` nodes span `\x`; we hide the leading `\` so the escaped character + * renders literally (e.g. `\*` shows a plain `*`, not italics). + * - `HardBreak` nodes cover the break; the backslash form starts with `\` + * (the two-space form is already invisible), so we hide that `\`. + * + * Both reveal when the cursor is near (same line) for editability, and the + * hidden ranges are registered atomic so cursor motion skips them. + */ +import { type EditorState } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; + +import { + type BuildResult, + type Range, + DecoCollector, + decorationExtension, + fullRanges, + isCursorNearSameLine, +} from './context'; +import { inlineFormattingHiddenMark } from './hiddenClasses'; + +export function buildEscapeDecorations( + 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 !== 'Escape' && node.name !== 'HardBreak') return; + // Only the backslash form of a hard break has a marker to hide. + if ( + node.name === 'HardBreak' && + state.doc.sliceString(node.from, node.from + 1) !== '\\' + ) { + return; + } + if (isCursorNearSameLine(state, node.from, node.to)) return; + out.push(node.from, node.from + 1, inlineFormattingHiddenMark, true); + }, + }); + } + + return out.result(); +} + +export const escapeDecoration = decorationExtension(buildEscapeDecorations); diff --git a/src/editors/automerge/livePreview/headingDecoration.ts b/src/editors/automerge/livePreview/headingDecoration.ts index d31c52d..59f50aa 100644 --- a/src/editors/automerge/livePreview/headingDecoration.ts +++ b/src/editors/automerge/livePreview/headingDecoration.ts @@ -46,15 +46,31 @@ export function buildHeadingDecorations( 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); + // ATX headings have an opening HeaderMark (`## `) and, optionally, a + // closing one (`## Title ##`). Hide both so neither shows as text. + const marks = node.node.getChildren('HeaderMark'); + const open = marks[0]; + if (open) { + // Include the space after the opening hashes. + const hideEnd = Math.min(open.to + 1, line.to); decos.push({ - from: headerMark.from, + from: open.from, to: hideEnd, deco: headingPrefixHiddenMark, }); } + if (marks.length > 1) { + const close = marks[marks.length - 1]; + // Include the whitespace preceding the closing hashes. + const before = state.doc.sliceString(line.from, close.from); + let start = before.length; + while (start > 0 && before[start - 1] === ' ') start--; + decos.push({ + from: line.from + start, + to: close.to, + deco: headingPrefixHiddenMark, + }); + } } }, }); diff --git a/src/editors/automerge/livePreview/index.ts b/src/editors/automerge/livePreview/index.ts index 827a8c7..95e69e7 100644 --- a/src/editors/automerge/livePreview/index.ts +++ b/src/editors/automerge/livePreview/index.ts @@ -18,6 +18,7 @@ import { linkDecoration, linkUrlAt } from './linkDecoration'; import { blockquoteDecoration } from './blockquoteDecoration'; import { hrDecoration } from './hrDecoration'; import { listDecoration } from './listDecoration'; +import { escapeDecoration } from './escapeDecoration'; /** Open a URL in the system browser, via the Electron bridge when present. */ function openExternal(url: string): void { @@ -55,6 +56,7 @@ export function livePreview(): Extension { blockquoteDecoration, hrDecoration, listDecoration, + escapeDecoration, linkClickHandler, ]; } diff --git a/src/editors/automerge/livePreview/linkDecoration.ts b/src/editors/automerge/livePreview/linkDecoration.ts index a3cfc97..195b748 100644 --- a/src/editors/automerge/livePreview/linkDecoration.ts +++ b/src/editors/automerge/livePreview/linkDecoration.ts @@ -1,10 +1,15 @@ /** - * Inline links: `[label](url)`. + * Inline and reference links. * - * 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. + * - Inline `[label](url)`: style the label `cm-link`, hide the brackets/parens, + * URL and optional title unless the cursor is near. + * - Reference `[label][ref]`: style the label, hide the brackets and the + * `[ref]` reference label (which has no URL child). + * - Definition lines `[ref]: url`: a muted `cm-link-def` line class. + * + * `linkUrlAt` resolves the URL of the inline link enclosing a document offset — + * used by the cmd/ctrl-click handler in `index.ts`. Autolinks and images are + * left raw. */ import { type EditorState } from '@codemirror/state'; import { Decoration } from '@codemirror/view'; @@ -22,22 +27,26 @@ import { import { inlineFormattingHiddenMark } from './hiddenClasses'; const linkLabelMark = Decoration.mark({ class: 'cm-link' }); +const linkDefLine = Decoration.line({ class: 'cm-link-def' }); /** Children of a Link node, grouped by role. */ function linkParts(node: SyntaxNode): { marks: SyntaxNode[]; + labels: SyntaxNode[]; url: SyntaxNode | null; title: SyntaxNode | null; } { const marks: SyntaxNode[] = []; + const labels: 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 === 'LinkLabel') labels.push(c); else if (c.name === 'URL') url = c; else if (c.name === 'LinkTitle') title = c; } - return { marks, url, title }; + return { marks, labels, url, title }; } export function buildLinkDecorations( @@ -51,8 +60,15 @@ export function buildLinkDecorations( from, to, enter(node) { + // Definition line `[ref]: url` — mute it; it is plumbing, not prose. + if (node.name === 'LinkReference') { + const line = state.doc.lineAt(node.from); + out.push(line.from, line.from, linkDefLine); + return; + } + if (node.name !== 'Link') return; - const { marks, url, title } = linkParts(node.node); + const { marks, labels, url, title } = linkParts(node.node); // Style the label (between the first `[` and its matching `]`). if (marks.length >= 2 && marks[1].from > marks[0].to) { @@ -66,6 +82,10 @@ export function buildLinkDecorations( for (const m of marks) { out.push(m.from, m.to, inlineFormattingHiddenMark, true); } + // Reference links carry a `[ref]` LinkLabel (no URL) that must hide too. + for (const l of labels) { + out.push(l.from, l.to, inlineFormattingHiddenMark, true); + } if (url) out.push(url.from, url.to, inlineFormattingHiddenMark, true); if (title) { out.push(title.from, title.to, inlineFormattingHiddenMark, true); diff --git a/src/editors/automerge/livePreview/livePreview.test.ts b/src/editors/automerge/livePreview/livePreview.test.ts index 643e651..98ff354 100644 --- a/src/editors/automerge/livePreview/livePreview.test.ts +++ b/src/editors/automerge/livePreview/livePreview.test.ts @@ -12,6 +12,7 @@ import { buildLinkDecorations, linkUrlAt } from './linkDecoration'; import { buildBlockquoteDecorations } from './blockquoteDecoration'; import { buildHrDecorations } from './hrDecoration'; import { buildListDecorations } from './listDecoration'; +import { buildEscapeDecorations } from './escapeDecoration'; import { type BuildResult } from './context'; interface FlatDeco { @@ -81,6 +82,16 @@ describe('headingDecoration', () => { const list = decos(state, buildHeadingDecorations(state)); expect(withClass(list, 'cm-heading-3')).toHaveLength(1); }); + + it('hides the closing ## sequence too', () => { + const state = makeState('## Section ##\n\nbody', 16); // in "body" + const list = decos(state, buildHeadingDecorations(state)); + const hidden = withClass(list, 'cm-formatting-hidden'); + // Opening "## " and the trailing " ##". + expect(hidden).toHaveLength(2); + expect(hidden[0].text).toBe('## '); + expect(hidden[hidden.length - 1].text).toBe(' ##'); + }); }); describe('inlineFormatDecoration', () => { @@ -187,6 +198,15 @@ describe('codeDecoration', () => { expect(withClass(list, 'cm-inline-formatting-hidden')).toHaveLength(0); }); + it('hides the language label when cursor is outside the block', () => { + const state = makeState('```js\ncode\n```\n\nafter', 18); // in "after" + const hidden = withClass( + decos(state, buildCodeDecorations(state)), + 'cm-inline-formatting-hidden', + ).map((h) => h.text); + expect(hidden).toContain('js'); // no stray language label floats above + }); + it('paints the active empty line of an unterminated block (after Enter)', () => { // "```js\n" — opening fence then Enter; the new line 2 is where the // cursor sits and must be inside the block. @@ -229,6 +249,20 @@ describe('linkDecoration', () => { expect(linkUrlAt(state, 5)).toBe('http://x'); // inside "a" expect(linkUrlAt(state, 0)).toBeNull(); // in "see " }); + + it('hides the [ref] of a reference link and mutes the definition', () => { + const state = makeState('See [docs][ref] here.\n\n[ref]: https://x.com', 0); + const list = decos(state, buildLinkDecorations(state)); + const label = withClass(list, 'cm-link'); + expect(label).toHaveLength(1); + expect(label[0].text).toBe('docs'); + const hidden = withClass(list, 'cm-inline-formatting-hidden').map( + (h) => h.text, + ); + expect(hidden).toContain('[ref]'); + // The definition line is muted rather than shown as body text. + expect(withClass(list, 'cm-link-def')).toHaveLength(1); + }); }); describe('blockquoteDecoration', () => { @@ -240,6 +274,13 @@ describe('blockquoteDecoration', () => { expect(hidden).toHaveLength(1); expect(hidden[0].text).toBe('> '); }); + + it('applies a deeper class to nested blockquotes', () => { + const state = makeState('> outer\n> > inner\n\nbody', 20); // in "body" + const list = decos(state, buildBlockquoteDecorations(state)); + expect(withClass(list, 'cm-quote-1')).toHaveLength(1); + expect(withClass(list, 'cm-quote-2')).toHaveLength(1); + }); }); describe('hrDecoration', () => { @@ -274,3 +315,34 @@ describe('listDecoration', () => { expect(withClass(list, 'cm-list-2')).toHaveLength(1); }); }); + +describe('escapeDecoration', () => { + it('hides the backslash of an escape, leaving the escaped char', () => { + const state = makeState('a \\* b', 0); + const hidden = withClass( + decos(state, buildEscapeDecorations(state)), + 'cm-inline-formatting-hidden', + ); + expect(hidden).toHaveLength(1); + expect(hidden[0].text).toBe('\\'); + }); + + it('reveals the backslash when the cursor is on it', () => { + const state = makeState('a \\* b', 3); // between the \ and * + const hidden = withClass( + decos(state, buildEscapeDecorations(state)), + 'cm-inline-formatting-hidden', + ); + expect(hidden).toHaveLength(0); + }); + + it('hides the backslash of a hard line break', () => { + const state = makeState('line\\\nnext', 8); // in "next" + const hidden = withClass( + decos(state, buildEscapeDecorations(state)), + 'cm-inline-formatting-hidden', + ); + expect(hidden).toHaveLength(1); + expect(hidden[0].text).toBe('\\'); + }); +});