206 lines
8.0 KiB
JavaScript
206 lines
8.0 KiB
JavaScript
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) {
|
|
return JSON.stringify({ version: VERSION, spans: normalize(model?.spans, contentLength) });
|
|
}
|
|
|
|
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 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) {
|
|
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 starts = [0];
|
|
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
|
return starts.map((start, index) => {
|
|
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
|
|
const totals = new Map();
|
|
const representatives = new Map();
|
|
for (const span of model?.spans || []) {
|
|
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 lineOwners(content, model) {
|
|
const starts = [0];
|
|
for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1);
|
|
return starts.map((start, index) => {
|
|
const end = index + 1 < starts.length ? starts[index + 1] : content.length;
|
|
const totals = new Map();
|
|
const representatives = new Map();
|
|
for (const span of model?.spans || []) {
|
|
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);
|
|
}
|
|
const identity = [...totals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
return identity ? representatives.get(identity) : "";
|
|
});
|
|
}
|
|
|
|
export function renderAuthorshipLayer(layer, editor, model, colorFor) {
|
|
if (!layer) return;
|
|
const style = getComputedStyle(editor);
|
|
layer.style.left = `${editor.offsetLeft}px`;
|
|
layer.style.paddingTop = style.paddingTop;
|
|
layer.style.paddingRight = style.paddingRight;
|
|
layer.style.paddingBottom = style.paddingBottom;
|
|
layer.style.paddingLeft = style.paddingLeft;
|
|
layer.style.fontFamily = style.fontFamily;
|
|
layer.style.fontSize = style.fontSize;
|
|
layer.style.fontWeight = style.fontWeight;
|
|
layer.style.lineHeight = style.lineHeight;
|
|
layer.style.letterSpacing = style.letterSpacing;
|
|
const text = editor.value;
|
|
const fragment = document.createDocumentFragment();
|
|
let cursor = 0;
|
|
for (const span of normalize(model?.spans, text.length)) {
|
|
if (span.start > cursor) fragment.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];
|
|
fragment.append(mark);
|
|
cursor = span.end;
|
|
}
|
|
if (cursor < text.length) fragment.append(document.createTextNode(text.slice(cursor)));
|
|
if (!text.endsWith("\n")) fragment.append(document.createTextNode("\n"));
|
|
layer.replaceChildren(fragment);
|
|
layer.scrollTop = editor.scrollTop;
|
|
layer.scrollLeft = editor.scrollLeft;
|
|
}
|