feat: show source diff between note and revision

This commit is contained in:
SiriusXT 2025-09-04 10:53:46 +08:00
parent e1b4a0b720
commit 0ae25d2212
4 changed files with 115 additions and 53 deletions

View File

@ -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("<pre>((.|\\t|\\n|\\r)+)?</pre>"), 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("<pre>", "<pre>\n").replace("</pre>", pre[i].indent + "</pre>"));
}
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,

View File

@ -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?",

View File

@ -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<FNote>();
const [ noteContent, setNoteContent ] = useState<string>();
const [ revisions, setRevisions ] = useState<RevisionItem[]>();
const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>();
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<RevisionItem[]>(`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
}}>
<RevisionPreview
noteContent={noteContent}
revisionItem={currentRevision}
setShown={setShown}
showDiff={showDiff}
setShowDiff={setShowDiff}
onRevisionDeleted={() => {
setRefreshCounter(c => c + 1);
setCurrentRevision(undefined);
@ -121,9 +131,12 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
</FormList>);
}
function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: {
function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShowDiff, onRevisionDeleted }: {
noteContent?: string,
revisionItem?: RevisionItem,
setShown: Dispatch<StateUpdater<boolean>>,
setShown: Dispatch<StateUpdater<boolean>>,
showDiff: boolean,
setShowDiff: Dispatch<StateUpdater<boolean>>,
onRevisionDeleted?: () => void
}) {
const [ fullRevision, setFullRevision ] = useState<RevisionPojo>();
@ -143,6 +156,17 @@ function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: {
{(revisionItem && <div className="revision-title-buttons">
{(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) &&
<>
{["text", "code", "mermaid"].includes(revisionItem.type) && (
<Button
icon={showDiff ? "bx bx-detail" : "bx bx-outline"}
text={showDiff ? t("revisions.content_button") : t("revisions.diff_button")}
title={showDiff ? t("revisions.content_button_title") : t("revisions.diff_button_title")}
onClick={async () => {
setShowDiff(!showDiff);
}}
/>
)}
&nbsp;
<Button
icon="bx bx-history"
text={t("revisions.restore_button")}
@ -179,7 +203,7 @@ function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: {
</div>)}
</div>
<div className="revision-content use-tn-links" style={{ overflow: "auto", wordBreak: "break-word" }}>
<RevisionContent revisionItem={revisionItem} fullRevision={fullRevision} />
<RevisionContent noteContent={noteContent} revisionItem={revisionItem} fullRevision={fullRevision} showDiff={showDiff}/>
</div>
</>
);
@ -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 <RevisionContentDiff noteContent={noteContent} itemContent={content} itemType={revisionItem.type}/>
}
switch (revisionItem.type) {
case "text":
return <RevisionContentText content={content} />
@ -267,6 +294,34 @@ function RevisionContentText({ content }: { content: string | Buffer<ArrayBuffer
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div>
}
function RevisionContentDiff({ noteContent, itemContent, itemType }: { noteContent?: string, itemContent: string | Buffer<ArrayBufferLike> | 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 `<span style="background:#d4fcbc">${utils.escapeHtml(part.value)}</span>`;
} else if (part.removed) {
return `<span style="background:#ffe6e6;text-decoration:line-through;">${utils.escapeHtml(part.value)}</span>`;
} else {
return utils.escapeHtml(part.value);
}
}).join("");
} else {
return <>{t("revisions.diff_not_available")}</>
}
return (
<div className="ck-content" style={{ whiteSpace: "pre-wrap" }}
dangerouslySetInnerHTML={{ __html: diffHtml }}></div>
);
}
function RevisionFooter({ note }: { note?: FNote }) {
if (!note) {
return <></>;

View File

@ -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*/`
<div class="note-detail-readonly-code note-detail-printable">
@ -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("<pre>((.|\\t|\\n|\\r)+)?</pre>"), 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("<pre>", "<pre>\n").replace("</pre>", pre[i].indent + "</pre>"));
}
return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html;
}
}