mirror of
https://github.com/zadam/trilium.git
synced 2025-10-21 15:49:00 +02:00
feat: show source diff between note and revision (#6887)
This commit is contained in:
commit
38673a85c9
@ -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>([\\s\\S]+?)?</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() {
|
export async function clearBrowserCache() {
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
const win = dynamicRequire("@electron/remote").getCurrentWindow();
|
const win = dynamicRequire("@electron/remote").getCurrentWindow();
|
||||||
@ -855,6 +903,7 @@ export default {
|
|||||||
getNoteTypeClass,
|
getNoteTypeClass,
|
||||||
getMimeTypeClass,
|
getMimeTypeClass,
|
||||||
isHtmlEmpty,
|
isHtmlEmpty,
|
||||||
|
formatHtml,
|
||||||
clearBrowserCache,
|
clearBrowserCache,
|
||||||
copySelectionToClipboard,
|
copySelectionToClipboard,
|
||||||
dynamicRequire,
|
dynamicRequire,
|
||||||
|
@ -2376,3 +2376,12 @@ 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;
|
||||||
|
}
|
@ -263,6 +263,11 @@
|
|||||||
"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_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.",
|
"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",
|
||||||
"confirm_delete": "Do you want to delete this revision?",
|
"confirm_delete": "Do you want to delete this revision?",
|
||||||
|
@ -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";
|
||||||
@ -18,12 +19,15 @@ import open from "../../services/open";
|
|||||||
import ActionButton from "../react/ActionButton";
|
import ActionButton from "../react/ActionButton";
|
||||||
import options from "../../services/options";
|
import options from "../../services/options";
|
||||||
import { useTriliumEvent } from "../react/hooks";
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
import { diffWords } from "diff";
|
||||||
|
|
||||||
export default function RevisionsDialog() {
|
export default function RevisionsDialog() {
|
||||||
const [ note, setNote ] = useState<FNote>();
|
const [ note, setNote ] = useState<FNote>();
|
||||||
|
const [ noteContent, setNoteContent ] = useState<string>();
|
||||||
const [ revisions, setRevisions ] = useState<RevisionItem[]>();
|
const [ revisions, setRevisions ] = useState<RevisionItem[]>();
|
||||||
const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>();
|
const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>();
|
||||||
const [ shown, setShown ] = useState(false);
|
const [ shown, setShown ] = useState(false);
|
||||||
|
const [ showDiff, setShowDiff ] = useState(false);
|
||||||
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
const [ refreshCounter, setRefreshCounter ] = useState(0);
|
||||||
|
|
||||||
useTriliumEvent("showRevisions", async ({ noteId }) => {
|
useTriliumEvent("showRevisions", async ({ noteId }) => {
|
||||||
@ -37,8 +41,10 @@ export default function RevisionsDialog() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (note?.noteId) {
|
if (note?.noteId) {
|
||||||
server.get<RevisionItem[]>(`notes/${note.noteId}/revisions`).then(setRevisions);
|
server.get<RevisionItem[]>(`notes/${note.noteId}/revisions`).then(setRevisions);
|
||||||
|
note.getContent().then(setNoteContent);
|
||||||
} else {
|
} else {
|
||||||
setRevisions(undefined);
|
setRevisions(undefined);
|
||||||
|
setNoteContent(undefined);
|
||||||
}
|
}
|
||||||
}, [ note?.noteId, refreshCounter ]);
|
}, [ note?.noteId, refreshCounter ]);
|
||||||
|
|
||||||
@ -54,7 +60,23 @@ 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 && (
|
||||||
|
<>
|
||||||
|
{["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")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
text={t("revisions.delete_all_revisions")}
|
||||||
|
size="small"
|
||||||
|
style={{ padding: "0 10px" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const text = t("revisions.confirm_delete_all");
|
const text = t("revisions.confirm_delete_all");
|
||||||
|
|
||||||
@ -64,12 +86,16 @@ export default function RevisionsDialog() {
|
|||||||
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 }}
|
||||||
onHidden={() => {
|
onHidden={() => {
|
||||||
setShown(false);
|
setShown(false);
|
||||||
|
setShowDiff(false);
|
||||||
setNote(undefined);
|
setNote(undefined);
|
||||||
setCurrentRevision(undefined);
|
setCurrentRevision(undefined);
|
||||||
setRevisions(undefined);
|
setRevisions(undefined);
|
||||||
@ -92,10 +118,13 @@ export default function RevisionsDialog() {
|
|||||||
marginLeft: "20px",
|
marginLeft: "20px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
maxWidth: "calc(100% - 150px)",
|
||||||
minWidth: 0
|
minWidth: 0
|
||||||
}}>
|
}}>
|
||||||
<RevisionPreview
|
<RevisionPreview
|
||||||
|
noteContent={noteContent}
|
||||||
revisionItem={currentRevision}
|
revisionItem={currentRevision}
|
||||||
|
showDiff={showDiff}
|
||||||
setShown={setShown}
|
setShown={setShown}
|
||||||
onRevisionDeleted={() => {
|
onRevisionDeleted={() => {
|
||||||
setRefreshCounter(c => c + 1);
|
setRefreshCounter(c => c + 1);
|
||||||
@ -121,8 +150,10 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re
|
|||||||
</FormList>);
|
</FormList>);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: {
|
function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevisionDeleted }: {
|
||||||
|
noteContent?: string,
|
||||||
revisionItem?: RevisionItem,
|
revisionItem?: RevisionItem,
|
||||||
|
showDiff: boolean,
|
||||||
setShown: Dispatch<StateUpdater<boolean>>,
|
setShown: Dispatch<StateUpdater<boolean>>,
|
||||||
onRevisionDeleted?: () => void
|
onRevisionDeleted?: () => void
|
||||||
}) {
|
}) {
|
||||||
@ -179,7 +210,7 @@ function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: {
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="revision-content use-tn-links" style={{ overflow: "auto", wordBreak: "break-word" }}>
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -197,12 +228,15 @@ const CODE_STYLE: CSSProperties = {
|
|||||||
whiteSpace: "pre-wrap"
|
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;
|
const content = fullRevision?.content;
|
||||||
if (!revisionItem || !content) {
|
if (!revisionItem || !content) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDiff) {
|
||||||
|
return <RevisionContentDiff noteContent={noteContent} itemContent={content} itemType={revisionItem.type}/>
|
||||||
|
}
|
||||||
switch (revisionItem.type) {
|
switch (revisionItem.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return <RevisionContentText content={content} />
|
return <RevisionContentText content={content} />
|
||||||
@ -267,6 +301,48 @@ 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
|
||||||
|
}) {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!noteContent || typeof itemContent !== "string") {
|
||||||
|
if (contentRef.current) {
|
||||||
|
contentRef.current.textContent = t("revisions.diff_not_available");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<span class="revision-diff-added">${utils.escapeHtml(part.value)}</span>`;
|
||||||
|
} else if (part.removed) {
|
||||||
|
return `<span class="revision-diff-removed">${utils.escapeHtml(part.value)}</span>`;
|
||||||
|
} else {
|
||||||
|
return utils.escapeHtml(part.value);
|
||||||
|
}
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
if (contentRef.current) {
|
||||||
|
contentRef.current.innerHTML = diffHtml;
|
||||||
|
}
|
||||||
|
}, [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 }) {
|
||||||
if (!note) {
|
if (!note) {
|
||||||
return <></>;
|
return <></>;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import type { EventData } from "../../components/app_context.js";
|
import type { EventData } from "../../components/app_context.js";
|
||||||
import type FNote from "../../entities/fnote.js";
|
import type FNote from "../../entities/fnote.js";
|
||||||
import AbstractCodeTypeWidget from "./abstract_code_type_widget.js";
|
import AbstractCodeTypeWidget from "./abstract_code_type_widget.js";
|
||||||
|
import utils from "../../services/utils.js";
|
||||||
|
|
||||||
const TPL = /*html*/`
|
const TPL = /*html*/`
|
||||||
<div class="note-detail-readonly-code note-detail-printable">
|
<div class="note-detail-readonly-code note-detail-printable">
|
||||||
@ -33,7 +34,7 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget {
|
|||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
|
|
||||||
const isFormattable = note.type === "text" && this.noteContext?.viewScope?.viewMode === "source";
|
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._update(note, content);
|
||||||
this.show();
|
this.show();
|
||||||
@ -54,52 +55,4 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget {
|
|||||||
|
|
||||||
resolve(this.$editor);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user