feat: show source diff between note and revision

This commit is contained in:
SiriusXT 2025-09-04 21:34:13 +08:00
parent 1c451fb98a
commit c60c738c7e
3 changed files with 81 additions and 50 deletions

View File

@ -2376,3 +2376,13 @@ footer.webview-footer button {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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;
}

View File

@ -263,10 +263,10 @@
"confirm_delete_all": "Do you want to delete all revisions of this note?", "confirm_delete_all": "Do you want to delete all revisions of this note?",
"no_revisions": "No revisions for this note yet...", "no_revisions": "No revisions for this note yet...",
"restore_button": "Restore", "restore_button": "Restore",
"diff_button": "Diff", "diff_on": "Show diff",
"content_button": "Content", "diff_off": "Show content",
"diff_button_title": "Show note source diff", "diff_on_hint": "Click to show note source diff",
"content_button_title": "Show revision content", "diff_off_hint": "Click to show note content",
"diff_not_available": "Diff isn't available.", "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.", "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", "delete_button": "Delete",

View File

@ -7,6 +7,7 @@ import { t } from "../../services/i18n";
import server from "../../services/server"; import server from "../../services/server";
import toast from "../../services/toast"; import toast from "../../services/toast";
import Button from "../react/Button"; import Button from "../react/Button";
import FormToggle from "../react/FormToggle";
import Modal from "../react/Modal"; import Modal from "../react/Modal";
import FormList, { FormListItem } from "../react/FormList"; import FormList, { FormListItem } from "../react/FormList";
import utils from "../../services/utils"; import utils from "../../services/utils";
@ -59,17 +60,36 @@ export default function RevisionsDialog() {
helpPageId="vZWERwf8U3nx" helpPageId="vZWERwf8U3nx"
bodyStyle={{ display: "flex", height: "80vh" }} bodyStyle={{ display: "flex", height: "80vh" }}
header={ header={
(!!revisions?.length && <Button text={t("revisions.delete_all_revisions")} size="small" style={{ padding: "0 10px" }} !!revisions?.length && (
onClick={async () => { <>
const text = t("revisions.confirm_delete_all"); {["text", "code", "mermaid"].includes(currentRevision?.type ?? "") && (
<FormToggle
currentValue={showDiff}
onChange={(newValue) => setShowDiff(newValue)}
switchOnName={t("revisions.diff_on")}
switchOffName={t("revisions.diff_off")}
switchOnTooltip={t("revisions.diff_on_hint")}
switchOffTooltip={t("revisions.diff_off_hint")}
/>
)}
&nbsp;
<Button
text={t("revisions.delete_all_revisions")}
size="small"
style={{ padding: "0 10px" }}
onClick={async () => {
const text = t("revisions.confirm_delete_all");
if (note && await dialog.confirm(text)) { if (note && await dialog.confirm(text)) {
await server.remove(`notes/${note.noteId}/revisions`); await server.remove(`notes/${note.noteId}/revisions`);
setRevisions([]); setRevisions([]);
setCurrentRevision(undefined); setCurrentRevision(undefined);
toast.showMessage(t("revisions.revisions_deleted")); toast.showMessage(t("revisions.revisions_deleted"));
} }
}}/>) }}
/>
</>
)
} }
footer={<RevisionFooter note={note} />} footer={<RevisionFooter note={note} />}
footerStyle={{ paddingTop: 0, paddingBottom: 0 }} footerStyle={{ paddingTop: 0, paddingBottom: 0 }}
@ -104,9 +124,8 @@ export default function RevisionsDialog() {
<RevisionPreview <RevisionPreview
noteContent={noteContent} noteContent={noteContent}
revisionItem={currentRevision} revisionItem={currentRevision}
setShown={setShown}
showDiff={showDiff} showDiff={showDiff}
setShowDiff={setShowDiff} setShown={setShown}
onRevisionDeleted={() => { onRevisionDeleted={() => {
setRefreshCounter(c => c + 1); setRefreshCounter(c => c + 1);
setCurrentRevision(undefined); setCurrentRevision(undefined);
@ -131,12 +150,11 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
</FormList>); </FormList>);
} }
function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShowDiff, onRevisionDeleted }: { function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted }: {
noteContent?: string, noteContent?: string,
revisionItem?: RevisionItem, revisionItem?: RevisionItem,
setShown: Dispatch<StateUpdater<boolean>>,
showDiff: boolean, showDiff: boolean,
setShowDiff: Dispatch<StateUpdater<boolean>>, setShown: Dispatch<StateUpdater<boolean>>,
onRevisionDeleted?: () => void onRevisionDeleted?: () => void
}) { }) {
const [ fullRevision, setFullRevision ] = useState<RevisionPojo>(); const [ fullRevision, setFullRevision ] = useState<RevisionPojo>();
@ -156,17 +174,6 @@ function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShow
{(revisionItem && <div className="revision-title-buttons"> {(revisionItem && <div className="revision-title-buttons">
{(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) && {(!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 <Button
icon="bx bx-history" icon="bx bx-history"
text={t("revisions.restore_button")} text={t("revisions.restore_button")}
@ -294,32 +301,46 @@ function RevisionContentText({ content }: { content: string | Buffer<ArrayBuffer
return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div> 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 }) { function RevisionContentDiff({ noteContent, itemContent, itemType }: {
let diffHtml: string; noteContent?: string,
itemContent: string | Buffer<ArrayBufferLike> | undefined,
itemType: string
}) {
const contentRef = useRef<HTMLDivElement>(null);
if (noteContent && typeof itemContent === "string") { useEffect(() => {
if (itemType === "text") { if (!noteContent || typeof itemContent !== "string") {
noteContent = utils.formatHtml(noteContent); if (contentRef.current) {
itemContent = utils.formatHtml(itemContent); 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) { if (part.added) {
return `<span style="background:rgba(100, 200, 100, 0.5)">${utils.escapeHtml(part.value)}</span>`; return `<span class="revision-diff-added">${utils.escapeHtml(part.value)}</span>`;
} else if (part.removed) { } else if (part.removed) {
return `<span style="background:rgba(255, 100, 100, 0.5);text-decoration:line-through;">${utils.escapeHtml(part.value)}</span>`; return `<span class="revision-diff-removed">${utils.escapeHtml(part.value)}</span>`;
} else { } else {
return utils.escapeHtml(part.value); return utils.escapeHtml(part.value);
} }
}).join(""); }).join("");
} else {
return <>{t("revisions.diff_not_available")}</>
}
return ( if (contentRef.current) {
<div className="ck-content" style={{ whiteSpace: "pre-wrap" }} contentRef.current.innerHTML = diffHtml;
dangerouslySetInnerHTML={{ __html: diffHtml }}></div> }
); }, [noteContent, itemContent, itemType]);
return <div ref={contentRef} className="ck-content" style={{ whiteSpace: "pre-wrap" }}></div>;
} }
function RevisionFooter({ note }: { note?: FNote }) { function RevisionFooter({ note }: { note?: FNote }) {