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