264 lines
10 KiB
JavaScript
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 }; },
|
|
};
|
|
}
|