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

291 lines
11 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
const VERSION = 2;
const OWNER_COLOR_SEPARATOR = "\u001f";
function ownerIdentity(owner) {
return String(owner || "").split(OWNER_COLOR_SEPARATOR, 1)[0];
}
function normalize(spans, length) {
const sorted = (Array.isArray(spans) ? spans : [])
.map(span => ({
start: Math.max(0, Math.min(length, Number(span?.start) || 0)),
end: Math.max(0, Math.min(length, Number(span?.end) || 0)),
owner: String(span?.owner || ""),
}))
.filter(span => span.owner && span.end > span.start)
.sort((a, b) => a.start - b.start || a.end - b.end);
const result = [];
for (const span of sorted) {
const previous = result.at(-1);
if (previous && previous.owner === span.owner && span.start <= previous.end) {
previous.end = Math.max(previous.end, span.end);
continue;
}
if (previous && span.start < previous.end) span.start = previous.end;
if (span.end > span.start) result.push(span);
}
return result;
}
function fromLineOwners(content, owners) {
const spans = [];
let offset = 0;
content.split("\n").forEach((line, index, lines) => {
const length = line.length + (index < lines.length - 1 ? 1 : 0);
const owner = String(owners[index] || "");
if (owner && length > 0) spans.push({ start: offset, end: offset + length, owner });
offset += length;
});
return normalize(spans, content.length);
}
export function parseAuthorship(content, raw) {
let parsed;
try { parsed = typeof raw === "string" ? JSON.parse(raw || "[]") : raw; } catch { parsed = []; }
if (Array.isArray(parsed)) return { version: VERSION, spans: fromLineOwners(content, parsed) };
if (parsed && parsed.version === VERSION && Array.isArray(parsed.spans)) {
return { version: VERSION, spans: normalize(parsed.spans, content.length) };
}
return { version: VERSION, spans: [] };
}
export function serializeAuthorship(model, contentLength) {
const canonical = canonicalSpans(model, contentLength);
const spans = canonical
? canonical.map(span => ({ start: span.start, end: span.end, owner: span.owner }))
: normalize(model?.spans, contentLength);
return JSON.stringify({ version: VERSION, spans });
}
function canonicalSpans(model, length) {
const spans = model?.spans;
if (!Array.isArray(spans)) return null;
let previousEnd = 0, previousOwner = "";
for (const span of spans) {
if (!span || typeof span.owner !== "string" || !span.owner
|| !Number.isFinite(span.start) || !Number.isFinite(span.end)
|| span.start < previousEnd || span.end <= span.start || span.end > length
|| (span.start === previousEnd && span.owner === previousOwner)) return null;
previousEnd = span.end;
previousOwner = span.owner;
}
return spans;
}
function appendCanonicalSpan(result, start, end, owner) {
if (!owner || end <= start) return;
const previous = result.at(-1);
if (previous?.owner === owner && start <= previous.end) {
previous.end = Math.max(previous.end, end);
} else result.push({ start, end, owner });
}
function fastAuthorshipEdit(spans, prefix, oldSuffix, insertedLength, delta, owner) {
const result = [];
let inserted = false;
const insert = () => {
if (!inserted) {
appendCanonicalSpan(result, prefix, prefix + insertedLength, owner ? String(owner) : "");
inserted = true;
}
};
for (const span of spans) {
if (span.end <= prefix) {
appendCanonicalSpan(result, span.start, span.end, span.owner);
} else if (span.start >= oldSuffix) {
insert();
appendCanonicalSpan(result, span.start + delta, span.end + delta, span.owner);
} else {
if (span.start < prefix) appendCanonicalSpan(result, span.start, prefix, span.owner);
insert();
if (span.end > oldSuffix) {
appendCanonicalSpan(result, prefix + insertedLength, span.end + delta, span.owner);
}
}
}
insert();
return { version: VERSION, spans: result };
}
export function applyAuthorshipEdit(model, previousText, nextText, owner) {
if (previousText === nextText) return parseAuthorship(nextText, model);
let prefix = 0;
const shared = Math.min(previousText.length, nextText.length);
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
let oldSuffix = previousText.length;
let newSuffix = nextText.length;
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
oldSuffix--;
newSuffix--;
}
const removedLength = oldSuffix - prefix;
const insertedLength = newSuffix - prefix;
const delta = insertedLength - removedLength;
const canonical = canonicalSpans(model, previousText.length);
if (canonical) return fastAuthorshipEdit(canonical, prefix, oldSuffix, insertedLength, delta, owner);
const updated = [];
for (const source of normalize(model?.spans, previousText.length)) {
if (source.end <= prefix) {
updated.push({ ...source });
continue;
}
if (source.start >= oldSuffix) {
updated.push({ start: source.start + delta, end: source.end + delta, owner: source.owner });
continue;
}
if (source.start < prefix) updated.push({ start: source.start, end: prefix, owner: source.owner });
if (source.end > oldSuffix) updated.push({ start: prefix + insertedLength, end: source.end + delta, owner: source.owner });
}
if (insertedLength > 0 && owner) updated.push({ start: prefix, end: prefix + insertedLength, owner: String(owner) });
return { version: VERSION, spans: normalize(updated, nextText.length) };
}
export function replaceAuthorshipOwner(model, matcher, replacement, contentLength) {
const canonical = canonicalSpans(model, contentLength);
if (canonical) {
const spans = [];
for (const span of canonical) {
const owner = matcher(span.owner) ? String(replacement || "") : span.owner;
appendCanonicalSpan(spans, span.start, span.end, owner);
}
return { version: VERSION, spans };
}
return {
version: VERSION,
spans: normalize((model?.spans || []).map(span => matcher(span.owner) ? { ...span, owner: replacement } : span), contentLength),
};
}
export function authorshipOwners(model) {
const owners = new Map();
for (const span of model?.spans || []) {
const identity = ownerIdentity(span.owner);
if (identity) owners.set(identity, span.owner);
}
return [...owners.values()];
}
export function lineAuthors(content, model) {
const spans = model?.spans || [];
const starts = [0];
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
let canonical = true;
for (let i = 0, previousEnd = 0; i < spans.length; i++) {
const span = spans[i];
if (!Number.isFinite(span?.start) || !Number.isFinite(span?.end)
|| span.start < previousEnd || span.end < span.start) {
canonical = false;
break;
}
previousEnd = span.end;
}
let cursor = 0;
return starts.map((start, index) => {
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
const totals = new Map();
const representatives = new Map();
if (canonical) {
while (cursor < spans.length && spans[cursor].end <= start) cursor++;
}
for (let i = canonical ? cursor : 0; i < spans.length; i++) {
const span = spans[i];
if (canonical && span.start >= end) break;
const overlap = Math.max(0, Math.min(end, span.end) - Math.max(start, span.start));
if (!overlap) continue;
const identity = ownerIdentity(span.owner);
totals.set(identity, (totals.get(identity) || 0) + overlap);
representatives.set(identity, span.owner);
}
return [...totals.entries()]
.sort((a, b) => b[1] - a[1])
.map(([identity]) => representatives.get(identity));
});
}
export function mapSelectionThroughEdit(previousText, nextText, start, end = start) {
if (previousText === nextText) return { start, end };
let prefix = 0;
const shared = Math.min(previousText.length, nextText.length);
while (prefix < shared && previousText.charCodeAt(prefix) === nextText.charCodeAt(prefix)) prefix++;
let oldSuffix = previousText.length;
let newSuffix = nextText.length;
while (oldSuffix > prefix && newSuffix > prefix && previousText.charCodeAt(oldSuffix - 1) === nextText.charCodeAt(newSuffix - 1)) {
oldSuffix--;
newSuffix--;
}
const insertedLength = newSuffix - prefix;
const delta = insertedLength - (oldSuffix - prefix);
const map = position => {
if (position < prefix) return position;
if (position > oldSuffix) return position + delta;
if (position === prefix && oldSuffix === prefix) return prefix + insertedLength;
return prefix + insertedLength;
};
return {
start: Math.max(0, Math.min(nextText.length, map(start))),
end: Math.max(0, Math.min(nextText.length, map(end))),
};
}
export function syncAuthorshipLayer(layer, editor) {
if (!layer || !editor) return;
const canvas = layer.querySelector(".authorship-canvas");
if (!canvas) return;
canvas.style.transform = `translate3d(${-editor.scrollLeft}px, ${-editor.scrollTop}px, 0)`;
layer.scrollTop = 0;
layer.scrollLeft = 0;
}
export function renderAuthorshipLayer(layer, editor, model, colorFor) {
if (!layer) return;
const style = getComputedStyle(editor);
layer.style.left = `${editor.offsetLeft}px`;
const canvas = document.createElement("div");
canvas.className = "authorship-canvas";
canvas.style.paddingTop = style.paddingTop;
canvas.style.paddingRight = style.paddingRight;
canvas.style.paddingBottom = style.paddingBottom;
canvas.style.paddingLeft = style.paddingLeft;
canvas.style.fontFamily = style.fontFamily;
canvas.style.fontSize = style.fontSize;
canvas.style.fontWeight = style.fontWeight;
canvas.style.lineHeight = style.lineHeight;
canvas.style.letterSpacing = style.letterSpacing;
canvas.style.minWidth = `${editor.scrollWidth}px`;
canvas.style.minHeight = `${editor.scrollHeight}px`;
const text = editor.value;
let cursor = 0;
for (const span of normalize(model?.spans, text.length)) {
if (span.start > cursor) canvas.append(document.createTextNode(text.slice(cursor, span.start)));
const mark = document.createElement("span");
mark.className = "authorship-fragment";
mark.style.setProperty("--owner", colorFor(span.owner));
mark.textContent = text.slice(span.start, span.end);
mark.title = span.owner.split("\u001f", 1)[0];
canvas.append(mark);
cursor = span.end;
}
if (cursor < text.length) canvas.append(document.createTextNode(text.slice(cursor)));
if (!text.endsWith("\n")) canvas.append(document.createTextNode("\n"));
layer.replaceChildren(canvas);
syncAuthorshipLayer(layer, editor);
}