diff --git a/src/lib/grammar.ts b/src/lib/grammar.ts index 6e3791a..6af32f2 100644 --- a/src/lib/grammar.ts +++ b/src/lib/grammar.ts @@ -30,7 +30,11 @@ type Node = export type Token = { text: string; start: number }; -/** A span of the source bound to a placeholder (`name`) or matched literally. */ +/** + * A span of the line bound to a placeholder (`name`) or matched literally. + * Offsets are relative to the start of the line, so a parsed line can be cached + * and reused wherever in a description the same text turns up. + */ export type Match = { text: string; start: number; end: number; name: string | null }; export type MatchResult = { @@ -236,12 +240,7 @@ const matchAtoms = ( * forgiving — a line being typed is malformed most of the time — so it binds * what it can and reports where it gave up rather than failing outright. */ -export const matchTemplate = ( - grammar: Grammar, - tokens: Token[], - source: string, - sourceStart: number -): MatchResult => { +export const matchTemplate = (grammar: Grammar, tokens: Token[], source: string): MatchResult => { // A greedy argument runs to the end of the last token rather than the end of // the line, so trailing whitespace never lands inside a highlighted field. const last = tokens[tokens.length - 1]; @@ -277,7 +276,7 @@ export const matchTemplate = ( const tail = bound.matches[bound.matches.length - 1]; if (tail?.name && grammar.isGreedy(tail.name) && consumed < tokens.length) { tail.end = lineEnd; - tail.text = source.slice(tail.start - sourceStart, lineEnd - sourceStart); + tail.text = source.slice(tail.start, lineEnd); consumed = tokens.length; } diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 6641915..ab2fd87 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -26,10 +26,27 @@ const inline = (text: string) => : match ); -export const markdown = (text: string) => +const render = (text: string) => text .split(/\n{2,}/) .map((paragraph) => paragraph.trim()) .filter(Boolean) .map((paragraph) => `

${inline(paragraph)}

`) .join(''); + +// The panel re-renders every prose block it shows each time the active token +// changes — which, on hover, is as often as the pointer crosses a field. The +// input is drawn from the fixed set of strings in the spec, so rendering each +// one once and keeping it costs a bounded amount of memory. +const rendered = new Map(); + +export const markdown = (text: string) => { + let html = rendered.get(text); + + if (html === undefined) { + html = render(text); + rendered.set(text, html); + } + + return html; +}; diff --git a/src/lib/parser.ts b/src/lib/parser.ts index eb1630f..fe15362 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -4,11 +4,39 @@ import { grammarFor, matchTemplate, type Token } from './grammar'; import { attributeName, lookup } from './spec'; -import type { SDPField, SDPLine, SDPLocation, SDPPart } from './types'; +import type { Details, SDPField, SDPLine, SDPLocation, SDPOutline, SDPPart } from './types'; const LINE_BREAK = /\r\n|\r|\n/g; const TOKEN = /\S+/g; +/** Everything about a line that is decided by its own text and nothing else. */ +type LineCore = Pick; + +/** + * Parsing a line is pure in its content, so the same text always yields the same + * core. An edit only ever rewrites the line the caret is on, which means a + * keystroke reparses one line and reuses every other — what stops a long + * description from being rebuilt from scratch between two characters. + */ +const cores = new Map(); + +// Every intermediate state of a line being typed is cached too, so the cache is +// dropped whole once it outgrows any plausible description rather than being +// aged an entry at a time. +const CORE_LIMIT = 8192; + +const coreFor = (content: string): LineCore => { + const cached = cores.get(content); + if (cached) return cached; + + const core = parseLine(content); + + if (cores.size >= CORE_LIMIT) cores.clear(); + cores.set(content, core); + + return core; +}; + /** * Parses an SDP body into lines that keep their absolute character offsets in * the source text, so the caret position in the editor can be mapped back onto @@ -26,10 +54,10 @@ export const parseSDP = (sdp: string): SDPLine[] => { LINE_BREAK.lastIndex = 0; while ((match = LINE_BREAK.exec(sdp)) !== null) { - lines.push(parseLine(sdp.slice(lineStart, match.index), lineStart)); + lines.push(place(sdp.slice(lineStart, match.index), lineStart)); lineStart = match.index + match[0].length; } - lines.push(parseLine(sdp.slice(lineStart), lineStart)); + lines.push(place(sdp.slice(lineStart), lineStart)); // Everything after an "m=" line belongs to that media description, and // attributes read differently there — a media-level "a=mid" names a stream, @@ -50,6 +78,38 @@ export const parseSDP = (sdp: string): SDPLine[] => { return lines; }; +/** Anchors a line's cached core at the offset this document puts it at. */ +const place = (content: string, start: number): SDPLine => ({ + ...coreFor(content), + start, + end: start + content.length, + section: null +}); + +/** + * The two whole-document facts the editor's chrome needs, gathered in the one + * pass the lines are already being walked in: the run of lines each media + * description spans, for the gutter marker, and how wide the widest line is, + * which is what the overlay reserves room for while most lines go unrendered. + */ +export const outlineOf = (lines: SDPLine[]): SDPOutline => { + const sections = new Map(); + let columns = 0; + + lines.forEach((line, i) => { + if (line.content.length > columns) columns = line.content.length; + if (line.section === null) return; + + const span = sections.get(line.section); + if (!span) sections.set(line.section, { first: i, count: 1 }); + // Blank lines trailing the section say nothing about it, so the marker + // stops at the last line that does. + else if (line.content.trim()) span.count = i - span.first + 1; + }); + + return { sections, columns }; +}; + const tokenize = (value: string, valueStart: number): Token[] => { const tokens: Token[] = []; let token: RegExpExecArray | null; @@ -62,15 +122,8 @@ const tokenize = (value: string, valueStart: number): Token[] => { return tokens; }; -const parseLine = (content: string, start: number): SDPLine => { - const line: SDPLine = { - content, - start, - end: start + content.length, - fields: [], - parts: [], - section: null - }; +const parseLine = (content: string): LineCore => { + const line: LineCore = { content, fields: [], parts: [] }; // An SDP line is "="; anything else is free text. const indent = content.length - content.trimStart().length; @@ -81,28 +134,27 @@ const parseLine = (content: string, start: number): SDPLine => { line.type = content[indent]; - const valueStart = start + indent + 2; - const value = content.slice(indent + 2); - const tokens = tokenize(value, valueStart); + const valueStart = indent + 2; + const tokens = tokenize(content.slice(valueStart), valueStart); if (line.type === 'a' && tokens.length) line.attribute = attributeName(tokens[0].text); line.details = lookup(line.type, tokens[0]?.text ?? ''); - line.fields = bindFields(line, tokens, content, start); + line.fields = bindFields(line.details, tokens, content); line.parts = buildParts(line, indent); return line; }; /** Runs the line's grammar over its tokens and resolves each match to an argument. */ -const bindFields = (line: SDPLine, tokens: Token[], content: string, start: number): SDPField[] => { +const bindFields = (details: Details | undefined, tokens: Token[], content: string): SDPField[] => { const fields: SDPField[] = []; let consumed = 0; - if (line.details) { - const grammar = grammarFor(line.details); - const result = matchTemplate(grammar, tokens, content, start); + if (details) { + const grammar = grammarFor(details); + const result = matchTemplate(grammar, tokens, content); consumed = result.consumed; @@ -139,42 +191,51 @@ const bindFields = (line: SDPLine, tokens: Token[], content: string, start: numb * fields — ends up in exactly one part. That keeps the rendered overlay * glyph-for-glyph aligned with the textarea underneath. */ -const buildParts = (line: SDPLine, indent: number): SDPPart[] => { - const { content, start, type } = line; +const buildParts = (line: LineCore, indent: number): SDPPart[] => { + const { content, type } = line; const parts: SDPPart[] = []; if (indent) parts.push({ text: content.slice(0, indent), kind: 'plain' }); parts.push({ text: type as string, kind: 'type' }); - let cursor = start + indent + 1; + let cursor = indent + 1; line.fields.forEach((field, fieldIndex) => { if (field.start > cursor) { - parts.push({ text: content.slice(cursor - start, field.start - start), kind: 'plain' }); + parts.push({ text: content.slice(cursor, field.start), kind: 'plain' }); } parts.push({ text: field.text, kind: field.kind, fieldIndex }); cursor = field.end; }); - if (cursor < line.end) parts.push({ text: content.slice(cursor - start), kind: 'plain' }); + if (cursor < content.length) parts.push({ text: content.slice(cursor), kind: 'plain' }); return parts; }; /** Maps a caret offset in the source text onto the line and field it sits in. */ export const locate = (lines: SDPLine[], pos: number): SDPLocation | null => { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (pos > line.end) continue; + if (!lines.length) return null; - for (let j = 0; j < line.fields.length; j++) { - const field = line.fields[j]; - if (pos >= field.start && pos <= field.end) return { lineIndex: i, fieldIndex: j }; - } + // Lines are in order and cover the text end to end, so the one holding an + // offset is a binary search rather than a walk down from the top. + let low = 0; + let high = lines.length - 1; + + while (low < high) { + const mid = (low + high) >> 1; + if (pos > lines[mid].end) low = mid + 1; + else high = mid; + } + + const line = lines[low]; + const offset = pos - line.start; - return { lineIndex: i, fieldIndex: null }; + for (let j = 0; j < line.fields.length; j++) { + const field = line.fields[j]; + if (offset >= field.start && offset <= field.end) return { lineIndex: low, fieldIndex: j }; } - return lines.length ? { lineIndex: lines.length - 1, fieldIndex: null } : null; + return { lineIndex: low, fieldIndex: null }; }; diff --git a/src/lib/types.ts b/src/lib/types.ts index 94f3c91..2e499fb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -33,12 +33,17 @@ export type Details = { level?: Level; }; -/** One matched span of a line: either a grammar literal or a bound argument. */ +/** + * One matched span of a line: either a grammar literal or a bound argument. + * Offsets are relative to the line, not to the description, so the same line + * text parses to the same fields wherever it appears — which is what lets the + * parser cache a line and reuse it across edits. + */ export type SDPField = { text: string; - /** Absolute offset of the first character in the source text. */ + /** Offset of the first character, from the start of the line. */ start: number; - /** Absolute offset just past the last character in the source text. */ + /** Offset just past the last character, from the start of the line. */ end: number; /** Index into the owning `Details.args`, or null for literals and unmatched tokens. */ argIndex: number | null; @@ -55,7 +60,9 @@ export type SDPPart = { export type SDPLine = { content: string; + /** Absolute offset of the line's first character in the source text. */ start: number; + /** Absolute offset just past the line's last character in the source text. */ end: number; /** The single character before the "=", when the line has the shape "x=value". */ type?: string; @@ -72,3 +79,11 @@ export type SDPLine = { /** A caret or pointer position resolved onto a line and, when inside one, a field. */ export type SDPLocation = { lineIndex: number; fieldIndex: number | null }; + +/** Whole-description measurements the editor's chrome is laid out from. */ +export type SDPOutline = { + /** Media section index → the run of lines it spans, for the gutter marker. */ + sections: Map; + /** Length of the longest line, which sets how far the editor scrolls sideways. */ + columns: number; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4114585..affdcda 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,20 +8,32 @@ SPDX-License-Identifier: MIT import pionLogo from '$lib/assets/pion-logo.svg?raw'; import { syntaxParts } from '$lib/grammar'; import { markdown } from '$lib/markdown'; - import { locate, parseSDP } from '$lib/parser'; + import { locate, outlineOf, parseSDP } from '$lib/parser'; import { exampleSDP } from '$lib/spec'; import { theme } from '$lib/theme.svelte'; import type { SDPLocation, SDPPart } from '$lib/types'; + import { tick } from 'svelte'; import { fade } from 'svelte/transition'; let editorEl = $state(); let ghostEl = $state(); + let rowsEl = $state(); let viewportEl = $state(); // The section marker is drawn outside the scrolling overlay, so it has to be // offset by hand to stay level with the lines it spans. let scrollTop = $state(0); + // Row geometry, read back from the CSS both layers already share rather than + // repeated here, so the overlay cannot drift out of step with the text. + let lineHeight = $state(24); + let padding = $state(0); + let ghostHeight = $state(0); + + // Rows built above and below the visible ones, so a scroll has something to + // show before the next render catches up. + const OVERSCAN = 8; + let sdpText = $state(exampleSDP); let caret = $state(0); let hover = $state(null); @@ -34,6 +46,25 @@ SPDX-License-Identifier: MIT let usingKeyboard = $state(false); let lines = $derived(parseSDP(sdpText)); + let outline = $derived(outlineOf(lines)); + + // Wide enough for the highest line number there is. Left as a `ch` expression + // rather than measured back in pixels, so the column the numbers sit in and + // the edge the text starts at are the same fractional width, not two roundings + // of it. + let digits = $derived(String(lines.length).length); + + // The editor never wraps and every line is one row of a known height, so + // which rows are on screen is arithmetic rather than something to measure. + // Only those are built: a description of ten thousand lines costs the same + // to render as one of fifty, and the spans the overlay does build are few + // enough that re-styling them all as the pointer moves stays free. + let firstRow = $derived(Math.max(0, Math.floor((scrollTop - padding) / lineHeight) - OVERSCAN)); + let rowCount = $derived(Math.ceil(ghostHeight / lineHeight) + OVERSCAN * 2 + 1); + let rows = $derived(lines.slice(firstRow, firstRow + rowCount)); + + // The numbers standing against those rows, one-based the way an editor counts. + let numbers = $derived(Array.from({ length: rows.length }, (_, r) => firstRow + r + 1)); let active = $derived((usingKeyboard ? null : hover) ?? locate(lines, caret)); // Where the caret sits, independent of whatever the mouse is doing. This @@ -82,21 +113,13 @@ SPDX-License-Identifier: MIT // preamble rather than a section, so they get no marker. let sectionSpan = $derived.by(() => { const section = active ? lines[active.lineIndex]?.section : null; - if (section == null) return null; - - let first = -1; - let last = -1; - lines.forEach((line, i) => { - if (line.section !== section) return; - if (first === -1) first = i; - // Blank lines trailing the section say nothing about it, so the marker - // stops at the last line that does. - if (line.content.trim()) last = i; - }); - - return first === -1 ? null : { first, count: Math.max(last, first) - first + 1 }; + return section == null ? null : (outline.sections.get(section) ?? null); }); + // The line the band runs across. A blank line has nothing to mark, so it gets + // no band even while the pointer is over it. + let highlightRow = $derived(active && lines[active.lineIndex]?.content ? active.lineIndex : null); + // Where this line sits, which is what decides whether "a=" lines are in scope. let placement = $derived( activeLine == null || activeLine.section === null @@ -146,17 +169,24 @@ SPDX-License-Identifier: MIT : caretLocation.fieldIndex === part.fieldIndex; }; + // Rows exist only for the slice of the document on screen, so a line's element + // sits at its offset within that slice — and is simply absent once the line is + // scrolled far enough out of view, which is exactly when nothing anchored to + // it should be shown either. + const lineElement = (lineIndex: number): Element | null => + rowsEl?.children[lineIndex - firstRow] ?? null; + // Found by indexing straight into the DOM from `debouncedActive` rather than // querying for a marker attribute: the marker is written by the same render // pass that this effect can run ahead of, which left the tooltip measuring - // last render's anchor. The span layout itself never lags — only classes and - // attributes do — so indexing is safe the instant `debouncedActive` changes. + // last render's anchor. The span layout itself never lags — only classes do — + // so indexing is safe the instant `debouncedActive` changes. const tooltipAnchorEl = (): Element | null => { - if (!debouncedActive || !ghostEl) return null; + if (!debouncedActive) return null; const { lineIndex, fieldIndex } = debouncedActive; const parts = lines[lineIndex]?.parts; - const lineEl = ghostEl.children[lineIndex]; + const lineEl = lineElement(lineIndex); if (!parts || !lineEl) return null; const p = parts.findIndex( @@ -181,8 +211,44 @@ SPDX-License-Identifier: MIT ]; }; + // Extending a selection (Shift+arrow) leaves `selectionStart` pinned at the + // fixed anchor — the end actually moving is whichever one `selectionEnd` + // is when growing forward, `selectionStart` when growing backward. Reading + // plain `selectionStart` while shift-selecting downward would keep tracking + // the anchor's line instead of the one the selection is growing toward. + const selectionFocus = (el: HTMLTextAreaElement) => + el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd; + const syncCaret = () => { - if (editorEl) caret = editorEl.selectionStart; + if (editorEl) caret = selectionFocus(editorEl); + }; + + // How many lines of lookahead a keyboard-driven caret keeps between itself + // and the edge of the viewport, so the next few lines it's heading toward + // are already on screen rather than arriving one keystroke at a time. + const SCROLL_MARGIN = 5; + + const applyScrollMargin = () => { + if (!editorEl) return; + + const location = locate(lines, selectionFocus(editorEl)); + if (!location) return; + + // Capped at half the viewport, so a margin wider than the editor is + // tall can't force the top and bottom checks to fight each other. + const margin = Math.min(SCROLL_MARGIN * lineHeight, (editorEl.clientHeight - lineHeight) / 2); + + const caretTop = padding + location.lineIndex * lineHeight; + const caretBottom = caretTop + lineHeight; + + const minScrollTop = caretBottom + margin - editorEl.clientHeight; + const maxScrollTop = caretTop - margin; + + if (editorEl.scrollTop < minScrollTop) { + editorEl.scrollTop = Math.min(minScrollTop, editorEl.scrollHeight - editorEl.clientHeight); + } else if (editorEl.scrollTop > maxScrollTop) { + editorEl.scrollTop = Math.max(0, maxScrollTop); + } }; /** @@ -192,12 +258,16 @@ SPDX-License-Identifier: MIT * the same height, which turns the vertical search into one division. */ const locatePoint = (x: number, y: number): SDPLocation | null => { - const first = ghostEl?.children[0]?.getBoundingClientRect(); - if (!first?.height) return null; + if (!rowsEl) return null; + + // The rows begin at `firstRow` and are a fixed height, so one measurement + // of where they start answers the vertical half outright. + const top = rowsEl.getBoundingClientRect().top; + const lineIndex = firstRow + Math.floor((y - top) / lineHeight); + if (lineIndex < 0 || lineIndex >= lines.length) return null; - const lineIndex = Math.floor((y - first.top) / first.height); - const lineEl = ghostEl?.children[lineIndex]; - if (!lineEl || lineIndex < 0 || lineIndex >= lines.length) return null; + const lineEl = lineElement(lineIndex); + if (!lineEl) return null; // Parts are rendered one span each, so a span's position is its part's index. const parts = lines[lineIndex].parts; @@ -216,10 +286,20 @@ SPDX-License-Identifier: MIT hover = pointer ? locatePoint(pointer.x, pointer.y) : null; }; + // mousemove fires several times per frame and every hit test reads layout back + // out of the DOM, so the pointer is only recorded here and resolved once, just + // before the frame that would show the result. + let hoverFrame = 0; + const onPointerMove = (event: MouseEvent) => { pointer = { x: event.clientX, y: event.clientY }; usingKeyboard = false; - syncHover(); + + if (hoverFrame) return; + hoverFrame = requestAnimationFrame(() => { + hoverFrame = 0; + syncHover(); + }); }; const onPointerLeave = () => { @@ -227,14 +307,131 @@ SPDX-License-Identifier: MIT hover = null; }; + // Scrolling moves the text under a stationary pointer and swaps out which rows + // exist at all. Both the hit test and the tooltip anchor measure those rows, so + // they have to wait for the new offset to render rather than read the old one. + let syncPending = false; + + const syncAfterRender = () => { + if (syncPending) return; + syncPending = true; + tick().then(() => { + syncPending = false; + syncHover(); + placeTooltip(); + }); + }; + + // What the editor has to scroll through, which is everything the overlay + // scrollbars are drawn from. Read back rather than derived, because the + // textarea's own layout is the authority on how wide its longest line is. + let view = $state({ top: 0, left: 0, scrollH: 0, scrollW: 0, clientH: 0, clientW: 0 }); + + const readView = () => { + if (!editorEl) return; + + view = { + top: editorEl.scrollTop, + left: editorEl.scrollLeft, + scrollH: editorEl.scrollHeight, + scrollW: editorEl.scrollWidth, + clientH: editorEl.clientHeight, + clientW: editorEl.clientWidth + }; + }; + + // Track lengths are measured rather than assumed, so the thumb arithmetic + // never has to restate the thickness the stylesheet picked. + let trackY = $state(0); + let trackX = $state(0); + + // Shown only when there is something to scroll, and each track stops short of + // the other so the two never meet in the corner. + let showY = $derived(view.scrollH > view.clientH + 1); + let showX = $derived(view.scrollW > view.clientW + 1); + + const MIN_THUMB = 24; + + const thumbFor = (offset: number, content: number, client: number, track: number) => { + const range = content - client; + if (range <= 1 || track <= 0) return null; + + const size = Math.max(MIN_THUMB, (client / content) * track); + const travel = track - size; + + return { size, travel, range, pos: travel > 0 ? (offset / range) * travel : 0 }; + }; + + let thumbY = $derived(thumbFor(view.top, view.scrollH, view.clientH, trackY)); + let thumbX = $derived(thumbFor(view.left, view.scrollW, view.clientW, trackX)); + + const percent = (offset: number, content: number, client: number) => + content > client ? Math.round((offset / (content - client)) * 100) : 0; + + /** + * Drives the editor from its overlay scrollbar. Pressing the track jumps the + * thumb under the pointer and then keeps dragging from there, so a click and + * a drag are the same gesture. Everything else follows from the editor's own + * scroll event, exactly as it does for the wheel. + */ + const startDrag = (vertical: boolean) => (event: PointerEvent) => { + const thumb = vertical ? thumbY : thumbX; + const track = event.currentTarget as HTMLElement; + if (!editorEl || !thumb) return; + + event.preventDefault(); + track.setPointerCapture(event.pointerId); + + const box = track.getBoundingClientRect(); + const along = (e: PointerEvent) => (vertical ? e.clientY - box.top : e.clientX - box.left); + + // Where on the thumb it was taken hold of; pressing the bare track centres + // the thumb on the pointer instead. + let grab = along(event) - thumb.pos; + if (grab < 0 || grab > thumb.size) grab = thumb.size / 2; + + const to = (e: PointerEvent) => { + const offset = thumb.travel > 0 ? ((along(e) - grab) / thumb.travel) * thumb.range : 0; + if (vertical) editorEl!.scrollTop = offset; + else editorEl!.scrollLeft = offset; + }; + + const stop = () => { + track.removeEventListener('pointermove', to); + track.removeEventListener('pointerup', stop); + track.removeEventListener('pointercancel', stop); + }; + + to(event); + track.addEventListener('pointermove', to); + track.addEventListener('pointerup', stop); + track.addEventListener('pointercancel', stop); + }; + const syncScroll = () => { if (!ghostEl || !editorEl) return; - ghostEl.scrollTop = editorEl.scrollTop; - ghostEl.scrollLeft = editorEl.scrollLeft; - scrollTop = editorEl.scrollTop; - // Scrolling moves the text under a stationary pointer. - syncHover(); - placeTooltip(); + + // A + + + {#if showY} + +
+ {#if thumbY} +
+ {/if} +
+ {/if} + + {#if showX} +
+ {#if thumbX} +
+ {/if} +
+ {/if}