diff --git a/apps/client/src/services/content_renderer.ts b/apps/client/src/services/content_renderer.ts index aef64291f..7137efae7 100644 --- a/apps/client/src/services/content_renderer.ts +++ b/apps/client/src/services/content_renderer.ts @@ -194,7 +194,7 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: if (type === "pdf") { const $pdfPreview = $(''); - $pdfPreview.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open`)); + $pdfPreview.attr("src", openService.getUrlForDownload(`pdfjs/web/viewer.html?file=../../api/${entityType}/${entityId}/open`)); $content.append($pdfPreview); } else if (type === "audio") { @@ -218,14 +218,14 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: // in attachment list const $downloadButton = $(` `); const $openButton = $(` `); diff --git a/apps/client/src/types-pdfjs.d.ts b/apps/client/src/types-pdfjs.d.ts index 12756778e..3a4b3f16d 100644 --- a/apps/client/src/types-pdfjs.d.ts +++ b/apps/client/src/types-pdfjs.d.ts @@ -45,6 +45,10 @@ interface WithContext { interface PdfDocumentModifiedMessage extends WithContext { type: "pdfjs-viewer-document-modified"; +} + +interface PdfDocumentBlobResultMessage extends WithContext { + type: "pdfjs-viewer-blob"; data: Uint8Array; } @@ -113,4 +117,5 @@ type PdfMessageEvent = MessageEvent< | PdfViewerThumbnailMessage | PdfViewerAttachmentsMessage | PdfViewerLayersMessage + | PdfDocumentBlobResultMessage >; diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 7fade29b9..d0031fe07 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -170,6 +170,73 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on return spacedUpdate; } +export function useBlobEditorSpacedUpdate({ note, noteType, noteContext, getData, onContentChange, dataSaved, updateInterval, replaceWithoutRevision }: { + noteType: NoteType; + note: FNote, + noteContext: NoteContext | null | undefined, + getData: () => Promise | Blob | undefined, + onContentChange: (newBlob: FBlob) => void, + dataSaved?: (savedData: Blob) => void, + updateInterval?: number; + /** If set to true, then the blob is replaced directly without saving a revision before. */ + replaceWithoutRevision?: boolean; +}) { + const parentComponent = useContext(ParentComponent); + const blob = useNoteBlob(note, parentComponent?.componentId); + + const callback = useMemo(() => { + return async () => { + const data = await getData(); + + // for read only notes + if (data === undefined || note.type !== noteType) return; + + protected_session_holder.touchProtectedSessionIfNecessary(note); + await server.upload(`notes/${note.noteId}/file?replace=${replaceWithoutRevision ? "1" : "0"}`, new File([ data ], note.title, { type: note.mime }), parentComponent?.componentId); + dataSaved?.(data); + }; + }, [ note, getData, dataSaved, noteType, parentComponent, replaceWithoutRevision ]); + const stateCallback = useCallback((state) => { + noteContext?.setContextData("saveState", { + state + }); + }, [ noteContext ]); + const spacedUpdate = useSpacedUpdate(callback, updateInterval, stateCallback); + + // React to note/blob changes. + useEffect(() => { + if (!blob) return; + spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob)); + }, [ blob ]); + + // React to update interval changes. + useEffect(() => { + if (!updateInterval) return; + spacedUpdate.setUpdateInterval(updateInterval); + }, [ updateInterval ]); + + // Save if needed upon switching tabs. + useTriliumEvent("beforeNoteSwitch", async ({ noteContext: eventNoteContext }) => { + if (eventNoteContext.ntxId !== noteContext?.ntxId) return; + await spacedUpdate.updateNowIfNecessary(); + }); + + // Save if needed upon tab closing. + useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => { + if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return; + await spacedUpdate.updateNowIfNecessary(); + }); + + // Save if needed upon window/browser closing. + useEffect(() => { + const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate(); + appContext.addBeforeUnloadListener(listener); + return () => appContext.removeBeforeUnloadListener(listener); + }, []); + + return spacedUpdate; +} + export function useNoteSavedData(noteId: string | undefined) { return useSyncExternalStore( (cb) => noteId ? noteSavedDataStore.subscribe(noteId, cb) : () => {}, diff --git a/apps/client/src/widgets/type_widgets/file/Pdf.tsx b/apps/client/src/widgets/type_widgets/file/Pdf.tsx index ad4bb8f56..ee731448a 100644 --- a/apps/client/src/widgets/type_widgets/file/Pdf.tsx +++ b/apps/client/src/widgets/type_widgets/file/Pdf.tsx @@ -4,9 +4,8 @@ import appContext from "../../../components/app_context"; import type NoteContext from "../../../components/note_context"; import FBlob from "../../../entities/fblob"; import FNote from "../../../entities/fnote"; -import server from "../../../services/server"; import { useViewModeConfig } from "../../collections/NoteList"; -import { useTriliumEvent } from "../../react/hooks"; +import { useBlobEditorSpacedUpdate, useTriliumEvent } from "../../react/hooks"; import PdfViewer from "./PdfViewer"; export default function PdfPreview({ note, blob, componentId, noteContext }: { @@ -18,12 +17,48 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: { const iframeRef = useRef(null); const historyConfig = useViewModeConfig(note, "pdfHistory"); + const spacedUpdate = useBlobEditorSpacedUpdate({ + note, + noteType: "file", + noteContext, + getData() { + if (!iframeRef.current?.contentWindow) return undefined; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout while waiting for blob response")); + }, 10_000); + + const onMessageReceived = (event: PdfMessageEvent) => { + if (event.data.type !== "pdfjs-viewer-blob") return; + if (event.data.noteId !== note.noteId || event.data.ntxId !== noteContext.ntxId) return; + const blob = new Blob([event.data.data as Uint8Array], { type: note.mime }); + + clearTimeout(timeout); + window.removeEventListener("message", onMessageReceived); + resolve(blob); + }; + + window.addEventListener("message", onMessageReceived); + iframeRef.current?.contentWindow?.postMessage({ + type: "trilium-request-blob", + }, window.location.origin); + }); + }, + onContentChange() { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.location.reload(); + } + }, + replaceWithoutRevision: true + }); + useEffect(() => { function handleMessage(event: PdfMessageEvent) { if (event.data?.type === "pdfjs-viewer-document-modified") { - const blob = new Blob([event.data.data as Uint8Array], { type: note.mime }); if (event.data.noteId === note.noteId && event.data.ntxId === noteContext.ntxId) { - server.upload(`notes/${note.noteId}/file`, new File([blob], note.title, { type: note.mime }), componentId); + spacedUpdate.resetUpdateTimer(); + spacedUpdate.scheduleUpdate(); } } @@ -138,13 +173,6 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: { }; }, [ note, historyConfig, componentId, blob, noteContext ]); - // Refresh when blob changes. - useEffect(() => { - if (iframeRef.current?.contentWindow) { - iframeRef.current.contentWindow.location.reload(); - } - }, [ blob ]); - useTriliumEvent("customDownload", ({ ntxId }) => { if (ntxId !== noteContext.ntxId) return; iframeRef.current?.contentWindow?.postMessage({ @@ -171,6 +199,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: { }); } }} + editable /> ); } diff --git a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx index 82bc4a2b9..7e6870dd4 100644 --- a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx +++ b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx @@ -15,12 +15,16 @@ interface PdfViewerProps extends Pick, "tabInd /** Note: URLs are relative to /pdfjs/web. */ pdfUrl: string; onLoad?(): void; + /** + * If set, enables editable mode which includes persistence of user settings, annotations as well as specific features such as sending table of contents data for the sidebar. + */ + editable?: boolean; } /** * Reusable component displaying a PDF. The PDF needs to be provided via a URL. */ -export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad }: PdfViewerProps) { +export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable }: PdfViewerProps) { const iframeRef = useSyncedRef(externalIframeRef, null); const [ locale ] = useTriliumOption("locale"); const [ newLayout ] = useTriliumOptionBool("newLayout"); @@ -30,7 +34,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad