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("") < 0) { + //open tag + if (x.charAt(x.length - 1) !== ">") 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("
", "")); + } + + 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\n").replace("", pre[i].indent + "
((.|\\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("") < 0) { - //open tag - if (x.charAt(x.length - 1) !== ">") 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("
", "")); - } - - 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\n").replace("", pre[i].indent + "
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 &&{ - const text = t("revisions.confirm_delete_all"); + !!revisions?.length && ( + <> + {["text", "code", "mermaid"].includes(currentRevision?.type ?? "") && ( + setShowDiff(newValue)} + switchOnName={t("revisions.diff_on")} + switchOffName={t("revisions.diff_off")} + switchOnTooltip={t("revisions.diff_on_hint")} + switchOffTooltip={t("revisions.diff_off_hint")} + /> + )} + + { + const text = t("revisions.confirm_delete_all"); - if (note && await dialog.confirm(text)) { - await server.remove(`notes/${note.noteId}/revisions`); - setRevisions([]); - setCurrentRevision(undefined); - toast.showMessage(t("revisions.revisions_deleted")); - } - }}/>) + if (note && await dialog.confirm(text)) { + await server.remove(`notes/${note.noteId}/revisions`); + setRevisions([]); + setCurrentRevision(undefined); + toast.showMessage(t("revisions.revisions_deleted")); + } + }} + /> + > + ) } footer={ } footerStyle={{ paddingTop: 0, paddingBottom: 0 }} @@ -103,10 +123,9 @@ export default function RevisionsDialog() { }}> { setRefreshCounter(c => c + 1); setCurrentRevision(undefined); @@ -131,12 +150,11 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re ); } -function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShowDiff, onRevisionDeleted }: { +function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted }: { noteContent?: string, - revisionItem?: RevisionItem, - setShown: Dispatch >, + revisionItem?: RevisionItem, showDiff: boolean, - setShowDiff: Dispatch >, + setShown: Dispatch >, onRevisionDeleted?: () => void }) { const [ fullRevision, setFullRevision ] = useState (); @@ -156,17 +174,6 @@ function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShow {(revisionItem && {(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) && <> - {["text", "code", "mermaid"].includes(revisionItem.type) && ( -} -function RevisionContentDiff({ noteContent, itemContent, itemType }: { noteContent?: string, itemContent: string | Buffer{ - setShowDiff(!showDiff); - }} - /> - )} - | 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); }