diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index e0b8c651b..2ad4003f5 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -473,6 +473,11 @@ type EventMappings = { noteContextRemoved: { ntxIds: string[]; }; + contextDataChanged: { + noteContext: NoteContext; + key: string; + value: unknown; + }; exportSvg: { ntxId: string | null | undefined; }; exportPng: { ntxId: string | null | undefined; }; geoMapCreateChildNote: { diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 5295007a7..767206167 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -12,6 +12,7 @@ import server from "../services/server.js"; import treeService from "../services/tree.js"; import utils from "../services/utils.js"; import { ReactWrappedWidget } from "../widgets/basic_widget.js"; +import type { HeadingContext } from "../widgets/sidebar/TableOfContents.js"; import appContext, { type EventData, type EventListener } from "./app_context.js"; import Component from "./component.js"; @@ -22,6 +23,26 @@ export interface SetNoteOpts { export type GetTextEditorCallback = (editor: CKTextEditor) => void; +export interface NoteContextDataMap { + toc: HeadingContext; + pdfPages: { + totalPages: number; + currentPage: number; + scrollToPage(page: number): void; + requestThumbnail(page: number): void; + }; + pdfAttachments: { + attachments: Array<{ filename: string; size: number }>; + downloadAttachment(filename: string): void; + }; + pdfLayers: { + layers: Array<{ id: string; name: string; visible: boolean }>; + toggleLayer(layerId: string, visible: boolean): void; + }; +} + +type ContextDataKey = keyof NoteContextDataMap; + class NoteContext extends Component implements EventListener<"entitiesReloaded"> { ntxId: string | null; hoistedNoteId: string; @@ -32,6 +53,13 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> parentNoteId?: string | null; viewScope?: ViewScope; + /** + * Metadata storage for UI components (e.g., table of contents, PDF page list, code outline). + * This allows type widgets to publish data that sidebar/toolbar components can consume. + * Data is automatically cleared when navigating to a different note. + */ + private contextData: Map = new Map(); + constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) { super(); @@ -91,6 +119,22 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> this.viewScope = opts.viewScope; ({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath)); + // Clear context data when switching notes and notify subscribers + const oldKeys = Array.from(this.contextData.keys()); + this.contextData.clear(); + if (oldKeys.length > 0) { + // Notify subscribers asynchronously to avoid blocking navigation + window.setTimeout(() => { + for (const key of oldKeys) { + this.triggerEvent("contextDataChanged", { + noteContext: this, + key, + value: undefined + }); + } + }, 0); + } + this.saveToRecentNotes(resolvedNotePath); protectedSessionHolder.touchProtectedSessionIfNecessary(this.note); @@ -443,6 +487,52 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return title; } + + /** + * Set metadata for this note context (e.g., table of contents, PDF pages, code outline). + * This data can be consumed by sidebar/toolbar components. + * + * @param key - Unique identifier for the data type (e.g., "toc", "pdfPages", "codeOutline") + * @param value - The data to store (will be cleared when switching notes) + */ + setContextData(key: K, value: NoteContextDataMap[K]): void { + this.contextData.set(key, value); + // Trigger event so subscribers can react + this.triggerEvent("contextDataChanged", { + noteContext: this, + key, + value + }); + } + + /** + * Get metadata for this note context. + * + * @param key - The data key to retrieve + * @returns The stored data, or undefined if not found + */ + getContextData(key: K): NoteContextDataMap[K] | undefined { + return this.contextData.get(key) as NoteContextDataMap[K] | undefined; + } + + /** + * Check if context data exists for a given key. + */ + hasContextData(key: ContextDataKey): boolean { + return this.contextData.has(key); + } + + /** + * Clear specific context data. + */ + clearContextData(key: ContextDataKey): void { + this.contextData.delete(key); + this.triggerEvent("contextDataChanged", { + noteContext: this, + key, + value: undefined + }); + } } export function openInCurrentNoteContext(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent | null, notePath: string, viewScope?: ViewScope) { diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 9dff8fef2..74de00884 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -187,13 +187,15 @@ export function formatSize(size: number | null | undefined) { return ""; } - size = Math.max(Math.round(size / 1024), 1); - - if (size < 1024) { - return `${size} KiB`; + if (size === 0) { + return "0 B"; } - return `${Math.round(size / 102.4) / 10} MiB`; + const k = 1024; + const sizes = ["B", "KiB", "MiB", "GiB"]; + const i = Math.floor(Math.log(size) / Math.log(k)); + + return `${Math.round((size / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`; } function toObject(array: T[], fn: (arg0: T) => [key: string, value: R]) { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 3cfc4ef1c..c9eb25ee3 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2238,5 +2238,15 @@ "empty_button": "Hide the panel", "toggle": "Toggle right panel", "custom_widget_go_to_source": "Go to source code" + }, + "pdf": { + "attachments_one": "{{count}} attachment", + "attachments_other": "{{count}} attachments", + "layers_one": "{{count}} layer", + "layers_other": "{{count}} layers", + "pages_one": "{{count}} page", + "pages_other": "{{count}} pages", + "pages_alt": "Page {{pageNumber}}", + "pages_loading": "Loading..." } } diff --git a/apps/client/src/types-pdfjs.d.ts b/apps/client/src/types-pdfjs.d.ts index 54a440f16..13cb91f6e 100644 --- a/apps/client/src/types-pdfjs.d.ts +++ b/apps/client/src/types-pdfjs.d.ts @@ -1,3 +1,15 @@ interface Window { + /** + * By default, pdf.js will try to store information about the opened PDFs such as zoom and scroll position in local storage. + * The Trilium alternative is to use attachments stored at note level. + * This variable represents the direct content used by the pdf.js viewer in its local storage key, but in plain JS object format. + * The variable must be set early at startup, before pdf.js fully initializes. + */ TRILIUM_VIEW_HISTORY_STORE?: object; + + /** + * If set to true, hides the pdf.js viewer default sidebar containing the outline, page navigation, etc. + * This needs to be set early in the main method. + */ + TRILIUM_HIDE_SIDEBAR?: boolean; } diff --git a/apps/client/src/widgets/note_types.tsx b/apps/client/src/widgets/note_types.tsx index a6b3feb27..3f3ab5e6f 100644 --- a/apps/client/src/widgets/note_types.tsx +++ b/apps/client/src/widgets/note_types.tsx @@ -4,7 +4,8 @@ */ import { NoteType } from "@triliumnext/commons"; -import { VNode, type JSX } from "preact"; +import { type JSX, VNode } from "preact"; + import { TypeWidgetProps } from "./type_widgets/type_widget"; /** @@ -13,7 +14,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget"; */ export type ExtendedNoteType = Exclude | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "aiChat"; -export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element); +export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined); type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget); interface NoteTypeMapping { diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 6c5b31061..4b499dd5a 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -8,7 +8,7 @@ import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayou import appContext, { EventData, EventNames } from "../../components/app_context"; import Component from "../../components/component"; -import NoteContext from "../../components/note_context"; +import NoteContext, { NoteContextDataMap } from "../../components/note_context"; import FBlob from "../../entities/fblob"; import FNote from "../../entities/fnote"; import attributes from "../../services/attributes"; @@ -1207,3 +1207,92 @@ export function useContentElement(noteContext: NoteContext | null | undefined) { return contentElement; } + +/** + * Set context data on the current note context. + * This allows type widgets to publish data (e.g., table of contents, PDF pages) + * that can be consumed by sidebar/toolbar components. + * + * Data is automatically cleared when navigating to a different note. + * + * @param key - Unique identifier for the data type (e.g., "toc", "pdfPages") + * @param value - The data to publish + * + * @example + * // In a PDF viewer widget: + * const { noteContext } = useActiveNoteContext(); + * useSetContextData(noteContext, "pdfPages", pages); + */ +export function useSetContextData( + noteContext: NoteContext | null | undefined, + key: K, + value: NoteContextDataMap[K] | undefined +) { + useEffect(() => { + if (!noteContext) return; + + if (value !== undefined) { + noteContext.setContextData(key, value); + } else { + noteContext.clearContextData(key); + } + + return () => { + noteContext.clearContextData(key); + }; + }, [noteContext, key, value]); +} + +/** + * Get context data from the active note context. + * This is typically used in sidebar/toolbar components that need to display + * data published by type widgets. + * + * The component will automatically re-render when the data changes. + * + * @param key - The data key to retrieve (e.g., "toc", "pdfPages") + * @returns The current data, or undefined if not available + * + * @example + * // In a Table of Contents sidebar widget: + * function TableOfContents() { + * const headings = useGetContextData("toc"); + * if (!headings) return
No headings available
; + * return
    {headings.map(h =>
  • {h.text}
  • )}
; + * } + */ +export function useGetContextData(key: K): NoteContextDataMap[K] | undefined { + const { noteContext } = useActiveNoteContext(); + return useGetContextDataFrom(noteContext, key); +} + +/** + * Get context data from a specific note context (not necessarily the active one). + * + * @param noteContext - The specific note context to get data from + * @param key - The data key to retrieve + * @returns The current data, or undefined if not available + */ +export function useGetContextDataFrom( + noteContext: NoteContext | null | undefined, + key: K +): NoteContextDataMap[K] | undefined { + const [data, setData] = useState(() => + noteContext?.getContextData(key) + ); + + // Update initial value when noteContext changes + useEffect(() => { + setData(noteContext?.getContextData(key)); + }, [noteContext, key]); + + // Subscribe to changes via Trilium event system + useTriliumEvent("contextDataChanged", ({ noteContext: eventNoteContext, key: changedKey, value }) => { + if (eventNoteContext === noteContext && changedKey === key) { + setData(value as NoteContextDataMap[K]); + } + }); + + return data; +} + diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index a512d1362..8356e0bd7 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -15,6 +15,9 @@ import { useActiveNoteContext, useLegacyWidget, useNoteProperty, useTriliumEvent import Icon from "../react/Icon"; import LegacyRightPanelWidget from "../right_panel_widget"; import HighlightsList from "./HighlightsList"; +import PdfAttachments from "./pdf/PdfAttachments"; +import PdfLayers from "./pdf/PdfLayers"; +import PdfPages from "./pdf/PdfPages"; import RightPanelWidget from "./RightPanelWidget"; import TableOfContents from "./TableOfContents"; @@ -57,13 +60,27 @@ export default function RightPanelContainer({ widgetsByParent }: { widgetsByPare function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) { const { note } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); + const noteMime = useNoteProperty(note, "mime"); const [ highlightsList ] = useTriliumOptionJson("highlightsList"); + const isPdf = noteType === "file" && noteMime === "application/pdf"; if (!rightPaneVisible) return []; const definitions: RightPanelWidgetDefinition[] = [ { el: , - enabled: (noteType === "text" || noteType === "doc"), + enabled: (noteType === "text" || noteType === "doc" || isPdf), + }, + { + el: , + enabled: isPdf, + }, + { + el: , + enabled: isPdf, + }, + { + el: , + enabled: isPdf, }, { el: , diff --git a/apps/client/src/widgets/sidebar/TableOfContents.css b/apps/client/src/widgets/sidebar/TableOfContents.css index 79c6ee548..bd66bcdb8 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.css +++ b/apps/client/src/widgets/sidebar/TableOfContents.css @@ -29,6 +29,11 @@ hyphens: auto; } +.toc li.active > .item-content { + font-weight: bold; + color: var(--main-text-color); +} + .toc > ol { --toc-depth-level: 1; } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 44a912af0..c072c8599 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; import { randomString } from "../../services/utils"; -import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; +import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; import Icon from "../react/Icon"; import RightPanelWidget from "./RightPanelWidget"; @@ -21,29 +21,50 @@ interface HeadingsWithNesting extends RawHeading { children: HeadingsWithNesting[]; } +export interface HeadingContext { + scrollToHeading(heading: RawHeading): void; + headings: RawHeading[]; + activeHeadingId?: string | null; +} + export default function TableOfContents() { const { note, noteContext } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); + const noteMime = useNoteProperty(note, "mime"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); return ( {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } {noteType === "text" && !isReadOnly && } + {noteType === "file" && noteMime === "application/pdf" && } ); } -function AbstractTableOfContents({ headings, scrollToHeading }: { +function PdfTableOfContents() { + const data = useGetContextData("toc"); + + return ( + {})} + activeHeadingId={data?.activeHeadingId} + /> + ); +} + +function AbstractTableOfContents({ headings, scrollToHeading, activeHeadingId }: { headings: T[]; scrollToHeading(heading: T): void; + activeHeadingId?: string | null; }) { const nestedHeadings = buildHeadingTree(headings); return ( {nestedHeadings.length > 0 ? (
    - {nestedHeadings.map(heading => )} + {nestedHeadings.map(heading => )}
) : (
{t("toc.no_headings")}
@@ -52,14 +73,16 @@ function AbstractTableOfContents({ headings, scrollToHeadi ); } -function TableOfContentsHeading({ heading, scrollToHeading }: { +function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: { heading: HeadingsWithNesting; scrollToHeading(heading: RawHeading): void; + activeHeadingId?: string | null; }) { const [ collapsed, setCollapsed ] = useState(false); + const isActive = heading.id === activeHeadingId; return ( <> -
  • +
  • {heading.children.length > 0 && ( {heading.children && (
      - {heading.children.map(heading => )} + {heading.children.map(heading => )}
    )} diff --git a/apps/client/src/widgets/sidebar/pdf/PdfAttachments.css b/apps/client/src/widgets/sidebar/pdf/PdfAttachments.css new file mode 100644 index 000000000..30c8fa573 --- /dev/null +++ b/apps/client/src/widgets/sidebar/pdf/PdfAttachments.css @@ -0,0 +1,57 @@ +.pdf-attachments-list { + width: 100%; +} + +.pdf-attachment-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--main-border-color); + transition: background-color 0.2s; +} + +.pdf-attachment-item:hover { + background-color: var(--hover-item-background-color); +} + +.pdf-attachment-item:last-child { + border-bottom: none; +} + +.pdf-attachment-info { + flex: 1; + min-width: 0; +} + +.pdf-attachment-filename { + font-size: 13px; + font-weight: 500; + color: var(--main-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pdf-attachment-size { + font-size: 11px; + color: var(--muted-text-color); + margin-top: 2px; +} + +.no-attachments { + padding: 16px; + text-align: center; + color: var(--muted-text-color); +} + +.pdf-attachment-item .bx { + flex-shrink: 0; + font-size: 18px; + color: var(--muted-text-color); +} + +.pdf-attachment-item:hover .bx { + color: var(--main-text-color); +} diff --git a/apps/client/src/widgets/sidebar/pdf/PdfAttachments.tsx b/apps/client/src/widgets/sidebar/pdf/PdfAttachments.tsx new file mode 100644 index 000000000..1794ebe52 --- /dev/null +++ b/apps/client/src/widgets/sidebar/pdf/PdfAttachments.tsx @@ -0,0 +1,62 @@ +import "./PdfAttachments.css"; + +import { t } from "../../../services/i18n"; +import { formatSize } from "../../../services/utils"; +import { useActiveNoteContext, useGetContextData, useNoteProperty } from "../../react/hooks"; +import Icon from "../../react/Icon"; +import RightPanelWidget from "../RightPanelWidget"; + +interface AttachmentInfo { + filename: string; + size: number; +} + +export default function PdfAttachments() { + const { note } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const noteMime = useNoteProperty(note, "mime"); + const attachmentsData = useGetContextData("pdfAttachments"); + + if (noteType !== "file" || noteMime !== "application/pdf") { + return null; + } + + if (!attachmentsData || attachmentsData.attachments.length === 0) { + return null; + } + + return ( + +
    + {attachmentsData.attachments.map((attachment) => ( + + ))} +
    +
    + ); +} + +function PdfAttachmentItem({ + attachment, + onDownload +}: { + attachment: AttachmentInfo; + onDownload: (filename: string) => void; +}) { + const sizeText = formatSize(attachment.size); + + return ( +
    onDownload(attachment.filename)}> + +
    +
    {attachment.filename}
    +
    {sizeText}
    +
    + +
    + ); +} diff --git a/apps/client/src/widgets/sidebar/pdf/PdfLayers.css b/apps/client/src/widgets/sidebar/pdf/PdfLayers.css new file mode 100644 index 000000000..cde66f218 --- /dev/null +++ b/apps/client/src/widgets/sidebar/pdf/PdfLayers.css @@ -0,0 +1,54 @@ +.pdf-layers-list { + width: 100%; +} + +.pdf-layer-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--main-border-color); + transition: background-color 0.2s; +} + +.pdf-layer-item:hover { + background-color: var(--hover-item-background-color); +} + +.pdf-layer-item:last-child { + border-bottom: none; +} + +.pdf-layer-item.hidden { + opacity: 0.5; +} + +.pdf-layer-name { + flex: 1; + font-size: 13px; + color: var(--main-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.no-layers { + padding: 16px; + text-align: center; + color: var(--muted-text-color); +} + +.pdf-layer-item .bx { + flex-shrink: 0; + font-size: 18px; + color: var(--muted-text-color); +} + +.pdf-layer-item:hover .bx { + color: var(--main-text-color); +} + +.pdf-layer-item.visible .bx { + color: var(--main-text-color); +} diff --git a/apps/client/src/widgets/sidebar/pdf/PdfLayers.tsx b/apps/client/src/widgets/sidebar/pdf/PdfLayers.tsx new file mode 100644 index 000000000..482c9113b --- /dev/null +++ b/apps/client/src/widgets/sidebar/pdf/PdfLayers.tsx @@ -0,0 +1,55 @@ +import "./PdfLayers.css"; + +import { t } from "../../../services/i18n"; +import { useActiveNoteContext, useGetContextData, useNoteProperty } from "../../react/hooks"; +import Icon from "../../react/Icon"; +import RightPanelWidget from "../RightPanelWidget"; + +interface LayerInfo { + id: string; + name: string; + visible: boolean; +} + +export default function PdfLayers() { + const { note } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const noteMime = useNoteProperty(note, "mime"); + const layersData = useGetContextData("pdfLayers"); + + if (noteType !== "file" || noteMime !== "application/pdf") { + return null; + } + + return (layersData?.layers && layersData.layers.length > 0 && + +
    + {layersData.layers.map((layer) => ( + + ))} +
    +
    + ); +} + +function PdfLayerItem({ + layer, + onToggle +}: { + layer: LayerInfo; + onToggle: (layerId: string, visible: boolean) => void; +}) { + return ( +
    onToggle(layer.id, !layer.visible)} + > + +
    {layer.name}
    +
    + ); +} diff --git a/apps/client/src/widgets/sidebar/pdf/PdfPages.css b/apps/client/src/widgets/sidebar/pdf/PdfPages.css new file mode 100644 index 000000000..408f45333 --- /dev/null +++ b/apps/client/src/widgets/sidebar/pdf/PdfPages.css @@ -0,0 +1,67 @@ +.pdf-pages-list { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 8px; + padding: 8px; + align-content: flex-start; +} + +.pdf-page-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.2s; + box-sizing: border-box; + position: relative; + + .pdf-page-number { + font-size: 12px; + margin-bottom: 4px; + color: var(--main-text-color); + position: absolute; + bottom: 1em; + left: 50%; + transform: translateX(-50%); + background-color: var(--accented-background-color); + padding: 0.2em 0.5em; + border-radius: 4px; + } +} + +.pdf-page-item:hover { + background-color: var(--hover-item-background-color); +} + +.pdf-page-item.active { + border-color: var(--main-border-color); + background-color: var(--active-item-background-color); +} + +.pdf-page-thumbnail { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.pdf-page-thumbnail img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.pdf-page-loading { + color: var(--muted-text-color); + font-size: 11px; +} + +.no-pages { + padding: 16px; + text-align: center; + color: var(--muted-text-color); +} diff --git a/apps/client/src/widgets/sidebar/pdf/PdfPages.tsx b/apps/client/src/widgets/sidebar/pdf/PdfPages.tsx new file mode 100644 index 000000000..fd6c2bcaf --- /dev/null +++ b/apps/client/src/widgets/sidebar/pdf/PdfPages.tsx @@ -0,0 +1,111 @@ +import "./PdfPages.css"; + +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; + +import { NoteContextDataMap } from "../../../components/note_context"; +import { t } from "../../../services/i18n"; +import { useActiveNoteContext, useGetContextData, useNoteProperty } from "../../react/hooks"; +import RightPanelWidget from "../RightPanelWidget"; + +export default function PdfPages() { + const { note } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const noteMime = useNoteProperty(note, "mime"); + const pagesData = useGetContextData("pdfPages"); + + if (noteType !== "file" || noteMime !== "application/pdf") { + return null; + } + + return (pagesData && + + + + ); +} + +function PdfPagesList({ pagesData }: { pagesData: NoteContextDataMap["pdfPages"] }) { + const [thumbnails, setThumbnails] = useState>(new Map()); + const requestedThumbnails = useRef>(new Set()); + + useEffect(() => { + // Listen for thumbnail responses via custom event + function handleThumbnail(event: CustomEvent) { + const { pageNumber, dataUrl } = event.detail; + setThumbnails(prev => new Map(prev).set(pageNumber, dataUrl)); + } + + window.addEventListener("pdf-thumbnail", handleThumbnail as EventListener); + return () => { + window.removeEventListener("pdf-thumbnail", handleThumbnail as EventListener); + }; + }, []); + + const requestThumbnail = useCallback((pageNumber: number) => { + // Only request if we haven't already requested it and don't have it + if (!requestedThumbnails.current.has(pageNumber) && !thumbnails.has(pageNumber) && pagesData) { + requestedThumbnails.current.add(pageNumber); + pagesData.requestThumbnail(pageNumber); + } + }, [pagesData, thumbnails]); + + if (!pagesData || pagesData.totalPages === 0) { + return
    No pages available
    ; + } + + const pages = Array.from({ length: pagesData.totalPages }, (_, i) => i + 1); + + return ( +
    + {pages.map(pageNumber => ( + pagesData.scrollToPage(pageNumber)} + /> + ))} +
    + ); +} + +function PdfPageItem({ + pageNumber, + isActive, + thumbnail, + onRequestThumbnail, + onPageClick +}: { + pageNumber: number; + isActive: boolean; + thumbnail?: string; + onRequestThumbnail(page: number): void; + onPageClick(): void; +}) { + const hasRequested = useRef(false); + + useEffect(() => { + if (!thumbnail && !hasRequested.current) { + hasRequested.current = true; + onRequestThumbnail(pageNumber); + } + }, [pageNumber, thumbnail, onRequestThumbnail]); + + return ( +
    +
    {pageNumber}
    +
    + {thumbnail ? ( + {t("pdf.pages_alt", + ) : ( +
    {t("pdf.pages_loading")}
    + )} +
    +
    + ); +} diff --git a/apps/client/src/widgets/type_widgets/File.tsx b/apps/client/src/widgets/type_widgets/File.tsx index a26bb0987..0d5b03860 100644 --- a/apps/client/src/widgets/type_widgets/File.tsx +++ b/apps/client/src/widgets/type_widgets/File.tsx @@ -16,7 +16,7 @@ export default function FileTypeWidget({ note, parentComponent, noteContext }: T if (blob?.content) { return ; } else if (note.mime === "application/pdf") { - return ; + return noteContext && ; } else if (note.mime.startsWith("video/")) { return ; } else if (note.mime.startsWith("audio/")) { diff --git a/apps/client/src/widgets/type_widgets/file/Pdf.tsx b/apps/client/src/widgets/type_widgets/file/Pdf.tsx index 1c4e24573..c915a0a5d 100644 --- a/apps/client/src/widgets/type_widgets/file/Pdf.tsx +++ b/apps/client/src/widgets/type_widgets/file/Pdf.tsx @@ -2,11 +2,12 @@ import { RefObject } from "preact"; import { useCallback, useEffect, useRef } from "preact/hooks"; 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 { useTriliumOption } from "../../react/hooks"; +import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; const VARIABLE_WHITELIST = new Set([ "root-background", @@ -15,16 +16,17 @@ const VARIABLE_WHITELIST = new Set([ "main-text-color" ]); -export default function PdfPreview({ note, blob, componentId, ntxId }: { - note: FNote, - blob: FBlob | null | undefined, +export default function PdfPreview({ note, blob, componentId, noteContext }: { + note: FNote; + noteContext: NoteContext; + blob: FBlob | null | undefined; componentId: string | undefined; - ntxId: string | null | undefined; }) { const iframeRef = useRef(null); const { onLoad } = useStyleInjection(iframeRef); const historyConfig = useViewModeConfig(note, "pdfHistory"); const [ locale ] = useTriliumOption("locale"); + const [ newLayout ] = useTriliumOptionBool("newLayout"); useEffect(() => { function handleMessage(event: MessageEvent) { @@ -36,13 +38,111 @@ export default function PdfPreview({ note, blob, componentId, ntxId }: { if (event.data.type === "pdfjs-viewer-save-view-history" && event.data?.data) { historyConfig?.storeFn(JSON.parse(event.data.data)); } + + if (event.data.type === "pdfjs-viewer-toc") { + if (event.data.data) { + // Convert PDF outline to HeadingContext format + const headings = convertPdfOutlineToHeadings(event.data.data); + noteContext.setContextData("toc", { + headings, + activeHeadingId: null, + scrollToHeading: (heading) => { + iframeRef.current?.contentWindow?.postMessage({ + type: "trilium-scroll-to-heading", + headingId: heading.id + }, window.location.origin); + } + }); + } else { + // No ToC available, use empty headings + noteContext.setContextData("toc", { + headings: [], + activeHeadingId: null, + scrollToHeading: () => {} + }); + } + } + + if (event.data.type === "pdfjs-viewer-active-heading") { + const currentToc = noteContext.getContextData("toc"); + if (currentToc) { + noteContext.setContextData("toc", { + ...currentToc, + activeHeadingId: event.data.headingId + }); + } + } + + if (event.data.type === "pdfjs-viewer-page-info") { + noteContext.setContextData("pdfPages", { + totalPages: event.data.totalPages, + currentPage: event.data.currentPage, + scrollToPage: (page: number) => { + iframeRef.current?.contentWindow?.postMessage({ + type: "trilium-scroll-to-page", + pageNumber: page + }, window.location.origin); + }, + requestThumbnail: (page: number) => { + iframeRef.current?.contentWindow?.postMessage({ + type: "trilium-request-thumbnail", + pageNumber: page + }, window.location.origin); + } + }); + } + + if (event.data.type === "pdfjs-viewer-current-page") { + const currentPages = noteContext.getContextData("pdfPages"); + if (currentPages) { + noteContext.setContextData("pdfPages", { + ...currentPages, + currentPage: event.data.currentPage + }); + } + } + + if (event.data.type === "pdfjs-viewer-thumbnail") { + // Forward thumbnail to any listeners + window.dispatchEvent(new CustomEvent("pdf-thumbnail", { + detail: { + pageNumber: event.data.pageNumber, + dataUrl: event.data.dataUrl + } + })); + } + + if (event.data.type === "pdfjs-viewer-attachments") { + noteContext.setContextData("pdfAttachments", { + attachments: event.data.attachments, + downloadAttachment: (filename: string) => { + iframeRef.current?.contentWindow?.postMessage({ + type: "trilium-download-attachment", + filename + }, window.location.origin); + } + }); + } + + if (event.data.type === "pdfjs-viewer-layers") { + noteContext.setContextData("pdfLayers", { + layers: event.data.layers, + toggleLayer: (layerId: string, visible: boolean) => { + iframeRef.current?.contentWindow?.postMessage({ + type: "trilium-toggle-layer", + layerId, + visible + }, window.location.origin); + } + }); + } } window.addEventListener("message", handleMessage); return () => { window.removeEventListener("message", handleMessage); }; - }, [ note, historyConfig, componentId, blob ]); + }, [ note, historyConfig, componentId, blob, noteContext ]); // Refresh when blob changes. useEffect(() => { @@ -57,8 +157,8 @@ export default function PdfPreview({ note, blob, componentId, ntxId }: { if (!iframe) return; const handleIframeClick = () => { - if (ntxId) { - appContext.tabManager.activateNoteContext(ntxId); + if (noteContext.ntxId) { + appContext.tabManager.activateNoteContext(noteContext.ntxId); } }; @@ -68,14 +168,14 @@ export default function PdfPreview({ note, blob, componentId, ntxId }: { iframeDoc.addEventListener('click', handleIframeClick); return () => iframeDoc.removeEventListener('click', handleIframeClick); } - }, [ iframeRef.current?.contentWindow, ntxId ]); + }, [ iframeRef.current?.contentWindow, noteContext ]); return (historyConfig &&