/* * 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); }