Files
rustpad/static/js/virtual-editor-layers.js
T
2026-10-16 22:49:22 +02:00

264 lines
10 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczynski @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Virtualize only decorative editor layers. The textarea, OT document,
* preview renderer, selection, history, and line link URLs are unchanged.
*/
export const VIRTUAL_LINE_THRESHOLD = 1500;
const OVERSCAN = 8;
export function buildLineStarts(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) starts.push(i + 1);
}
return starts;
}
export function visibleLineWindow(scrollTop, viewportHeight, paddingTop, lineHeight, total, overscan = OVERSCAN) {
const height = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0;
const top = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0;
const rowHeight = Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
const offset = Number.isFinite(paddingTop) ? paddingTop : 0;
const from = Math.max(0, Math.floor((top - offset) / rowHeight));
const to = Math.max(from + 1, Math.ceil((top + height - offset) / rowHeight));
return {
first: Math.max(0, Math.min(total, from - overscan)),
end: Math.max(0, Math.min(total, to + overscan)),
};
}
export function labelGroupLines(authorsByLine, ownerName) {
const groups = [];
let previousSignature = null;
for (let i = 0; i < authorsByLine.length; i++) {
const authors = authorsByLine[i] || [];
if (!authors.length) continue;
const signature = authors.map(ownerName).sort((a, b) => a.localeCompare(b)).join('\u0000');
if (signature === previousSignature) continue;
previousSignature = signature;
groups.push({ line: i, authors });
}
return groups;
}
function lowerBound(items, value, selector = item => item) {
let left = 0, right = items.length;
while (left < right) {
const mid = (left + right) >>> 1;
if (selector(items[mid]) < value) left = mid + 1;
else right = mid;
}
return left;
}
export function createVirtualEditorLayers({ editor, gutter, ownerLabels, authorshipLayer, colorFor, ownerName }) {
let active = false;
let starts = [0], text = '', spans = [], groups = [];
let showAuthorship = false, showLabels = false, linkedLine = null;
let lineHeight = 20, paddingTop = 0, paddingLeft = 0;
let spacer, gutterWindow, labelCanvas, colorCanvas, colorViewport;
let renderedFirst = -1, renderedEnd = -1;
let lineNodes = new Map(), labelNodes = new Map();
function initialize() {
if (active) return;
active = true;
spacer = document.createElement('div');
spacer.className = 'line-gutter-virtual-spacer';
spacer.style.position = 'relative';
spacer.style.width = '100%';
gutterWindow = document.createElement('div');
gutterWindow.className = 'line-gutter-virtual-window';
gutterWindow.style.position = 'absolute';
gutterWindow.style.left = '0';
gutterWindow.style.right = '0';
spacer.append(gutterWindow);
gutter.replaceChildren(spacer);
labelCanvas = document.createElement('div');
labelCanvas.className = 'owner-labels-canvas';
ownerLabels.replaceChildren(labelCanvas);
colorCanvas = document.createElement('div');
colorCanvas.className = 'authorship-canvas';
colorViewport = document.createElement('div');
colorViewport.className = 'authorship-viewport';
colorViewport.style.position = 'absolute';
colorViewport.style.whiteSpace = 'pre';
colorCanvas.append(colorViewport);
authorshipLayer.replaceChildren(colorCanvas);
}
function createLineNode() {
const row = document.createElement('div');
row.className = 'line-number-row';
const button = document.createElement('button');
button.className = 'line-number-button';
button.type = 'button';
button.tabIndex = -1;
row.append(button);
return row;
}
function updateGutter(first, end, force) {
if (!force && first === renderedFirst && end === renderedEnd) return;
const available = [...lineNodes.values()].filter(node => {
const line = Number(node.firstElementChild.dataset.line);
return line < first + 1 || line > end;
});
const next = new Map(), fragment = document.createDocumentFragment();
for (let i = first; i < end; i++) {
const line = i + 1;
const row = lineNodes.get(line) || available.pop() || createLineNode();
row.style.height = `${lineHeight}px`;
const button = row.firstElementChild;
if (button.dataset.line !== String(line)) {
button.dataset.line = String(line);
button.textContent = String(line);
button.setAttribute('aria-label', `Copy link to line ${line}`);
button.classList.remove('is-copied');
}
button.classList.toggle('is-linked', linkedLine === line);
next.set(line, row);
fragment.append(row);
}
gutterWindow.style.top = `${first * lineHeight}px`;
gutterWindow.replaceChildren(fragment);
lineNodes = next;
}
function createLabelNode(group) {
const node = document.createElement('span');
node.className = 'owner-label-group';
for (const owner of group.authors) {
const badge = document.createElement('span');
badge.className = 'owner-label';
badge.style.setProperty('--owner', colorFor(owner));
badge.textContent = ownerName(owner);
node.append(badge);
}
return node;
}
function updateLabels(first, end, force) {
if (!showLabels) {
if (labelNodes.size) labelCanvas.replaceChildren();
labelNodes.clear();
return;
}
if (!force && first === renderedFirst && end === renderedEnd) return;
const next = new Map(), fragment = document.createDocumentFragment();
for (let i = lowerBound(groups, first, item => item.line); i < groups.length && groups[i].line < end; i++) {
const group = groups[i];
const node = labelNodes.get(group.line) || createLabelNode(group);
node.style.top = `${paddingTop + group.line * lineHeight + lineHeight / 2}px`;
next.set(group.line, node);
fragment.append(node);
}
labelCanvas.replaceChildren(fragment);
labelNodes = next;
}
function updateColors(first, end, force) {
if (!showAuthorship) {
if (colorViewport.hasChildNodes()) colorViewport.replaceChildren();
return;
}
if (!force && first === renderedFirst && end === renderedEnd) return;
const from = starts[first] ?? text.length;
const to = end < starts.length ? starts[end] : text.length;
const fragment = document.createDocumentFragment();
let cursor = from;
let index = lowerBound(spans, from + 1, item => item.end);
while (index < spans.length) {
const span = spans[index++];
if (span.start >= to) break;
const start = Math.max(cursor, span.start, from);
const stop = Math.min(span.end, to);
if (start > cursor) fragment.append(document.createTextNode(text.slice(cursor, start)));
if (stop <= start) continue;
const mark = document.createElement('span');
mark.className = 'authorship-fragment';
mark.style.setProperty('--owner', colorFor(span.owner));
mark.textContent = text.slice(start, stop);
mark.title = String(span.owner).split('\u001f', 1)[0];
fragment.append(mark);
cursor = stop;
}
if (cursor < to) fragment.append(document.createTextNode(text.slice(cursor, to)));
if (end === starts.length && !text.endsWith('\n')) fragment.append(document.createTextNode('\n'));
colorViewport.style.top = `${paddingTop + first * lineHeight}px`;
colorViewport.style.left = `${paddingLeft}px`;
colorViewport.replaceChildren(fragment);
}
function updateViewport(force = false) {
if (!active) return;
const { first, end } = visibleLineWindow(
editor.scrollTop, editor.clientHeight, paddingTop, lineHeight, starts.length,
);
if (!force && first === renderedFirst && end === renderedEnd) return;
updateGutter(first, end, force);
updateLabels(first, end, force);
updateColors(first, end, force);
renderedFirst = first;
renderedEnd = end;
}
return {
get active() { return active; },
update({ content, lineStarts, model, authorsByLine, full, visible, link, computedStyle }) {
initialize();
text = content;
starts = lineStarts;
spans = model?.spans || [];
showAuthorship = visible;
showLabels = visible && full;
linkedLine = link;
const style = computedStyle;
lineHeight = parseFloat(style.lineHeight) || 29;
paddingTop = parseFloat(style.paddingTop) || 24;
paddingLeft = parseFloat(style.paddingLeft) || 0;
spacer.style.height = `${starts.length * lineHeight}px`;
gutterWindow.style.lineHeight = `${lineHeight}px`;
colorCanvas.style.paddingTop = style.paddingTop;
colorCanvas.style.paddingRight = style.paddingRight;
colorCanvas.style.paddingBottom = style.paddingBottom;
colorCanvas.style.paddingLeft = style.paddingLeft;
colorCanvas.style.fontFamily = style.fontFamily;
colorCanvas.style.fontSize = style.fontSize;
colorCanvas.style.fontWeight = style.fontWeight;
colorCanvas.style.lineHeight = style.lineHeight;
colorCanvas.style.letterSpacing = style.letterSpacing;
colorCanvas.style.minWidth = `${editor.scrollWidth}px`;
colorCanvas.style.minHeight = `${editor.scrollHeight}px`;
authorshipLayer.style.left = `${editor.offsetLeft}px`;
groups = showLabels ? labelGroupLines(authorsByLine, ownerName) : [];
labelNodes = new Map();
updateViewport(true);
},
sync() { updateViewport(); },
highlight(line) {
linkedLine = line;
for (const [number, row] of lineNodes) {
row.firstElementChild.classList.toggle('is-linked', number === line);
}
},
destroy() {
if (!active) return;
active = false;
gutter.replaceChildren();
ownerLabels.replaceChildren();
authorshipLayer.replaceChildren();
lineNodes = new Map();
labelNodes = new Map();
renderedFirst = renderedEnd = -1;
groups = [];
spans = [];
text = '';
},
get range() { return { first: renderedFirst, end: renderedEnd }; },
};
}