Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1379,7 +1379,13 @@ Set `autoScroll` to keep the container pinned to the bottom as output grows -- i
<HighlightStream language={typescript} {code} {done} autoScroll style="max-height: 20em; overflow-y: auto;" />
```

Per-chunk work is O(tail), not O(stream length so far): finished output is sealed into immutable chunks the DOM never re-diffs, so a response that's ten times longer doesn't cost ten times more per repaint. DOM updates stay proportional to the changed lines -- fine for chat-sized output up to very long responses, not a substitute for `HighlightVirtual` below if you also need to *scroll* through a huge, already-complete document.
Per-chunk work is O(tail), not O(stream length so far): finished output is sealed into immutable chunks the DOM never re-diffs, so a response that's ten times longer doesn't cost ten times more per repaint. DOM updates stay proportional to the changed lines -- fine for chat-sized output up to very long responses, but every line ever streamed still stays in the DOM; for a stream that runs long enough to *scroll* through, set `virtualize` to bound that too.

```svelte
<HighlightStream language={typescript} {code} {done} virtualize style="height: 20em;" />
```

`virtualize` renders only the lines within the scrolled viewport (plus `overscan`), the same windowing `HighlightVirtual` does for static documents -- a stream that runs to tens of thousands of lines still costs a couple dozen DOM nodes. It swaps the sealed-chunk session for `TokenizedDocument` (see [Large documents](#large-documents) below), so output always reflects the streaming (non-canonicalized) parse, even once `done` -- unlike the default mode, which upgrades to a canonical final render. `on:highlight` isn't dispatched in this mode, since materializing the full HTML on every repaint would defeat the point of windowing; `on:done`, the caret, and `autoScroll` all keep working.

## Large documents

Expand Down Expand Up @@ -1737,19 +1743,22 @@ Use `bind:this`, then call `undo()`, `redo()`, `focus()`, `selectAll()`, `insert

#### Props

| Name | Type | Default value |
| :--------- | :--------------------------------------------- | :------------- |
| code | `string` | `""` |
| language | { name: `string`; register: `object` } | N/A (required) |
| done | `boolean` | `false` |
| caret | `boolean` | `true` |
| autoScroll | `boolean` | `false` |
| Name | Type | Default value |
| :----------------- | :-------------------------------------- | :------------- |
| code | `string` | `""` |
| language | { name: `string`; register: `object` } | N/A (required) |
| done | `boolean` | `false` |
| caret | `boolean` | `true` |
| autoScroll | `boolean` | `false` |
| virtualize | `boolean` | `false` |
| overscan | `number` | `12` |
| checkpointInterval | `number` | `100` |

`$$restProps` are forwarded to the top-level `pre` element.
`$$restProps` are forwarded to the top-level `pre` element. `overscan` and `checkpointInterval` only apply when `virtualize` is set.

#### Dispatched Events

- **on:highlight**: fired after each highlight pass, with `{ highlighted }`
- **on:highlight**: fired after each highlight pass, with `{ highlighted }` -- not dispatched when `virtualize` is set
- **on:done**: fired after the final full highlight once `done` is set

