Skip to content

Commit b6b1a31

Browse files
authored
Merge pull request #345 from quarto-dev/feature/bd-9x3zbuj8-preview-hierarchy-nav-overlap
q2 preview: hierarchical block navigator + fix breadcrumb/toolbar overlap (bd-9x3zbuj8)
2 parents c64e391 + 42870bc commit b6b1a31

15 files changed

Lines changed: 1114 additions & 245 deletions

claude-notes/plans/2026-06-25-q2-preview-hierarchy-nav-and-overlap.md

Lines changed: 362 additions & 0 deletions
Large diffs are not rendered by default.

hub-client/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ be in reverse chronological order (latest first).
1515
1616
-->
1717

18+
### 2026-06-25
19+
20+
- [`15b9287c`](https://github.com/quarto-dev/q2/commits/15b9287c): The block-hierarchy navigator (◀ Dv ¶ ▶) now sits inline to the right of the rich-text formatting buttons instead of overlapping them, and `q2 preview --allow-edit` shows the navigator by default (matching the hub editor).
21+
1822
### 2026-06-24
1923

2024
- [`0ccf989a`](https://github.com/quarto-dev/q2/commits/0ccf989a): The editor no longer auto-inserts a second backtick — typing `` ` `` at the end of a word to close inline code now adds just one backtick instead of two (selecting a word and typing `` ` `` still wraps it).

q2-preview-spa/e2e/edit-cell-sizing.spec.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ test.afterEach(async () => {
6868
});
6969

7070
test('paragraph: active region height and following heading top unchanged on activation', async ({ page }) => {
71-
await page.goto(server.url);
71+
// Pin richText=0: these no-reflow tests use the textarea as the activation
72+
// baseline (assertNoReflowOnActivation waits for a <textarea>). Since rich-text
73+
// is the q2-preview default (bd-j1nto6eq), Para/Header would otherwise open in
74+
// the rich editor and no textarea would mount. Same baseline pin as the
75+
// hub-client e2e (bd-038tnyqy). Tracked: bd-n4v4phe4.
76+
await page.goto(`${server.url}${server.url.includes('?') ? '&' : '?'}richText=0`);
7277
await waitForEditableBlocks(page);
7378
const iframe = page.frameLocator('iframe');
7479
await assertNoReflowOnActivation(
@@ -80,7 +85,12 @@ test('paragraph: active region height and following heading top unchanged on act
8085
});
8186

8287
test('heading: active region height and following list top unchanged on activation', async ({ page }) => {
83-
await page.goto(server.url);
88+
// Pin richText=0: these no-reflow tests use the textarea as the activation
89+
// baseline (assertNoReflowOnActivation waits for a <textarea>). Since rich-text
90+
// is the q2-preview default (bd-j1nto6eq), Para/Header would otherwise open in
91+
// the rich editor and no textarea would mount. Same baseline pin as the
92+
// hub-client e2e (bd-038tnyqy). Tracked: bd-n4v4phe4.
93+
await page.goto(`${server.url}${server.url.includes('?') ? '&' : '?'}richText=0`);
8494
await waitForEditableBlocks(page);
8595
const iframe = page.frameLocator('iframe');
8696
await assertNoReflowOnActivation(
@@ -92,7 +102,12 @@ test('heading: active region height and following list top unchanged on activati
92102
});
93103

94104
test('paragraph above list: active region height and list top unchanged on activation', async ({ page }) => {
95-
await page.goto(server.url);
105+
// Pin richText=0: these no-reflow tests use the textarea as the activation
106+
// baseline (assertNoReflowOnActivation waits for a <textarea>). Since rich-text
107+
// is the q2-preview default (bd-j1nto6eq), Para/Header would otherwise open in
108+
// the rich editor and no textarea would mount. Same baseline pin as the
109+
// hub-client e2e (bd-038tnyqy). Tracked: bd-n4v4phe4.
110+
await page.goto(`${server.url}${server.url.includes('?') ? '&' : '?'}richText=0`);
96111
await waitForEditableBlocks(page);
97112
const iframe = page.frameLocator('iframe');
98113
await assertNoReflowOnActivation(

q2-preview-spa/e2e/nesting-cursor.spec.ts

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
* embeds the SPA + WASM through `include_dir!`. Asserts the §3a/§3b nesting-cursor
66
* RESOLUTION that no existing q2-preview-spa spec covers:
77
*
8-
* - WITH `?nestingCursor=1` (+ `--allow-edit`): a leaf-click on a blockquote
8+
* - DEFAULT boot (no param, + `--allow-edit`, bd-9x3zbuj8): the nesting cursor
9+
* is now ON by default (matching hub-client). A leaf-click on a blockquote
910
* child opens THAT child with a CLEAN, AST-regenerated buffer (no `>`
10-
* markers) — proving leaf resolution + nested-buffer regeneration end to
11-
* end through the binary's embedded SPA + WASM.
12-
* - WITHOUT the param: clicking the blockquote opens the WHOLE quote WITH `>`
13-
* markers (Phase-2 locked, prefixing-atomic) — proving the unlock is gated.
11+
* markers), and the breadcrumb navigator chip is visible — proving leaf
12+
* resolution + nested-buffer regeneration end to end through the binary's
13+
* embedded SPA + WASM, with no query param required.
14+
* - WITH the explicit `?nestingCursor=0` opt-out: clicking the blockquote
15+
* opens the WHOLE quote WITH `>` markers (Phase-2 locked, prefixing-atomic)
16+
* — proving the unlock can still be turned off.
1417
*
1518
* The boot path is covered at jsdom in
1619
* `q2-preview-spa/src/p3-2-nesting-cursor-spa.integration.test.tsx`; the
@@ -76,25 +79,70 @@ test.describe('P3.5 — SPA nesting-cursor resolution (real q2 preview binary)',
7679
);
7780
}
7881

79-
test('with ?nestingCursor=1: leaf-click opens the blockquote child with a clean buffer (no `>`)', async ({ page }) => {
80-
// server.url already carries the CLI's `?page=index.qmd` query (previewServer
81-
// waitForUrl captures it), so append nestingCursor with the correct separator —
82-
// `${server.url}?nestingCursor=1` would produce the malformed `?page=index.qmd?nestingCursor=1`
83-
// (nestingCursor parses to null → the unlock silently never engages).
82+
test('default boot (no param): leaf-click opens the blockquote child in the RICH editor with the navigator INLINE in the toolbar (no overlap)', async ({ page }) => {
83+
// bd-9x3zbuj8: the nesting cursor is now ON by default — no query param.
84+
// Combined with rich-text being the SPA default, leaf-clicking a paragraph
85+
// opens the rich editor (ProseMirror), and the hierarchy navigator renders
86+
// INLINE in the toolbar row (Task 2) instead of as a floating chip.
87+
await page.goto(server.url);
88+
await waitForBlockquote(page);
89+
90+
const iframe = page.frameLocator('iframe');
91+
92+
// Leaf-click the blockquote CHILD (the inner paragraph). In unlocked mode
93+
// this resolves to the child Para, which opens in the rich editor.
94+
await iframe.locator('blockquote p[data-block-pool-id]').first().click();
95+
96+
// The rich editor (ProseMirror) — NOT a textarea — must open, showing the
97+
// rendered quote text with no `>` source markers.
98+
const pm = iframe.locator('.q2-richtext-editor .ProseMirror').first();
99+
await pm.waitFor({ timeout: 10_000 });
100+
await expect
101+
.poll(async () => pm.textContent(), {
102+
timeout: 8000,
103+
message: 'rich editor should show the blockquote-child text',
104+
})
105+
.toContain('Quote line one.');
106+
const text = (await pm.textContent()) ?? '';
107+
expect(text, 'rich editor shows both lines').toContain('Quote line two.');
108+
expect(text, 'rich editor renders text without `>` markers').not.toContain('>');
109+
expect(text, 'leaf edit must not include the Intro paragraph').not.toContain('Intro paragraph.');
110+
111+
// Task 2: the navigator renders INLINE inside the toolbar row (to the right
112+
// of the formatting buttons), and the standalone floating chip is suppressed
113+
// — so the two never overlap.
114+
const toolbar = iframe.locator('.q2-rt-toolbar').first();
115+
await expect(toolbar, 'rich-text toolbar must be present').toBeVisible({ timeout: 5000 });
116+
await expect(
117+
toolbar.locator('.q2-breadcrumb-out'),
118+
'navigator ◀ must be inside the toolbar row (inline breadcrumb)',
119+
).toBeVisible();
120+
await expect(
121+
toolbar.locator('.q2-crumb').last(),
122+
'current crumb (¶) must be inside the toolbar row',
123+
).toHaveText('¶');
124+
await expect(
125+
iframe.locator('[data-testid="q2-breadcrumb-chip"]'),
126+
'standalone floating chip must be suppressed when the inline breadcrumb shows',
127+
).toHaveCount(0);
128+
129+
await pm.press('Escape');
130+
});
131+
132+
test('default nesting cursor with ?richText=0: leaf-click opens the blockquote child as a clean textarea buffer (no `>`)', async ({ page }) => {
133+
// With rich text opted OUT, the nested leaf opens in the textarea — this is
134+
// where the clean-buffer REGENERATION (markers stripped) is observable. Also
135+
// proves the nesting-cursor default-on is independent of the rich-text flag.
84136
const sep = server.url.includes('?') ? '&' : '?';
85-
await page.goto(`${server.url}${sep}nestingCursor=1`);
137+
await page.goto(`${server.url}${sep}richText=0`);
86138
await waitForBlockquote(page);
87139

88140
const iframe = page.frameLocator('iframe');
89141

90-
// Leaf-click the blockquote CHILD (the inner paragraph). In unlocked
91-
// mode this resolves to the child, not the whole quote.
92142
await iframe.locator('blockquote p[data-block-pool-id]').first().click();
93143
const ta = iframe.locator('textarea').first();
94144
await ta.waitFor({ timeout: 10_000 });
95145

96-
// The textarea must carry the CLEAN regenerated buffer: both quote lines,
97-
// and crucially NO `>` markers (regeneration stripped them).
98146
await expect
99147
.poll(async () => ta.inputValue(), {
100148
timeout: 8000,
@@ -108,20 +156,29 @@ test.describe('P3.5 — SPA nesting-cursor resolution (real q2 preview binary)',
108156
value,
109157
'clean nested buffer must NOT contain `>` markers (leaf resolution + regeneration)',
110158
).not.toContain('>');
111-
// And it must not have pulled in the surrounding paragraphs.
112159
expect(value, 'leaf edit must not include the Intro paragraph').not.toContain('Intro paragraph.');
113160

161+
// The standalone navigator chip is the breadcrumb home in plain/textarea mode.
162+
const chip = iframe.locator('[data-testid="q2-breadcrumb-chip"]');
163+
await expect(chip, 'breadcrumb navigator chip must be visible by default').toBeVisible({ timeout: 5000 });
164+
114165
await ta.press('Escape');
115166
});
116167

117-
test('without the param: clicking the blockquote opens the whole quote with `>` (locked)', async ({ page }) => {
118-
await page.goto(server.url);
168+
test('with ?nestingCursor=0 (opt-out): clicking the blockquote opens the whole quote with `>` (locked)', async ({ page }) => {
169+
// server.url already carries the CLI's `?page=index.qmd` query (previewServer
170+
// waitForUrl captures it), so append nestingCursor with the correct separator —
171+
// `${server.url}?nestingCursor=0` would produce the malformed `?page=index.qmd?nestingCursor=0`
172+
// (nestingCursor parses to null → defaults back ON, defeating the opt-out).
173+
const sep = server.url.includes('?') ? '&' : '?';
174+
await page.goto(`${server.url}${sep}nestingCursor=0`);
119175
await waitForBlockquote(page);
120176

121177
const iframe = page.frameLocator('iframe');
122178

123-
// Click inside the blockquote. In locked mode (default), prefixing-atomic
124-
// resolution opens the WHOLE quote as one buffer, markers included.
179+
// Click inside the blockquote. In locked mode (explicit opt-out),
180+
// prefixing-atomic resolution opens the WHOLE quote as one buffer,
181+
// markers included.
125182
await iframe.locator('blockquote p[data-block-pool-id]').first().click();
126183
const ta = iframe.locator('textarea').first();
127184
await ta.waitFor({ timeout: 10_000 });

q2-preview-spa/src/PreviewApp.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,16 +275,21 @@ const CONNECTION_BANNER_STYLE: React.CSSProperties = {
275275
};
276276

277277
/**
278-
* P3.2: parse `?nestingCursor=1` from the boot URL search string.
279-
* Returns true only when the param is exactly `"1"`. Read-at-load;
280-
* the SPA does not react to URL changes after mount.
278+
* Parse `?nestingCursor` from the boot URL (P3.2; default flipped in
279+
* bd-9x3zbuj8). The hierarchical block navigator (BreadcrumbChip) is now the
280+
* DEFAULT in q2 preview, matching hub-client (which defaults the
281+
* `unlockNestingCursor` preference on): only an explicit `?nestingCursor=0`
282+
* opts OUT; an absent param, `?nestingCursor=1`, or any other value keeps it
283+
* on. Read-at-load; the SPA does not react to URL changes after mount.
284+
*
285+
* Safe to default on globally: the navigator self-gates on an active edit
286+
* target, so a read-only preview (no `--allow-edit`) never shows it.
281287
*/
282-
function parseNestingCursorParam(search: string): boolean {
283-
if (!search) return false;
288+
export function parseNestingCursorParam(search: string): boolean {
284289
try {
285-
return new URLSearchParams(search).get('nestingCursor') === '1';
290+
return new URLSearchParams(search).get('nestingCursor') !== '0';
286291
} catch {
287-
return false;
292+
return true;
288293
}
289294
}
290295

q2-preview-spa/src/p3-2-nesting-cursor-spa.integration.test.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ describe('P3.2 SPA query param: ?nestingCursor=1 → iframe receives unlockNesti
193193
expect(mockRegenerateNestedBuffers).toHaveBeenCalledWith(CONTENT, AST);
194194
});
195195

196-
it('passes unlockNestingCursor: false (or absent) when no ?nestingCursor param', async () => {
196+
it('passes unlockNestingCursor: true by default when no ?nestingCursor param (bd-9x3zbuj8)', async () => {
197197
Object.defineProperty(window, 'location', {
198198
value: {
199199
...window.location,
@@ -214,8 +214,13 @@ describe('P3.2 SPA query param: ?nestingCursor=1 → iframe receives unlockNesti
214214
);
215215

216216
const props = capturedIframeProps[capturedIframeProps.length - 1];
217-
// No param → nestingCursor defaults to false → unlockNestingCursor should be false/absent.
218-
expect(props?.unlockNestingCursor).toBeFalsy();
217+
// The navigator is now default-ON (matching hub-client): no param ⇒
218+
// unlockNestingCursor: true. It still self-gates on an edit target, so a
219+
// read-only preview shows nothing.
220+
expect(props?.unlockNestingCursor).toBe(true);
221+
// On-path: with nestingCursor on AND non-empty content + AST, the nested
222+
// buffer regen runs (same assertion as the ?nestingCursor=1 case).
223+
expect(mockRegenerateNestedBuffers).toHaveBeenCalledWith(CONTENT, AST);
219224
});
220225

221226
it('passes unlockNestingCursor: false when ?nestingCursor=0', async () => {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* The hierarchical block navigator (BreadcrumbChip) is now the DEFAULT in
3+
* q2 preview, matching hub-client (which defaults the `unlockNestingCursor`
4+
* preference on). Only an explicit `?nestingCursor=0` opts out (bd-9x3zbuj8).
5+
* These assert that default-on / opt-out contract.
6+
*
7+
* Note: the navigator self-gates on an active edit target, so it only becomes
8+
* visible when actually editing — i.e. under `q2 preview --allow-edit`.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { parseNestingCursorParam } from './PreviewApp';
13+
14+
describe('parseNestingCursorParam — the nesting navigator is the default', () => {
15+
it('defaults ON when no query string is present', () => {
16+
expect(parseNestingCursorParam('')).toBe(true);
17+
});
18+
19+
it('defaults ON when the nestingCursor param is absent', () => {
20+
expect(parseNestingCursorParam('?page=index.qmd')).toBe(true);
21+
});
22+
23+
it('stays ON for ?nestingCursor=1', () => {
24+
expect(parseNestingCursorParam('?nestingCursor=1')).toBe(true);
25+
});
26+
27+
it('turns OFF only for the explicit ?nestingCursor=0 opt-out', () => {
28+
expect(parseNestingCursorParam('?nestingCursor=0')).toBe(false);
29+
});
30+
31+
it('keeps ON for any other value (only "0" disables)', () => {
32+
expect(parseNestingCursorParam('?nestingCursor=true')).toBe(true);
33+
expect(parseNestingCursorParam('?nestingCursor=')).toBe(true);
34+
});
35+
36+
it('works mixed with other params', () => {
37+
expect(parseNestingCursorParam('?page=a.qmd&nestingCursor=0&richText=1')).toBe(false);
38+
expect(parseNestingCursorParam('?page=a.qmd&richText=0')).toBe(true);
39+
});
40+
});

0 commit comments

Comments
 (0)