diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 82da155..e661a02 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -72,9 +72,10 @@ Heading nodes carry an `id` (slug). The renderer for `Heading` emits a `` (`viewerRef`) for imperative scroll calls — see [Imperative scroll](#imperative-scroll).
- Computes layout each render from `useTerminalDimensions`:
- - `tocWidth = clamp(16, contentWidth + 3, floor(termWidth * 0.4))` (3 cols for the inner scrollbox's paddingX + a buffer).
+ - `tocWidth = clamp(16, contentWidth + 3, floor(termWidth * 0.4))` (3 cols for the inner scrollbox's paddingX + a buffer). The TOC always auto-sizes to its visible content.
+ - `effectiveContentMax = contentWidthOverride ?? contentMaxWidth` — a session-only `contentWidthOverride` (set by dragging the content/TOC seam, see §9 TOC) lifts the configured cap when non-null; it is not persisted.
- `viewerColumnWidth = (hasToc ? termWidth - tocWidth : termWidth) - 2` (2 cols for the viewer scrollbar + paddingRight).
- - `contentWidth = min(CONTENT_MAX_WIDTH, viewerColumnWidth)` — exposed via context so block renderers can size to it.
+ - `contentWidth = min(effectiveContentMax, viewerColumnWidth)` — exposed via context so block renderers can size to it. `effectiveContentMax` also flows to context as `contentMaxWidth`, so the Viewer's inner cap lifts with the drag.
- Memoises an `AppState` object into `AppStateContext` so descendants read state via `useAppState()`.
- Wires `useKeyboard` → `mapKey(ev, focus, { searchActive, helpOpen })` → `dispatch(action, commands)`. When `focus === 'search'`, `App` skips dispatch entirely — `SearchInput` owns its own `useKeyboard`.
- Runs two effects:
@@ -88,7 +89,8 @@ Layout (rendered tree):
← absolute overlay; top/left 0; zIndex 10
← scrollbox, contentWidth + overhead
- {hasToc && }
+ {hasToc && }
+ {isResizing && } ← drag shield
← height 1
@@ -224,6 +226,12 @@ Jumps (`tocSelect`, `nextHeading`/`prevHeading`) call `scrollChildToTop(id, ance
Width is computed by `tocContentWidth` in `toc-util.ts` (`INDENT_PER_LEVEL * (level-1) + MARKER_WIDTH + inlineVisibleWidth(inline)`), clamped in `App.tsx`.
+### Drag-resize
+
+The content/TOC seam carries an invisible 1-col `ResizeHandle` (`src/app/components/ResizeHandle.tsx`), absolutely positioned on the TOC pane's left edge so it costs no column; hovering reveals a `▏` bar. It only forwards its mousedown — `App` (`onSeamDown`) owns the gesture. Dragging sets `view.contentWidthOverride` from the seam column (`contentWidth = event.x - VIEWER_OVERHEAD`, clamped so content keeps ≥ MIN_CONTENT_WIDTH and the auto-width TOC keeps its cols — see `contentWidthFromSeamX` in `src/app/lib/sidebar-resize.ts`). Because the TOC is auto-width and packs immediately right of the content, dragging the seam grows/shrinks the content in both directions and the TOC rides along — reclaiming the dead space a capped content leaves on wide terminals. A double-click on the seam clears the override back to the configured cap.
+
+While resizing, `App` mounts a transparent full-screen **drag shield** (`position=absolute`, `zIndex 1000`) whose `onMouseDrag` drives the resize and whose `onMouseUp`/`onMouseDragEnd` ends it. Why a shield rather than the handle itself: OpenTUI binds drag-**capture** to the hit-target of the _first_ `drag` event (not the `mousedown` target), and a real terminal's first motion has already left the 1-col handle. A stable full-screen owner also absorbs the trailing mouseup of a double-click, so the reset (which shifts the TOC left under the cursor) never lands a stray click on a TOC row. The row box carries the same handlers as a fallback for the first event before the shield mounts. A drag clears the double-click timer so a drag's own mousedown never pairs with the next click. The handle's `` sets `selectable={false}` so the mousedown doesn't start a text selection that would hijack the drag.
+
## 10. Search (`src/app/lib/search.ts`, `match-nav.ts`, `components/SearchInput.tsx`)
`findMatches(nodes, pattern)` walks the AST and returns `Match[]`, each carrying:
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 8998230..3287d2f 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { dirname, resolve } from 'node:path'
import { flushSync, useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
+import { MouseButton } from '@opentui/core'
+import type { MouseEvent } from '@opentui/core'
import { AppStateContext, HeadingStateContext } from './state'
import type { AppState, HeadingState, ScrollboxHandle, Status } from './state'
import type { Action } from './lib/keys'
@@ -22,6 +24,8 @@ import { SearchBar } from './components/SearchBar'
import { HelpPanel } from './components/HelpPanel'
import { StickyHeader } from './components/StickyHeader'
import { StatusLine } from './components/StatusLine'
+import { ResizeHandle } from './components/ResizeHandle'
+import { contentWidthFromSeamX, isDoubleClick } from './lib/sidebar-resize'
import { CONTENT_MAX_WIDTH, VIEWER_OVERHEAD } from './styles/layout'
import { theme } from './styles/theme'
import type { LoadedDocument } from './lib/loadDocument'
@@ -72,6 +76,20 @@ export function App({
// so the reader never sees it painted at scrollTop 0 before the jump lands.
const [covering, setCovering] = useState(false)
+ // Sidebar drag-resize. The 1-col handle only fires the mousedown; the drag
+ // stream is owned by a full-screen shield mounted while `isResizing` (see the
+ // JSX below). OpenTUI binds drag-capture to the first drag event's hit-target
+ // and the pointer immediately leaves the 1-col handle, so capturing on a
+ // stable, full-screen element is the only way to receive the whole drag
+ // regardless of whether the viewer or the TOC sits under the cursor. The ref
+ // mirrors the state so the drag handler no-ops if it ever fires outside a resize.
+ const isResizingRef = useRef(false)
+ const [isResizing, setIsResizing] = useState(false)
+ // Tracks the previous seam mousedown so a second within the window resets to
+ // auto. Owned here, not in the handle, so the reset fires whether the second
+ // mousedown lands on the handle or on the drag shield mounted over it.
+ const lastSeamDownAtRef = useRef(null)
+
// At startup the H1 (if any) sits at the top of the viewport — seed it so
// the overlay's hide-when-visible rule fires on the first paint.
const seedVisible = useMemo>(
@@ -130,11 +148,15 @@ export function App({
Math.floor(termWidth * 0.4),
Math.max(16, tocVisibleContentWidth(toc, view.expanded) + TOC_PADDING),
)
+ // Dragging the content/TOC seam sets a session-only content max-width override
+ // (double-click on the handle clears it). It lifts the configured cap so the
+ // reclaimed columns on wide terminals become readable content, not dead space.
+ const effectiveContentMax = view.contentWidthOverride ?? contentMaxWidth
const viewerColumnWidth = Math.max(
1,
(isTocShown ? termWidth - tocWidth : termWidth) - VIEWER_OVERHEAD,
)
- const contentWidth = Math.min(contentMaxWidth, viewerColumnWidth)
+ const contentWidth = Math.min(effectiveContentMax, viewerColumnWidth)
// Stable across nav; rebuilt only when the doc (toc/fileLabel) changes.
const fold = useMemo(() => createFold({ toc, fileLabel }), [toc, fileLabel])
@@ -290,7 +312,7 @@ export function App({
tocCursorId: view.tocCursorId,
search: view.search,
contentWidth,
- contentMaxWidth,
+ contentMaxWidth: effectiveContentMax,
dir: nav.doc.dir,
historyDepth: nav.historyDepth,
trailLabels,
@@ -305,7 +327,7 @@ export function App({
view.search,
view.helpVisible,
contentWidth,
- contentMaxWidth,
+ effectiveContentMax,
nav.doc.dir,
nav.historyDepth,
trailLabels,
@@ -341,11 +363,44 @@ export function App({
const onAncestorClick = (id: string) =>
id === FILE_ROW_ID ? dispatchTocAction({ kind: 'top' }) : onEntryJump(id)
+ const onSeamDown = (event: MouseEvent) => {
+ if (event.button !== MouseButton.LEFT) return
+ event.stopPropagation()
+ const now = Date.now()
+ const isReset = isDoubleClick({ now, lastDownAt: lastSeamDownAtRef.current })
+ lastSeamDownAtRef.current = isReset ? null : now
+ if (isReset) actions.setContentWidthOverride(null)
+ // Mount the shield on every seam press (reset included) so the trailing
+ // mouseup lands on it, not on a TOC row that the reset just shifted under
+ // the cursor. onResizeEnd (its mouseup) tears it down.
+ isResizingRef.current = true
+ setIsResizing(true)
+ }
+ const onResizeDrag = (event: MouseEvent) => {
+ if (!isResizingRef.current) return
+ event.stopPropagation()
+ // A drag ends the double-click chain: without this, the drag's own mousedown
+ // would pair with the next click and fire a reset one press too early.
+ lastSeamDownAtRef.current = null
+ actions.setContentWidthOverride(contentWidthFromSeamX({ x: event.x, termWidth, tocWidth }))
+ }
+ const onResizeEnd = () => {
+ isResizingRef.current = false
+ setIsResizing(false)
+ }
+
return (
-
+
@@ -376,10 +431,35 @@ export function App({
settles. `visible={false}` still frees the column so the viewer reclaims
the width. */}
{toc.length > 0 && (
-
+
+
)}
+ {/* Drag shield: while resizing, a transparent full-screen box on top
+ captures the whole drag so it works regardless of whether the
+ pointer is over the viewer or the TOC. Without it, OpenTUI binds
+ the drag to whatever content sits under the cursor at first motion. */}
+ {isResizing && (
+
+ )}
diff --git a/src/app/components/ResizeHandle.test.tsx b/src/app/components/ResizeHandle.test.tsx
new file mode 100644
index 0000000..8ffe654
--- /dev/null
+++ b/src/app/components/ResizeHandle.test.tsx
@@ -0,0 +1,166 @@
+import { test, expect } from 'bun:test'
+import { createTestRenderer, createMockMouse, MouseButtons } from '@opentui/core/testing'
+import { createRoot } from '@opentui/react'
+import { App } from '../App'
+import { buildTree } from '../lib/ast'
+
+// "Sibling" is pushed well below the initial viewport by filler lines, so it
+// only ever appears in the TOC pane — never in the viewer's visible rows. The
+// TOC packs immediately right of the content, so its left edge (and thus
+// Sibling's column) tracks the content width: a wider content pushes it right.
+const FIXTURE = [
+ '# Title',
+ '',
+ ...Array.from({ length: 30 }, (_, i) => `filler ${i}`),
+ '',
+ '## Parent',
+ '',
+ '### Child',
+ '',
+ ...Array.from({ length: 40 }, (_, i) => `more filler ${i}`),
+ '',
+ '## Sibling',
+ '',
+ 'sibling body text',
+].join('\n')
+
+// Wider than CONTENT_MAX_WIDTH (100) so content starts capped with slack on the
+// right — the exact wide-terminal case where the seam drag must grow content.
+async function renderApp() {
+ const { nodes, toc, headingIds } = buildTree(FIXTURE)
+ const { renderer, flush, renderOnce, captureCharFrame } = await createTestRenderer({
+ width: 140,
+ height: 20,
+ })
+ const settle = async () => {
+ await flush({ maxPasses: 20 })
+ await new Promise(r => setTimeout(r, 30))
+ await renderOnce()
+ }
+ createRoot(renderer).render(
+ ,
+ )
+ await settle()
+ return { renderer, settle, captureCharFrame }
+}
+
+/** Locates the row/col of the first (and only) occurrence of `label`. */
+function findLabelRowCol(lines: string[], label: string): { row: number; col: number } {
+ for (let row = 0; row < lines.length; row++) {
+ const col = lines[row]?.indexOf(label) ?? -1
+ if (col >= 0) return { row, col }
+ }
+ throw new Error(`label "${label}" not found in frame`)
+}
+
+/**
+ * The resize handle is a normally-invisible 1-col strip at the content/TOC seam
+ * that only renders a '▏' glyph while hovered. Sweeping leftward from a known
+ * TOC-pane column hovers each candidate column until the glyph appears, which
+ * locates the handle's absolute terminal column without hardcoding widths.
+ */
+async function findHandleCol(
+ mouse: ReturnType,
+ settle: () => Promise,
+ captureCharFrame: () => string,
+ row: number,
+ searchFrom: number,
+): Promise {
+ for (let col = searchFrom; col >= 0; col--) {
+ await mouse.moveTo(col, row)
+ await settle()
+ const line = captureCharFrame().split('\n')[row] ?? ''
+ if (line[col] === '▏') return col
+ }
+ throw new Error('resize handle column not found')
+}
+
+// Uses the built-in mouse.drag(), whose first interpolated motion event lands
+// OFF the 1-col handle — exactly how a real terminal reports a drag. OpenTUI
+// binds drag-capture to the first drag event's hit-target, so resize must
+// survive the pointer immediately leaving the handle's column; the full-screen
+// drag shield is what makes that reliable in either direction.
+
+test('dragging the seam right grows the content (TOC edge moves right)', async () => {
+ const { renderer, settle, captureCharFrame } = await renderApp()
+ const mouse = createMockMouse(renderer)
+
+ const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)
+
+ await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.LEFT)
+ await settle()
+
+ const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ expect(after.col).toBeGreaterThan(before.col)
+
+ renderer.destroy()
+})
+
+test('dragging the seam left shrinks the content (TOC edge moves left)', async () => {
+ const { renderer, settle, captureCharFrame } = await renderApp()
+ const mouse = createMockMouse(renderer)
+
+ const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)
+
+ await mouse.drag(handleCol, before.row, handleCol - 12, before.row, MouseButtons.LEFT)
+ await settle()
+
+ const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ expect(after.col).toBeLessThan(before.col)
+
+ renderer.destroy()
+})
+
+test('double-click on the handle resets the content to its cap', async () => {
+ const { renderer, settle, captureCharFrame } = await renderApp()
+ const mouse = createMockMouse(renderer)
+
+ const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)
+
+ await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.LEFT)
+ await settle()
+
+ const grown = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ expect(grown.col).toBeGreaterThan(before.col)
+
+ const newHandleCol = await findHandleCol(
+ mouse,
+ settle,
+ captureCharFrame,
+ grown.row,
+ grown.col - 1,
+ )
+ await mouse.doubleClick(newHandleCol, grown.row, MouseButtons.LEFT)
+ await settle()
+
+ const reset = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ expect(reset.col).toBe(before.col)
+
+ renderer.destroy()
+})
+
+test('right-button drag does not resize', async () => {
+ const { renderer, settle, captureCharFrame } = await renderApp()
+ const mouse = createMockMouse(renderer)
+
+ const before = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ const handleCol = await findHandleCol(mouse, settle, captureCharFrame, before.row, before.col - 1)
+
+ await mouse.drag(handleCol, before.row, handleCol + 12, before.row, MouseButtons.RIGHT)
+ await settle()
+
+ const after = findLabelRowCol(captureCharFrame().split('\n'), 'Sibling')
+ expect(after.col).toBe(before.col)
+
+ renderer.destroy()
+})
diff --git a/src/app/components/ResizeHandle.tsx b/src/app/components/ResizeHandle.tsx
new file mode 100644
index 0000000..836ed11
--- /dev/null
+++ b/src/app/components/ResizeHandle.tsx
@@ -0,0 +1,48 @@
+import { useState } from 'react'
+import type { MouseEvent } from '@opentui/core'
+import { useTerminalDimensions } from '@opentui/react'
+import { theme } from '../styles/theme'
+
+type ResizeHandleProps = {
+ /** Forwards a mousedown on the seam to `App`, which owns start/reset/double-click. */
+ onSeamMouseDown: (event: MouseEvent) => void
+}
+
+/**
+ * Invisible 1-col grab zone overlaid on the content/TOC seam (the sidebar's left
+ * edge). Absolute positioning keeps it out of flex layout so it costs no column.
+ * Hover reveals a thin bar so the otherwise-invisible zone is discoverable.
+ *
+ * It only forwards the mousedown; `App` owns the resize/reset/double-click logic
+ * and the drag stream (see the drag shield in `App`). OpenTUI binds drag-capture
+ * to the hit-target of the *first* drag event, and a real terminal's first motion
+ * already leaves this 1-col strip — so the handle cannot reliably receive the
+ * drag events itself.
+ */
+export function ResizeHandle({ onSeamMouseDown }: ResizeHandleProps) {
+ const { height: termHeight } = useTerminalDimensions()
+ const [isHovered, setIsHovered] = useState(false)
+
+ return (
+ setIsHovered(true)}
+ onMouseOut={() => setIsHovered(false)}
+ >
+ {/* Text is selectable by default; a mousedown on selectable content
+ starts a text selection that hijacks the drag stream. Opt out so the
+ mousedown cleanly starts our resize instead. Width:1 wraps one char
+ per row, so repeating the glyph fills the column's full height;
+ overflow beyond the box is clipped. */}
+
+ {(isHovered ? '▏' : ' ').repeat(termHeight)}
+
+
+ )
+}
diff --git a/src/app/lib/commands.test.ts b/src/app/lib/commands.test.ts
index 5f310ea..4d7a5a0 100644
--- a/src/app/lib/commands.test.ts
+++ b/src/app/lib/commands.test.ts
@@ -91,6 +91,7 @@ function makeDeps(
toggleMouse: mock(),
toggleTocVisible: mock(),
toggleHelp: mock(),
+ setContentWidthOverride: mock(),
}
const doc = { nodes: [], toc, headingIds, ...overrides.doc }
const viewState: ViewState = {
@@ -103,6 +104,7 @@ function makeDeps(
tocVisible: true,
helpVisible: false,
mouseEnabled: false,
+ contentWidthOverride: null,
...overrides.state,
}
const deps: CommandDeps = {
diff --git a/src/app/lib/sidebar-resize.test.ts b/src/app/lib/sidebar-resize.test.ts
new file mode 100644
index 0000000..1801743
--- /dev/null
+++ b/src/app/lib/sidebar-resize.test.ts
@@ -0,0 +1,37 @@
+import { test, expect } from 'bun:test'
+import { contentWidthFromSeamX, isDoubleClick, DOUBLE_CLICK_MS } from './sidebar-resize'
+import { VIEWER_OVERHEAD } from '../styles/layout'
+import { MIN_CONTENT_WIDTH } from './config'
+
+test('contentWidthFromSeamX: mid-range width is the seam column minus viewer overhead', () => {
+ expect(contentWidthFromSeamX({ x: 100, termWidth: 200, tocWidth: 24 })).toBe(
+ 100 - VIEWER_OVERHEAD,
+ )
+})
+
+test('contentWidthFromSeamX: clamps to MIN_CONTENT_WIDTH when dragged too far left', () => {
+ expect(contentWidthFromSeamX({ x: 5, termWidth: 200, tocWidth: 24 })).toBe(MIN_CONTENT_WIDTH)
+})
+
+test('contentWidthFromSeamX: clamps so the auto-width TOC keeps its columns', () => {
+ // Farthest-right seam leaves exactly termWidth - tocWidth - overhead for content.
+ expect(contentWidthFromSeamX({ x: 500, termWidth: 200, tocWidth: 24 })).toBe(
+ 200 - 24 - VIEWER_OVERHEAD,
+ )
+})
+
+test('contentWidthFromSeamX: narrow terminals never invert the clamp', () => {
+ expect(contentWidthFromSeamX({ x: 5, termWidth: 30, tocWidth: 24 })).toBe(MIN_CONTENT_WIDTH)
+})
+
+test('isDoubleClick: true when prior down is within the window', () => {
+ expect(isDoubleClick({ now: 1000, lastDownAt: 1000 - (DOUBLE_CLICK_MS - 1) })).toBe(true)
+})
+
+test('isDoubleClick: false when prior down is too old', () => {
+ expect(isDoubleClick({ now: 1000, lastDownAt: 1000 - (DOUBLE_CLICK_MS + 1) })).toBe(false)
+})
+
+test('isDoubleClick: false when there was no prior down', () => {
+ expect(isDoubleClick({ now: 1000, lastDownAt: null })).toBe(false)
+})
diff --git a/src/app/lib/sidebar-resize.ts b/src/app/lib/sidebar-resize.ts
new file mode 100644
index 0000000..873163e
--- /dev/null
+++ b/src/app/lib/sidebar-resize.ts
@@ -0,0 +1,37 @@
+import { VIEWER_OVERHEAD } from '../styles/layout'
+import { MIN_CONTENT_WIDTH } from './config'
+
+/** Two `down` events within this window count as a double-click (reset). */
+export const DOUBLE_CLICK_MS = 400
+
+/**
+ * Content sits on the left starting at column 0; the sidebar seam (and the grab
+ * handle) is at its right edge, `contentWidth + VIEWER_OVERHEAD`. So the content
+ * width a drag to absolute column `x` asks for is `x - VIEWER_OVERHEAD`. Clamped
+ * to keep content >= MIN_CONTENT_WIDTH and leave the auto-width TOC (`tocWidth`)
+ * its columns; on very narrow terminals the lower bound wins.
+ */
+export function contentWidthFromSeamX({
+ x,
+ termWidth,
+ tocWidth,
+}: {
+ x: number
+ termWidth: number
+ tocWidth: number
+}): number {
+ const raw = x - VIEWER_OVERHEAD
+ const max = Math.max(MIN_CONTENT_WIDTH, termWidth - tocWidth - VIEWER_OVERHEAD)
+ return Math.min(max, Math.max(MIN_CONTENT_WIDTH, raw))
+}
+
+/** True when `now` follows a recorded prior `down` within DOUBLE_CLICK_MS. */
+export function isDoubleClick({
+ now,
+ lastDownAt,
+}: {
+ now: number
+ lastDownAt: number | null
+}): boolean {
+ return lastDownAt !== null && now - lastDownAt <= DOUBLE_CLICK_MS
+}
diff --git a/src/app/lib/view-state.ts b/src/app/lib/view-state.ts
index 7ead610..3033ad4 100644
--- a/src/app/lib/view-state.ts
+++ b/src/app/lib/view-state.ts
@@ -14,6 +14,8 @@ export type ViewState = {
tocVisible: boolean
helpVisible: boolean
mouseEnabled: boolean
+ /** Manual content max-width override in cols; null uses the configured cap. Session-only. */
+ contentWidthOverride: number | null
}
export type ViewActions = {
@@ -27,6 +29,7 @@ export type ViewActions = {
toggleMouse: () => void
toggleTocVisible: () => void
toggleHelp: () => void
+ setContentWidthOverride: (n: number | null) => void
}
export function useViewState({ seedVisible }: { seedVisible: Set }): {
@@ -43,6 +46,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): {
tocVisible: true,
helpVisible: false,
mouseEnabled: false,
+ contentWidthOverride: null,
}))
const focus = useCallback((f: Focus) => setState(s => ({ ...s, focus: f })), [])
@@ -77,6 +81,10 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): {
[],
)
const toggleHelp = useCallback(() => setState(s => ({ ...s, helpVisible: !s.helpVisible })), [])
+ const setContentWidthOverride = useCallback(
+ (n: number | null) => setState(s => ({ ...s, contentWidthOverride: n })),
+ [],
+ )
const actions = useMemo(
() => ({
@@ -90,6 +98,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): {
toggleMouse,
toggleTocVisible,
toggleHelp,
+ setContentWidthOverride,
}),
[
focus,
@@ -102,6 +111,7 @@ export function useViewState({ seedVisible }: { seedVisible: Set }): {
toggleMouse,
toggleTocVisible,
toggleHelp,
+ setContentWidthOverride,
],
)