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