v0.2.91
This commit is contained in:
1 parent
4da41a1404
commit
5cb6960b04
31 files changed
+1611
-2123
No files matched your search
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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 }; },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user