```svelte
Expand Down
221 changes: 218 additions & 3 deletions src/HighlightStream.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,39 @@
*/
export let autoScroll = false;

/**
* Render only the lines within the scrolled viewport (plus `overscan`)
* instead of the whole growing buffer, so a long-running stream costs a
* bounded number of DOM nodes instead of one per line. Backed by
* `createTokenizedDocument` rather than the default sealed-chunk session,
* so output always reflects the streaming (non-canonicalized) parse, even
* once `done` - the same tradeoff `HighlightVirtual` makes. `on:highlight`
* is not dispatched in this mode, since materializing the full HTML on
* every repaint would defeat the point of windowing.
* @type {boolean}
*/
export let virtualize = false;

/**
* Extra lines rendered above and below the viewport when `virtualize` is
* set.
* @type {number}
*/
export let overscan = 12;

/**
* Lines between engine checkpoints when `virtualize` is set (forwarded to
* `createTokenizedDocument`).
* @type {number}
*/
export let checkpointInterval = 100;

import { createEventDispatcher, onMount, tick } from "svelte";
import { extendLines } from "./engine.js";
import { ensureRegistered, registry } from "./registry.js";
import { createCompletedHtmlBuffer } from "./stream-highlighted.js";
import { createTokenizedDocument } from "./tokenized-document.js";
import { watchLineHeight, windowRange } from "./virtual-window.js";

// Lines between sealed chunks. Once a chunk fills, its line spans are
// joined into one immutable HTML string and never touched again - keyed
Expand Down Expand Up @@ -60,6 +89,28 @@
// Stick to bottom until the user scrolls away from it.
let stickToBottom = true;

// `virtualize` state: a random-access tokenized document (rather than the
// sealed-chunk session above) windowed the same way `HighlightVirtual`
// windows a static document.
/** @type {HTMLElement} */
let probe;
let vLineHeight = 16;
let vScrollTop = 0;
let vClientHeight = 0;
/** @type {ReturnType<typeof requestAnimationFrame> | undefined} */
let vFrame;
/** @type {ResizeObserver | undefined} */
let resizeObserver;
/** @type {ReturnType<typeof createTokenizedDocument> | undefined} */
let vdoc;
let vdocLanguageName = "";
let vdocCheckpointInterval;
let vLineCount = 0;
let vStart = 0;
let vEnd = 0;
/** @type {string[]} */
let vVisibleLines = [];

/** @type {ReturnType<typeof registry.createSession> | undefined} */
let session;
let sessionLanguageName = "";
Expand Down Expand Up @@ -204,6 +255,7 @@
const gap =
container.scrollHeight - container.scrollTop - container.clientHeight;
stickToBottom = gap <= 4;
if (virtualize) scheduleVirtualRepaint();
}

function cancelFrame() {
Expand All @@ -222,10 +274,94 @@
});
}

function ensureVirtualDoc() {
if (
vdoc &&
vdocLanguageName === language.name &&
vdocCheckpointInterval === checkpointInterval
) {
return;
}
vdoc = createTokenizedDocument({ language, checkpointInterval });
vdocLanguageName = language.name;
vdocCheckpointInterval = checkpointInterval;
}

function computeVirtualWindow() {
if (!vdoc) return;
const total = vdoc.lineCount();
vLineCount = total;
({ start: vStart, end: vEnd } = windowRange({
scrollTop: vScrollTop,
clientHeight: vClientHeight,
lineHeight: vLineHeight,
overscan,
total,
}));
vVisibleLines = vdoc.lineRange(vStart, vEnd);
}

// Mirrors `scrollToBottom`/the shrink-clamp in `HighlightVirtual`, merged:
// while streaming with `autoScroll`, stick to the (growing) bottom; once
// the user scrolls away, just keep the scroll position in bounds.
async function syncVirtualFromContainer() {
await tick();
if (!container) return;
if (autoScroll && stickToBottom) {
container.scrollTop = container.scrollHeight;
} else {
const maxScrollTop = Math.max(
0,
container.scrollHeight - container.clientHeight,
);
if (container.scrollTop > maxScrollTop) {
container.scrollTop = maxScrollTop;
}
}
vScrollTop = container.scrollTop;
vClientHeight = container.clientHeight;
}

function cancelVirtualFrame() {
if (vFrame != null) {
cancelAnimationFrame(vFrame);
vFrame = undefined;
}
}

// Coalesce scroll bursts into one window recompute per frame.
function scheduleVirtualRepaint() {
if (vFrame != null) return;
vFrame = requestAnimationFrame(() => {
vFrame = undefined;
if (container) vScrollTop = container.scrollTop;
});
}

function measureVirtualLineHeight() {
return watchLineHeight(
() => probe,
() => vLineHeight,
(height) => (vLineHeight = height),
);
}

$: {
void code;
void language;
if (mounted && !done) {
if (virtualize) {
// Content/window updates are handled by the virtualize-specific
// reactive blocks below; this block only tracks `done` dispatch so
// both modes share the same guard/reset semantics.
if (mounted && done) {
if (!doneDispatched) {
doneDispatched = true;
dispatch("done");
}
} else {
doneDispatched = false;
}
} else if (mounted && !done) {
doneDispatched = false;
scheduleRepaint();
} else {
Expand All @@ -239,20 +375,99 @@
}
}

// Rebuilds/updates the virtualized document whenever its content or shape
// changes. Deliberately separate from the scroll-driven block below, same
// reasoning as `HighlightVirtual`.
$: if (virtualize && mounted) {
void code;
void language;
void checkpointInterval;
ensureVirtualDoc();
vdoc.setCode(code);
vLineCount = vdoc.lineCount();
computeVirtualWindow();
syncVirtualFromContainer();
}

// Scroll/resize/overscan/lineHeight-driven window recompute.
$: if (virtualize && mounted) {
void overscan;
void vLineHeight;
void vScrollTop;
void vClientHeight;
void vLineCount;
computeVirtualWindow();
}

$: useSplitRendering = mounted && !done;
$: showCaret = useSplitRendering && caret;

onMount(() => {
mounted = true;
return cancelFrame;
if (virtualize) {
measureVirtualLineHeight();
syncVirtualFromContainer();
if (typeof ResizeObserver !== "undefined" && container) {
resizeObserver = new ResizeObserver(() => {
if (container) vClientHeight = container.clientHeight;
});
resizeObserver.observe(container);
}
}
return () => {
cancelFrame();
cancelVirtualFrame();
resizeObserver?.disconnect();
};
});
</script>

<pre bind:this={container} on:scroll={onScroll} {...$$restProps}><code
{#if virtualize}
<pre
bind:this={container}
class:hljs={true}
class:shl-virtual={true}
on:scroll={onScroll}
{...$$restProps}
><code>{#if !mounted}{code}{:else}<span class="shl-virtual-sizer" style="height: {vLineCount * vLineHeight}px;"><span class="shl-virtual-window" style="transform: translateY({vStart * vLineHeight}px);">{#each vVisibleLines as line, i (vStart + i)}<span class="highlight-stream-line" data-line={vStart + i}>{@html line}</span>{#if showCaret && vEnd === vLineCount && i === vVisibleLines.length - 1}<span class="highlight-stream-caret" aria-hidden="true"></span>{/if}{"\n"}{/each}</span></span>{/if}</code><span
bind:this={probe}
class="shl-virtual-probe highlight-stream-line"
aria-hidden="true"
>&nbsp;</span></pre>
{:else}
<pre bind:this={container} on:scroll={onScroll} {...$$restProps}><code
class:hljs={true}
>{#if useSplitRendering}{#each sealedChunks as chunk, c (c)}{@html chunk}{/each}{#each tailLines as line, li (sealedLineCount + li)}{#if sealedLineCount + li > 0}{"\n"}{/if}<span class="highlight-stream-line" data-line={sealedLineCount + li}>{@html line}</span>{/each}{#if showCaret}<span class="highlight-stream-caret" aria-hidden="true"></span>{/if}{:else}{@html highlighted}{/if}</code></pre>
{/if}

<style>
.shl-virtual {
display: block;
position: relative;
overflow: auto;
white-space: pre;
margin: 0;
}

.shl-virtual-sizer {
display: block;
position: relative;
}

.shl-virtual-window {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
}

.shl-virtual-probe {
position: absolute;
visibility: hidden;
pointer-events: none;
}

.highlight-stream-caret {
display: inline-block;
width: var(--caret-width, 0.6em);
Expand Down
25 changes: 25 additions & 0 deletions src/HighlightStream.svelte.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ export type HighlightStreamProps = HTMLAttributes<HTMLPreElement> & {
*/
autoScroll?: boolean;

/**
* Render only the lines within the scrolled viewport (plus `overscan`)
* instead of the whole growing buffer, so a long-running stream costs a
* bounded number of DOM nodes instead of one per line. Backed by
* `createTokenizedDocument` rather than the sealed-chunk session used
* otherwise, so output always reflects the streaming (non-canonicalized)
* parse, even once `done` -- the same tradeoff `HighlightVirtual` makes.
* `on:highlight` is not dispatched in this mode.
* @default false
*/
virtualize?: boolean;

/**
* Extra lines rendered above and below the viewport when `virtualize` is set.
* @default 12
*/
overscan?: number;

/**
* Lines between engine checkpoints when `virtualize` is set (forwarded to
* `createTokenizedDocument`).
* @default 100
*/
checkpointInterval?: number;

/**
* Width of the blinking caret.
* @default "0.6em"
Expand Down
Loading