From 790f2ba27701aecc481ca44410be1fb455e8261e Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Tue, 21 Jul 2026 21:29:24 -0400 Subject: [PATCH] Render nested blockquotes as equal-width bars at any depth The depth styling used a single border-left with `double` for levels 2-3, so level 2 rendered as two hairlines thinner than the level-1 solid bar, level 3 as thicker lines, and levels past 3 had no class at all (depth was capped at 3). A single border can only ever be one bar. Pass the nesting depth to CSS as an inline `--quote-depth` variable and paint the bars with a repeating gradient clipped (via background-size) to exactly that many 3px bars. Every level now adds one more equal-width bar, at any depth, with a dark-theme bar color. Co-Authored-By: Claude Opus 4.8 --- src/editors/automerge/automergeEditor.css | 37 +++++++++++-------- .../livePreview/blockquoteDecoration.ts | 29 ++++++++++----- .../automerge/livePreview/livePreview.test.ts | 24 ++++++++++-- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/src/editors/automerge/automergeEditor.css b/src/editors/automerge/automergeEditor.css index e7a70e6..5dbd0c5 100644 --- a/src/editors/automerge/automergeEditor.css +++ b/src/editors/automerge/automergeEditor.css @@ -225,23 +225,30 @@ padding-left: 3.5em; } -/* Blockquotes — the depth class carries the bar + indent so nested quotes - * (`> >`) read as progressively deeper. */ +/* + * Blockquotes. Each nesting level draws one equal-width vertical bar. The + * builder sets `--quote-depth` (the number of `>` on the line) as an inline + * variable; a repeating gradient paints a 3px bar every `--quote-step`, and + * `background-size` clips it to exactly `--quote-depth` bars — so any depth + * renders with uniform bars (a single `border-left` can only be one bar). + */ .automerge-editor-container .cm-quote { + --quote-bar: #d0d7de; + --quote-bar-w: 3px; + --quote-step: 0.9em; color: #656d76; - margin-left: 0; -} -.automerge-editor-container .cm-quote-1 { - border-left: 3px solid #d0d7de; - padding-left: 1em; -} -.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; + padding-left: calc(var(--quote-depth, 1) * var(--quote-step) + 0.5em); + background-image: repeating-linear-gradient( + to right, + var(--quote-bar) 0 var(--quote-bar-w), + transparent var(--quote-bar-w) var(--quote-step) + ); + background-repeat: no-repeat; + background-size: calc(var(--quote-depth, 1) * var(--quote-step)) 100%; +} +:root[data-theme='dark'] .automerge-editor-container .cm-quote { + --quote-bar: #3b4048; + color: #9198a1; } /* Reference-link definition line (`[ref]: url`) — muted, it is plumbing. */ diff --git a/src/editors/automerge/livePreview/blockquoteDecoration.ts b/src/editors/automerge/livePreview/blockquoteDecoration.ts index 53f9077..f994a4e 100644 --- a/src/editors/automerge/livePreview/blockquoteDecoration.ts +++ b/src/editors/automerge/livePreview/blockquoteDecoration.ts @@ -1,10 +1,12 @@ /** * Blockquotes (`> …`). * - * 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. + * 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. Nesting + * depth is the number of `>` markers on the line (`> >` → 2); it is passed to + * the CSS as an inline `--quote-depth` variable, which draws that many + * equal-width bars via a repeating gradient. This renders any depth uniformly + * (a single `border-left` could only ever be one bar). */ import { type EditorState } from '@codemirror/state'; import { Decoration } from '@codemirror/view'; @@ -20,10 +22,19 @@ import { } from './context'; import { blockquoteFormattingHiddenMark } from './hiddenClasses'; -const MAX_DEPTH = 3; -const quoteLines = [1, 2, 3].map((depth) => - Decoration.line({ class: `cm-quote cm-quote-${depth}` }), -); +// One line decoration per nesting depth, cached (CM6 reuses instances). +const quoteLineByDepth = new Map(); +function quoteLine(depth: number): Decoration { + let deco = quoteLineByDepth.get(depth); + if (!deco) { + deco = Decoration.line({ + class: 'cm-quote', + attributes: { style: `--quote-depth:${depth}` }, + }); + quoteLineByDepth.set(depth, deco); + } + return deco; +} export function buildBlockquoteDecorations( state: EditorState, @@ -52,7 +63,7 @@ export function buildBlockquoteDecorations( 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]); + out.push(line.from, line.from, quoteLine(depth)); } return out.result(); diff --git a/src/editors/automerge/livePreview/livePreview.test.ts b/src/editors/automerge/livePreview/livePreview.test.ts index 98ff354..e95bfd9 100644 --- a/src/editors/automerge/livePreview/livePreview.test.ts +++ b/src/editors/automerge/livePreview/livePreview.test.ts @@ -20,6 +20,7 @@ interface FlatDeco { to: number; cls: string; text: string; + style: string; } function makeState(doc: string, cursor = 0): EditorState { @@ -43,6 +44,9 @@ function collect(state: EditorState, set: DecorationSet): FlatDeco[] { // spec is typed `any` upstream. cls: (iter.value.spec as { class?: string }).class ?? '', text: state.doc.sliceString(iter.from, iter.to), + style: + (iter.value.spec as { attributes?: { style?: string } }).attributes + ?.style ?? '', }); iter.next(); } @@ -275,11 +279,23 @@ describe('blockquoteDecoration', () => { expect(hidden[0].text).toBe('> '); }); - it('applies a deeper class to nested blockquotes', () => { + it('encodes nesting depth for each quoted line', () => { 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); + const styles = withClass( + decos(state, buildBlockquoteDecorations(state)), + 'cm-quote', + ).map((q) => q.style); + expect(styles).toContain('--quote-depth:1'); + expect(styles).toContain('--quote-depth:2'); + }); + + it('renders arbitrary nesting depth (beyond 3)', () => { + const state = makeState('> > > > deep\n\nbody', 15); // in "body" + const styles = withClass( + decos(state, buildBlockquoteDecorations(state)), + 'cm-quote', + ).map((q) => q.style); + expect(styles).toContain('--quote-depth:4'); }); }); -- 2.51.2