From 0ae25d22129c97af992b43af865d0cce898dc7cc Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Thu, 4 Sep 2025 10:53:46 +0800 Subject: [PATCH 1/5] feat: show source diff between note and revision --- apps/client/src/services/utils.ts | 49 +++++++++++++++ .../src/translations/en/translation.json | 5 ++ apps/client/src/widgets/dialogs/revisions.tsx | 63 +++++++++++++++++-- .../widgets/type_widgets/read_only_code.ts | 51 +-------------- 4 files changed, 115 insertions(+), 53 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 77fec1366..bf3894474 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -297,6 +297,54 @@ function isHtmlEmpty(html: string) { ); } +function formatHtml(html: string) { + let indent = "\n"; + const tab = "\t"; + let i = 0; + let pre: { indent: string; tag: string }[] = []; + + html = html + .replace(new RegExp("
((.|\\t|\\n|\\r)+)?
"), function (x) { + pre.push({ indent: "", tag: x }); + return "<--TEMPPRE" + i++ + "/-->"; + }) + .replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) { + let ret; + const tagRegEx = /<\/?([^\s/>]+)/.exec(x); + let tag = tagRegEx ? tagRegEx[1] : ""; + let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); + + if (p) { + const pInd = parseInt(p[1]); + pre[pInd].indent = indent; + } + + if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) { + // self closing tag + ret = indent + x; + } else { + if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); + else ret = indent + x; + !p && (indent += tab); + } else { + //close tag + indent = indent.substr(0, indent.length - 1); + if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); + else ret = indent + x; + } + } + return ret; + }); + + for (i = pre.length; i--;) { + html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("
", "
\n").replace("
", pre[i].indent + "
")); + } + + return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; +} + export async function clearBrowserCache() { if (isElectron()) { const win = dynamicRequire("@electron/remote").getCurrentWindow(); @@ -855,6 +903,7 @@ export default { getNoteTypeClass, getMimeTypeClass, isHtmlEmpty, + formatHtml, clearBrowserCache, copySelectionToClipboard, dynamicRequire, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index d76843a27..1fb32af95 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -263,6 +263,11 @@ "confirm_delete_all": "Do you want to delete all revisions of this note?", "no_revisions": "No revisions for this note yet...", "restore_button": "Restore", + "diff_button": "Diff", + "content_button": "Content", + "diff_button_title": "Show note source diff", + "content_button_title": "Show revision content", + "diff_not_available": "Diff isn't available.", "confirm_restore": "Do you want to restore this revision? This will overwrite the current title and content of the note with this revision.", "delete_button": "Delete", "confirm_delete": "Do you want to delete this revision?", diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index 0fa4f956e..78f4468ae 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -18,12 +18,15 @@ import open from "../../services/open"; import ActionButton from "../react/ActionButton"; import options from "../../services/options"; import { useTriliumEvent } from "../react/hooks"; +import { diffWords } from "diff"; export default function RevisionsDialog() { const [ note, setNote ] = useState(); + const [ noteContent, setNoteContent ] = useState(); const [ revisions, setRevisions ] = useState(); const [ currentRevision, setCurrentRevision ] = useState(); const [ shown, setShown ] = useState(false); + const [ showDiff, setShowDiff ] = useState(false); const [ refreshCounter, setRefreshCounter ] = useState(0); useTriliumEvent("showRevisions", async ({ noteId }) => { @@ -37,8 +40,10 @@ export default function RevisionsDialog() { useEffect(() => { if (note?.noteId) { server.get(`notes/${note.noteId}/revisions`).then(setRevisions); + note.getContent().then(setNoteContent); } else { setRevisions(undefined); + setNoteContent(undefined); } }, [ note?.noteId, refreshCounter ]); @@ -70,6 +75,7 @@ export default function RevisionsDialog() { footerStyle={{ paddingTop: 0, paddingBottom: 0 }} onHidden={() => { setShown(false); + setShowDiff(false); setNote(undefined); setCurrentRevision(undefined); setRevisions(undefined); @@ -92,11 +98,15 @@ export default function RevisionsDialog() { marginLeft: "20px", display: "flex", flexDirection: "column", + maxWidth: "calc(100% - 150px)", minWidth: 0 }}> { setRefreshCounter(c => c + 1); setCurrentRevision(undefined); @@ -121,9 +131,12 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re ); } -function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: { +function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShowDiff, onRevisionDeleted }: { + noteContent?: string, revisionItem?: RevisionItem, - setShown: Dispatch>, + setShown: Dispatch>, + showDiff: boolean, + setShowDiff: Dispatch>, onRevisionDeleted?: () => void }) { const [ fullRevision, setFullRevision ] = useState(); @@ -143,6 +156,17 @@ function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: { {(revisionItem &&
{(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) && <> + {["text", "code", "mermaid"].includes(revisionItem.type) && ( +
- +
); @@ -197,12 +221,15 @@ const CODE_STYLE: CSSProperties = { whiteSpace: "pre-wrap" }; -function RevisionContent({ revisionItem, fullRevision }: { revisionItem?: RevisionItem, fullRevision?: RevisionPojo }) { +function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }: { noteContent?:string, revisionItem?: RevisionItem, fullRevision?: RevisionPojo, showDiff: boolean}) { const content = fullRevision?.content; if (!revisionItem || !content) { return <>; } + if (showDiff) { + return + } switch (revisionItem.type) { case "text": return @@ -267,6 +294,34 @@ function RevisionContentText({ content }: { content: string | Buffer } +function RevisionContentDiff({ noteContent, itemContent, itemType }: { noteContent?: string, itemContent: string | Buffer | undefined, itemType: string }) { + let diffHtml: string; + + if (noteContent && typeof itemContent === "string") { + if (itemType === "text") { + noteContent = utils.formatHtml(noteContent); + itemContent = utils.formatHtml(itemContent); + } + const diff = diffWords(noteContent, itemContent); + diffHtml = diff.map(part => { + if (part.added) { + return `${utils.escapeHtml(part.value)}`; + } else if (part.removed) { + return `${utils.escapeHtml(part.value)}`; + } else { + return utils.escapeHtml(part.value); + } + }).join(""); + } else { + return <>{t("revisions.diff_not_available")} + } + + return ( +
+ ); +} + function RevisionFooter({ note }: { note?: FNote }) { if (!note) { return <>; diff --git a/apps/client/src/widgets/type_widgets/read_only_code.ts b/apps/client/src/widgets/type_widgets/read_only_code.ts index fd74aaa5a..cdae4565e 100644 --- a/apps/client/src/widgets/type_widgets/read_only_code.ts +++ b/apps/client/src/widgets/type_widgets/read_only_code.ts @@ -1,6 +1,7 @@ import type { EventData } from "../../components/app_context.js"; import type FNote from "../../entities/fnote.js"; import AbstractCodeTypeWidget from "./abstract_code_type_widget.js"; +import utils from "../../services/utils.js"; const TPL = /*html*/`
@@ -33,7 +34,7 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget { if (!blob) return; const isFormattable = note.type === "text" && this.noteContext?.viewScope?.viewMode === "source"; - const content = isFormattable ? this.format(blob.content) : blob.content; + const content = isFormattable ? utils.formatHtml(blob.content) : blob.content; this._update(note, content); this.show(); @@ -54,52 +55,4 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget { resolve(this.$editor); } - - format(html: string) { - let indent = "\n"; - const tab = "\t"; - let i = 0; - let pre: { indent: string; tag: string }[] = []; - - html = html - .replace(new RegExp("
((.|\\t|\\n|\\r)+)?
"), function (x) { - pre.push({ indent: "", tag: x }); - return "<--TEMPPRE" + i++ + "/-->"; - }) - .replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) { - let ret; - const tagRegEx = /<\/?([^\s/>]+)/.exec(x); - let tag = tagRegEx ? tagRegEx[1] : ""; - let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); - - if (p) { - const pInd = parseInt(p[1]); - pre[pInd].indent = indent; - } - - if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) { - // self closing tag - ret = indent + x; - } else { - if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); - else ret = indent + x; - !p && (indent += tab); - } else { - //close tag - indent = indent.substr(0, indent.length - 1); - if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); - else ret = indent + x; - } - } - return ret; - }); - - for (i = pre.length; i--;) { - html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("
", "
\n").replace("
", pre[i].indent + "
")); - } - - return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; - } } From fa2188f087ef084e5ce632335bf91fa0d30c5591 Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Thu, 4 Sep 2025 17:36:19 +0800 Subject: [PATCH 2/5] fix: improve
 tag regex handling when formatting
 HTML strings

---
 apps/client/src/services/utils.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts
index bf3894474..a8f4f567f 100644
--- a/apps/client/src/services/utils.ts
+++ b/apps/client/src/services/utils.ts
@@ -304,7 +304,7 @@ function formatHtml(html: string) {
     let pre: { indent: string; tag: string }[] = [];
 
     html = html
-        .replace(new RegExp("
((.|\\t|\\n|\\r)+)?
"), function (x) { + .replace(new RegExp("
([\\s\\S]+?)?
"), function (x) { pre.push({ indent: "", tag: x }); return "<--TEMPPRE" + i++ + "/-->"; }) From 1c451fb98a29101dfca9a57c4266679261bc18a8 Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Thu, 4 Sep 2025 18:47:14 +0800 Subject: [PATCH 3/5] fix: adapt diff highlight for dark theme --- apps/client/src/widgets/dialogs/revisions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index 78f4468ae..af64e2d84 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -305,9 +305,9 @@ function RevisionContentDiff({ noteContent, itemContent, itemType }: { noteConte const diff = diffWords(noteContent, itemContent); diffHtml = diff.map(part => { if (part.added) { - return `${utils.escapeHtml(part.value)}`; + return `${utils.escapeHtml(part.value)}`; } else if (part.removed) { - return `${utils.escapeHtml(part.value)}`; + return `${utils.escapeHtml(part.value)}`; } else { return utils.escapeHtml(part.value); } From c60c738c7e2ac153631efc862c160683b7c8cb65 Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Thu, 4 Sep 2025 21:34:13 +0800 Subject: [PATCH 4/5] feat: show source diff between note and revision --- apps/client/src/stylesheets/style.css | 10 ++ .../src/translations/en/translation.json | 8 +- apps/client/src/widgets/dialogs/revisions.tsx | 113 +++++++++++------- 3 files changed, 81 insertions(+), 50 deletions(-) diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 2aefbbc01..98b012ea5 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -2375,4 +2375,14 @@ footer.webview-footer button { max-width: 25vw; overflow: hidden; text-overflow: ellipsis; +} + + +.revision-diff-added { + background: rgba(100, 200, 100, 0.5); +} + +.revision-diff-removed { + background: rgba(255, 100, 100, 0.5); + text-decoration: line-through; } \ No newline at end of file diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 1fb32af95..9d7fff76f 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -263,10 +263,10 @@ "confirm_delete_all": "Do you want to delete all revisions of this note?", "no_revisions": "No revisions for this note yet...", "restore_button": "Restore", - "diff_button": "Diff", - "content_button": "Content", - "diff_button_title": "Show note source diff", - "content_button_title": "Show revision content", + "diff_on": "Show diff", + "diff_off": "Show content", + "diff_on_hint": "Click to show note source diff", + "diff_off_hint": "Click to show note content", "diff_not_available": "Diff isn't available.", "confirm_restore": "Do you want to restore this revision? This will overwrite the current title and content of the note with this revision.", "delete_button": "Delete", diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index af64e2d84..65c7dfd2c 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -7,6 +7,7 @@ import { t } from "../../services/i18n"; import server from "../../services/server"; import toast from "../../services/toast"; import Button from "../react/Button"; +import FormToggle from "../react/FormToggle"; import Modal from "../react/Modal"; import FormList, { FormListItem } from "../react/FormList"; import utils from "../../services/utils"; @@ -59,17 +60,36 @@ export default function RevisionsDialog() { helpPageId="vZWERwf8U3nx" bodyStyle={{ display: "flex", height: "80vh" }} header={ - (!!revisions?.length &&
} -function RevisionContentDiff({ noteContent, itemContent, itemType }: { noteContent?: string, itemContent: string | Buffer | undefined, itemType: string }) { - let diffHtml: string; +function RevisionContentDiff({ noteContent, itemContent, itemType }: { + noteContent?: string, + itemContent: string | Buffer | undefined, + itemType: string +}) { + const contentRef = useRef(null); - if (noteContent && typeof itemContent === "string") { - if (itemType === "text") { - noteContent = utils.formatHtml(noteContent); - itemContent = utils.formatHtml(itemContent); + useEffect(() => { + if (!noteContent || typeof itemContent !== "string") { + if (contentRef.current) { + contentRef.current.textContent = t("revisions.diff_not_available"); + } + return; } - const diff = diffWords(noteContent, itemContent); - diffHtml = diff.map(part => { + + let processedNoteContent = noteContent; + let processedItemContent = itemContent; + + if (itemType === "text") { + processedNoteContent = utils.formatHtml(noteContent); + processedItemContent = utils.formatHtml(itemContent); + } + + const diff = diffWords(processedNoteContent, processedItemContent); + const diffHtml = diff.map(part => { if (part.added) { - return `${utils.escapeHtml(part.value)}`; + return `${utils.escapeHtml(part.value)}`; } else if (part.removed) { - return `${utils.escapeHtml(part.value)}`; + return `${utils.escapeHtml(part.value)}`; } else { return utils.escapeHtml(part.value); } }).join(""); - } else { - return <>{t("revisions.diff_not_available")} - } - - return ( -
- ); + + if (contentRef.current) { + contentRef.current.innerHTML = diffHtml; + } + }, [noteContent, itemContent, itemType]); + + return
; } function RevisionFooter({ note }: { note?: FNote }) { From 5e572a8c6afa2471237027bfb64762f5a25bfe40 Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Thu, 4 Sep 2025 22:07:04 +0800 Subject: [PATCH 5/5] fix: remove unnecessary line breaks --- apps/client/src/stylesheets/style.css | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 98b012ea5..e20c63ee0 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -2377,7 +2377,6 @@ footer.webview-footer button { text-overflow: ellipsis; } - .revision-diff-added { background: rgba(100, 200, 100, 0.5); }