diff --git a/src/lib/diff-render.ts b/src/lib/diff-render.ts index 325dd51..4857b8e 100644 --- a/src/lib/diff-render.ts +++ b/src/lib/diff-render.ts @@ -6,12 +6,112 @@ const dmp = new DiffMatchPatch(); const INSERT = 1; const DELETE = -1; +const normalizeLF = (s: string): string => s.replace(/\r\n/g, "\n"); + +function renderEqual(text: string): string { + return `${escapeHtml(text)}`; +} + +// Split text into [leadingWhitespace, body, trailingWhitespace] so the green/ +// red highlight wraps only the meaningful change — without this, padding on +// an ins/del whose content has a leading space shows up as a stray colored gap. +function splitWhitespaceEdges(text: string): { + lead: string; + body: string; + trail: string; +} { + const lead = text.match(/^\s*/)?.[0] ?? ""; + const trail = text.match(/\s*$/)?.[0] ?? ""; + const body = text.slice(lead.length, text.length - trail.length); + return { lead, body, trail }; +} + +function renderOne([op, text]: [number, string]): string { + if (op !== INSERT && op !== DELETE) return renderEqual(text); + if (text.trim().length === 0) return ""; + + const { lead, body, trail } = splitWhitespaceEdges(text); + const tag = + op === INSERT + ? `${escapeHtml(body)}` + : `${escapeHtml(body)}`; + return `${escapeHtml(lead)}${tag}${escapeHtml(trail)}`; +} + +const CONTEXT_LINES = 2; +const HUNK_SEPARATOR = `
⋯
`; + +/** + * Collapse long equal regions into ±N lines of context around each change, + * git/jj style. First/last equal regions are trimmed from the outer side; + * inner equal regions get a `⋯` separator in the middle when long. + */ +function renderDiffs(diffs: [number, string][]): string { + const out: string[] = []; + + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i]; + if (!diff) continue; + const [op, text] = diff; + if (op !== 0) { + out.push(renderOne(diff)); + continue; + } + const lines = text.split("\n"); + const isFirst = i === 0; + const isLast = i === diffs.length - 1; + + if (isFirst && isLast) { + // No changes at all; renderContentDiff filters this but be safe. + out.push(renderEqual(text)); + } else if (isFirst) { + if (lines.length <= CONTEXT_LINES) { + out.push(renderEqual(text)); + } else { + out.push(HUNK_SEPARATOR); + out.push(renderEqual(lines.slice(-CONTEXT_LINES).join("\n"))); + } + } else if (isLast) { + if (lines.length <= CONTEXT_LINES) { + out.push(renderEqual(text)); + } else { + out.push(renderEqual(lines.slice(0, CONTEXT_LINES).join("\n"))); + out.push(HUNK_SEPARATOR); + } + } else { + if (lines.length <= CONTEXT_LINES * 2) { + out.push(renderEqual(text)); + } else { + out.push(renderEqual(lines.slice(0, CONTEXT_LINES).join("\n"))); + out.push(HUNK_SEPARATOR); + out.push(renderEqual(lines.slice(-CONTEXT_LINES).join("\n"))); + } + } + } + + return `
${out.join("")}
`; +} + +/** + * Compute and render a diff between two full content snapshots. Normalizes + * line endings on both sides so legacy CRLF-tainted content doesn't churn. + */ +export function renderContentDiff(before: string, after: string): string { + const beforeLF = normalizeLF(before); + const afterLF = normalizeLF(after); + if (beforeLF === afterLF) return ""; + const diffs = dmp.diff_main(beforeLF, afterLF) as [number, string][]; + dmp.diff_cleanupSemanticLossless( + diffs as unknown as Parameters[0], + ); + return renderDiffs(diffs); +} + /** - * Render a diff-match-patch patch text as inline HTML for the history page. - * - * Each patch becomes a hunk. Within a hunk, equal text is plain (context), - * inserts wrap in , deletes wrap in . Newlines are preserved with - *
. Returns "" for an empty patch (e.g. no-op revision). + * Legacy fallback: render a stored dmp patch as inline HTML. Used only when + * the corresponding snapshot is missing (revisions older than the snapshot + * cap). Output is uglier than `renderContentDiff` because dmp patches only + * preserve a tiny context window around each change. */ export function renderDiffInline(patchText: string): string { if (!patchText.trim()) return ""; @@ -25,15 +125,14 @@ export function renderDiffInline(patchText: string): string { } const hunks = patches.map((patch) => { - const parts = patch.diffs.map(([op, text]) => { - const html = escapeHtml(text).replace(/\n/g, "
"); - if (op === INSERT) - return `${html}`; - if (op === DELETE) - return `${html}`; - return `${html}`; - }); - return `
${parts.join("")}
`; + const diffs = patch.diffs.map(([op, text]) => [op, text]) as [ + number, + string, + ][]; + dmp.diff_cleanupSemantic( + diffs as unknown as Parameters[0], + ); + return renderDiffs(diffs); }); return hunks.join( diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index f6d22f1..35a72ca 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -62,9 +62,10 @@ export const en: Messages = { exportWiki: "Wiki (.zip)", share: "Share", history: "History", - historyHeading: "Note history", + historyHeading: "History", historyEmpty: "No revisions yet.", historyDefaultMessage: "edit", + backToNotePrefix: "Back to", }, editor: { title: "Title", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index eba05a1..9ca783f 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -63,9 +63,10 @@ export const fr: PartialMessages = { exportWiki: "Wiki (.zip)", share: "Partager", history: "Historique", - historyHeading: "Historique de la note", + historyHeading: "Historique", historyEmpty: "Aucune révision pour le moment.", historyDefaultMessage: "modification", + backToNotePrefix: "Retour à", }, editor: { title: "Titre", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 2f46980..57af38e 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -67,6 +67,7 @@ export interface Messages { historyHeading: string; historyEmpty: string; historyDefaultMessage: string; + backToNotePrefix: string; }; editor: { title: string; diff --git a/src/lib/import-export/zip-parse.ts b/src/lib/import-export/zip-parse.ts index 11125c8..21b1c48 100644 --- a/src/lib/import-export/zip-parse.ts +++ b/src/lib/import-export/zip-parse.ts @@ -70,7 +70,12 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { const ext = name.slice(name.lastIndexOf(".")).toLowerCase(); if (ext === ".md" || ext === ".markdown") { - mdFiles.push({ name, content: new TextDecoder().decode(data) }); + // Normalize CRLF (Windows-authored files) to LF so diffs against + // future edits don't churn on line-ending differences. + mdFiles.push({ + name, + content: new TextDecoder().decode(data).replace(/\r\n/g, "\n"), + }); } else if (IMAGE_EXTENSIONS.has(ext)) { imageFiles.set(name.toLowerCase(), data); } diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index 8ff5b15..4ed3556 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -38,7 +38,12 @@ export function parseNoteFormFields(formData: { get(name: string): string | File | null; }): NoteFormFields { const title = (formData.get("title") as string | null) ?? ""; - const content = (formData.get("content") as string | null) ?? ""; + // Browsers serialize