mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	feat: show source diff between note and revision
This commit is contained in:
		| @@ -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, | ||||
|   | ||||
| @@ -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?", | ||||
|   | ||||
| @@ -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>>,  | ||||
|     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); | ||||
|                                     }} | ||||
|                                 /> | ||||
|                             )} | ||||
|                               | ||||
|                             <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 <></>; | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user