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