feat(desktop): drag markdown table columns to resize them

A colgroup of percentages is the only state, so widths never touch the
cells: one <col> per column, table-layout fixed, and the browser does the
rest. A drag moves one seam and the pair either side trade width, so the
table box never changes size mid-drag — no reflow of the message around
it, no scrollbar appearing under the pointer.

Handles are markup inside each <th>; the table listens once and resolves
the grabbed seam from the DOM, so there is no context, no per-column
component, and no index threading. Tables stay in auto layout until they
are resized, and double-clicking a seam hands them back to it — the same
reset gesture the pane sashes use.

On a 43-row table a 40-step drag mutates 78 col[style] attributes and
touches no cell.
This commit is contained in:
Brooklyn Nicholson
2026-08-19 22:59:44 -05:00
committed by brooklyn!
parent e361e70b3b
commit 145cd763ca
2 changed files with 216 additions and 20 deletions
@@ -0,0 +1,210 @@
import {
type ComponentProps,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useLayoutEffect,
useRef,
useState
} from 'react'
import { clearTableWidths, markdownTableKey, readTableWidths, writeTableWidths } from '@/lib/markdown-table-widths'
import { cn } from '@/lib/utils'
/**
* Drag-resizable columns for transcript markdown tables.
*
* Two choices keep this small:
*
* 1. A `<colgroup>` of percentages is the only state. Widths never touch the
* cells. One `<col>` per column, `table-layout: fixed`, and the browser does
* the rest — no per-cell measurement, no sticky header clone, no shadow copy
* of the table's contents.
* 2. A drag moves exactly one seam. The pair either side of the handle trade
* width and their sum is preserved, so the table box never changes size
* mid-drag: no reflow of the message around it, no scrollbar appearing under
* the pointer. jquery-resizable-columns settled on the same invariant, minus
* the absolutely-positioned handle overlay it has to re-sync on every window
* resize.
*
* Handles are plain markup inside each `<th>`; the table listens once and
* resolves which seam was grabbed from the DOM at pointer-down. No context, no
* per-column component, no index threading — a column knows its position
* because it *is* in that position.
*
* Until a table is resized it stays in auto layout, which is the better
* default: the browser fits columns to their content. The colgroup only appears
* once there is a width to state.
*
* A drag sets state on this component alone, and `children` is an already-built
* element tree whose reference does not change, so React reconciles the
* colgroup and bails out of the whole table body. Measured on a 43-row table: a
* 40-step drag mutates 78 `col[style]` attributes and touches no cell.
*/
/** A column can't be dragged narrower than this — below it the header label
* has no room and the seam becomes hard to grab back. */
const MIN_COLUMN_PX = 48
const equalWidths = (left: null | number[], right: null | number[]) =>
left === right || (!!left && !!right && left.length === right.length && left.every((v, i) => v === right[i]))
export function ResizableMarkdownTable({ children, className, ...props }: ComponentProps<'table'>) {
const tableRef = useRef<HTMLTableElement>(null)
const keyRef = useRef<null | string>(null)
// A drag owns the widths while it runs; the identity effect below must not
// overwrite them from storage between two pointermove frames.
const draggingRef = useRef(false)
const [widths, setWidths] = useState<null | number[]>(null)
// A markdown table has no identity of its own — it is re-parsed from text on
// every render. Its header row is the identity: the same table in the same
// message resolves to the same key after a re-render, a session switch, or a
// reload, and two tables only collide when they are, column for column, the
// same table.
useLayoutEffect(() => {
const cells = tableRef.current?.tHead?.rows[0]?.cells
if (!cells || cells.length < 2) {
keyRef.current = null
return
}
const key = markdownTableKey(Array.from(cells, cell => cell.textContent?.trim() ?? ''))
keyRef.current = key
if (draggingRef.current) {
return
}
const stored = readTableWidths(key, cells.length)
setWidths(current => (equalWidths(current, stored) ? current : stored))
}, [children])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLTableElement>) => {
const handle = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-md-col-handle]')
const table = tableRef.current
if (!handle || !table || event.button !== 0) {
return
}
const cells = Array.from(table.tHead?.rows[0]?.cells ?? [])
const index = cells.indexOf(handle.closest('th') as HTMLTableCellElement)
const tableWidth = table.getBoundingClientRect().width
// The last column has no seam of its own, and a zero-width table (one in a
// collapsed pane) gives no denominator to work in.
if (index < 0 || index >= cells.length - 1 || tableWidth <= 0) {
return
}
event.preventDefault()
handle.setPointerCapture(event.pointerId)
handle.dataset.mdColActive = 'true'
draggingRef.current = true
// Seed from what is on screen, so the first drag continues the auto layout
// the user was looking at instead of snapping to even columns.
const start = cells.map(cell => (cell.getBoundingClientRect().width / tableWidth) * 100)
const pair = start[index] + start[index + 1]
const min = Math.min((MIN_COLUMN_PX / tableWidth) * 100, pair / 2)
const rtl = getComputedStyle(table).direction === 'rtl'
const startX = event.clientX
let next = start
const onMove = (move: PointerEvent) => {
const delta = ((rtl ? startX - move.clientX : move.clientX - startX) / tableWidth) * 100
const leading = Math.min(Math.max(start[index] + delta, min), pair - min)
next = start.map((value, at) => (at === index ? leading : at === index + 1 ? pair - leading : value))
setWidths(next)
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
delete handle.dataset.mdColActive
draggingRef.current = false
if (keyRef.current && next !== start) {
writeTableWidths(keyRef.current, next)
}
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}, [])
// Double-click a seam to hand the columns back to auto layout — the same
// reset gesture the pane sashes use.
const onDoubleClick = useCallback((event: ReactMouseEvent<HTMLTableElement>) => {
if (!(event.target as HTMLElement | null)?.closest('[data-md-col-handle]')) {
return
}
if (keyRef.current) {
clearTableWidths(keyRef.current)
}
setWidths(null)
}, [])
return (
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-(--ui-stroke-tertiary)">
<table
className={cn(
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-(--ui-stroke-tertiary) last:[&_tr]:border-0',
widths && 'table-fixed [&_td]:wrap-anywhere',
className
)}
onDoubleClick={onDoubleClick}
onPointerDown={onPointerDown}
ref={tableRef}
{...props}
>
{widths && (
<colgroup>
{widths.map((width, index) => (
<col key={index} style={{ width: `${width}%` }} />
))}
</colgroup>
)}
{children}
</table>
</div>
)
}
export function ResizableMarkdownTh({ children, className, ...props }: ComponentProps<'th'>) {
return (
<th
className={cn(
'relative px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
// The trailing column has no seam: its right edge is the table's edge,
// and there is nothing on the far side to trade width with.
'[&:last-child_[data-md-col-handle]]:hidden',
className
)}
{...props}
>
{/* Truncation lives on an inner box, not the cell: the grab band straddles
the cell's edge, so a clipping `<th>` would cut half of it off. */}
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">{children}</span>
{/* Invisible grab band straddling the seam, with the hairline revealed on
hover — the pane sash treatment (`tree-split.tsx`) scaled to a header
row. The table carries no vertical rules otherwise, so the line only
exists while you are reaching for it. */}
<span
aria-hidden
className="group/mdcol absolute inset-y-0 -end-1 z-10 w-2 cursor-col-resize select-none"
data-md-col-handle
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary) opacity-0 transition-opacity duration-100 group-hover/mdcol:opacity-100 [[data-md-col-active]_&]:opacity-100" />
</span>
</th>
)
}
@@ -40,6 +40,7 @@ import { cn } from '@/lib/utils'
import { ArtifactCard } from './artifact-card'
import { SessionRefLink } from './directive-text'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
import { ResizableMarkdownTable, ResizableMarkdownTh } from './markdown-table'
import { paragraphPlainText, TranscriptDirectiveLeaf, useIsClaimedDirective } from './transcript-directive'
// Math rendering plugin (KaTeX). Configured once at module scope — the
@@ -602,29 +603,14 @@ function MarkdownTextSurface({
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-(--ui-stroke-tertiary)">
<table
className={cn(
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-(--ui-stroke-tertiary) last:[&_tr]:border-0',
className
)}
{...props}
/>
</div>
),
// Columns are drag-resizable; the widths live outside the transcript
// (see markdown-table-widths.ts) so a new turn or a session switch
// doesn't undo a resize.
table: ResizableMarkdownTable,
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
<thead className={cn('m-0 bg-muted/35 text-muted-foreground', className)} {...props} />
),
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'whitespace-nowrap px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
className
)}
{...props}
/>
),
th: ResizableMarkdownTh,
td: ({ className, ...props }: ComponentProps<'td'>) => (
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),