Compare commits
16 Commits
feature/pd
...
feat/extra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366166a561 | ||
|
|
4d2b02eddb | ||
|
|
07871853a5 | ||
|
|
254145f0e5 | ||
|
|
c28f11336e | ||
|
|
2e30683b7b | ||
|
|
0af7b8b145 | ||
|
|
5d39b84886 | ||
|
|
537c4051cc | ||
|
|
d0a22bc517 | ||
|
|
19a75acf3f | ||
|
|
3f0abce874 | ||
|
|
36dd29f919 | ||
|
|
d7838f0b67 | ||
|
|
3353d4f436 | ||
|
|
7740154bdc |
@@ -473,11 +473,6 @@ type EventMappings = {
|
||||
noteContextRemoved: {
|
||||
ntxIds: string[];
|
||||
};
|
||||
contextDataChanged: {
|
||||
noteContext: NoteContext;
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
exportSvg: { ntxId: string | null | undefined; };
|
||||
exportPng: { ntxId: string | null | undefined; };
|
||||
geoMapCreateChildNote: {
|
||||
@@ -540,6 +535,7 @@ export type FilteredCommandNames<T extends CommandData> = keyof Pick<CommandMapp
|
||||
|
||||
export class AppContext extends Component {
|
||||
isMainWindow: boolean;
|
||||
windowId: string;
|
||||
components: Component[];
|
||||
beforeUnloadListeners: (WeakRef<BeforeUploadListener> | (() => boolean))[];
|
||||
tabManager!: TabManager;
|
||||
@@ -548,10 +544,11 @@ export class AppContext extends Component {
|
||||
|
||||
lastSearchString?: string;
|
||||
|
||||
constructor(isMainWindow: boolean) {
|
||||
constructor(isMainWindow: boolean, windowId: string) {
|
||||
super();
|
||||
|
||||
this.isMainWindow = isMainWindow;
|
||||
this.windowId = windowId;
|
||||
// non-widget/layout components needed for the application
|
||||
this.components = [];
|
||||
this.beforeUnloadListeners = [];
|
||||
@@ -681,8 +678,7 @@ export class AppContext extends Component {
|
||||
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener);
|
||||
}
|
||||
}
|
||||
|
||||
const appContext = new AppContext(window.glob.isMainWindow);
|
||||
const appContext = new AppContext(window.glob.isMainWindow, window.glob.windowId);
|
||||
|
||||
// we should save all outstanding changes before the page/app is closed
|
||||
$(window).on("beforeunload", () => {
|
||||
|
||||
@@ -142,14 +142,15 @@ export default class Entrypoints extends Component {
|
||||
}
|
||||
|
||||
async openInWindowCommand({ notePath, hoistedNoteId, viewScope }: NoteCommandData) {
|
||||
const extraWindowId = utils.randomString(4);
|
||||
const extraWindowHash = linkService.calculateHash({ notePath, hoistedNoteId, viewScope });
|
||||
|
||||
if (utils.isElectron()) {
|
||||
const { ipcRenderer } = utils.dynamicRequire("electron");
|
||||
|
||||
ipcRenderer.send("create-extra-window", { extraWindowHash });
|
||||
ipcRenderer.send("create-extra-window", { extraWindowId, extraWindowHash });
|
||||
} else {
|
||||
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=1${extraWindowHash}`;
|
||||
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=${extraWindowId}${extraWindowHash}`;
|
||||
|
||||
window.open(url, "", "width=1000,height=800");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
|
||||
@@ -23,26 +22,6 @@ 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;
|
||||
@@ -53,13 +32,6 @@ 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<string, unknown> = new Map();
|
||||
|
||||
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
|
||||
super();
|
||||
|
||||
@@ -119,17 +91,6 @@ 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();
|
||||
for (const key of oldKeys) {
|
||||
this.triggerEvent("contextDataChanged", {
|
||||
noteContext: this,
|
||||
key,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
|
||||
this.saveToRecentNotes(resolvedNotePath);
|
||||
|
||||
protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);
|
||||
@@ -482,52 +443,6 @@ 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<K extends ContextDataKey>(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<K extends ContextDataKey>(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<HTMLCanvasElement> | null, notePath: string, viewScope?: ViewScope) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import linkService from "../services/link.js";
|
||||
import type { EventData } from "./app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
|
||||
const MAX_SAVED_WINDOWS = 10;
|
||||
|
||||
interface TabState {
|
||||
contexts: NoteContext[];
|
||||
position: number;
|
||||
@@ -41,9 +43,6 @@ export default class TabManager extends Component {
|
||||
this.recentlyClosedTabs = [];
|
||||
|
||||
this.tabsUpdate = new SpacedUpdate(async () => {
|
||||
if (!appContext.isMainWindow) {
|
||||
return;
|
||||
}
|
||||
if (options.is("databaseReadonly")) {
|
||||
return;
|
||||
}
|
||||
@@ -52,9 +51,21 @@ export default class TabManager extends Component {
|
||||
.map((nc) => nc.getPojoState())
|
||||
.filter((t) => !!t);
|
||||
|
||||
await server.put("options", {
|
||||
openNoteContexts: JSON.stringify(openNoteContexts)
|
||||
});
|
||||
// Update the current window’s openNoteContexts in options
|
||||
const savedWindows = options.getJson("openNoteContexts") || [];
|
||||
const win = savedWindows.find(w => w.windowId === appContext.windowId);
|
||||
if (win) {
|
||||
win.contexts = openNoteContexts;
|
||||
} else {
|
||||
savedWindows.push({
|
||||
windowId: appContext.windowId,
|
||||
createdAt: Date.now(),
|
||||
closedAt: null,
|
||||
contexts: openNoteContexts
|
||||
});
|
||||
}
|
||||
|
||||
await options.save("openNoteContexts", JSON.stringify(savedWindows));
|
||||
});
|
||||
|
||||
appContext.addBeforeUnloadListener(this);
|
||||
@@ -69,8 +80,13 @@ export default class TabManager extends Component {
|
||||
}
|
||||
|
||||
async loadTabs() {
|
||||
// Get the current window’s openNoteContexts
|
||||
const savedWindows = options.getJson("openNoteContexts") || [];
|
||||
const currentWin = savedWindows.find(w => w.windowId === appContext.windowId);
|
||||
const openNoteContexts = currentWin ? currentWin.contexts : undefined;
|
||||
|
||||
try {
|
||||
const noteContextsToOpen = (appContext.isMainWindow && options.getJson("openNoteContexts")) || [];
|
||||
const noteContextsToOpen = openNoteContexts || [];
|
||||
|
||||
// preload all notes at once
|
||||
await froca.getNotes([...noteContextsToOpen.flatMap((tab: NoteContextState) =>
|
||||
@@ -119,6 +135,32 @@ export default class TabManager extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
// Save window contents
|
||||
if (currentWin) {
|
||||
currentWin.createdAt = Date.now();
|
||||
currentWin.closedAt = null;
|
||||
currentWin.contexts = filteredNoteContexts;
|
||||
} else {
|
||||
// Filter out the oldest entry (excluding the main window)
|
||||
if (savedWindows?.length >= MAX_SAVED_WINDOWS) {
|
||||
const candidates = savedWindows.filter(w => w.windowId !== "main");
|
||||
if (candidates.length > 0) {
|
||||
const oldest = candidates.reduce((a, b) =>
|
||||
a.createdAt < b.createdAt ? a : b
|
||||
);
|
||||
savedWindows.splice(savedWindows.indexOf(oldest), 1);
|
||||
}
|
||||
}
|
||||
savedWindows.push({
|
||||
windowId: appContext.windowId,
|
||||
createdAt: Date.now(),
|
||||
closedAt: null,
|
||||
contexts: filteredNoteContexts
|
||||
});
|
||||
}
|
||||
|
||||
await options.save("openNoteContexts", JSON.stringify(savedWindows));
|
||||
|
||||
// if there's a notePath in the URL, make sure it's open and active
|
||||
// (useful, for e.g., opening clipped notes from clipper or opening link in an extra window)
|
||||
if (parsedFromUrl.notePath) {
|
||||
|
||||
@@ -27,10 +27,6 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
loadResults.addRevision(ec.entityId, ec.noteId, ec.componentId);
|
||||
} else if (ec.entityName === "options") {
|
||||
const attributeEntity = ec.entity as FAttributeRow;
|
||||
if (attributeEntity.name === "openNoteContexts") {
|
||||
continue; // only noise
|
||||
}
|
||||
|
||||
options.set(attributeEntity.name as OptionNames, attributeEntity.value);
|
||||
loadResults.addOption(attributeEntity.name as OptionNames);
|
||||
} else if (ec.entityName === "attachments") {
|
||||
|
||||
@@ -187,15 +187,13 @@ export function formatSize(size: number | null | undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (size === 0) {
|
||||
return "0 B";
|
||||
size = Math.max(Math.round(size / 1024), 1);
|
||||
|
||||
if (size < 1024) {
|
||||
return `${size} KiB`;
|
||||
}
|
||||
return `${Math.round(size / 102.4) / 10} MiB`;
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(size) / Math.log(k));
|
||||
|
||||
return `${Math.round((size / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function toObject<T, R>(array: T[], fn: (arg0: T) => [key: string, value: R]) {
|
||||
|
||||
@@ -1259,12 +1259,6 @@ body.layout-horizontal #rest-pane > .classic-toolbar-widget {
|
||||
#center-pane .note-split {
|
||||
padding-top: 2px;
|
||||
background-color: var(--note-split-background-color, var(--main-background-color));
|
||||
transition: border-color 250ms ease-in;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-color: var(--link-selection-outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
body:not(.background-effects) #center-pane .note-split {
|
||||
|
||||
@@ -2234,13 +2234,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/client/src/types.d.ts
vendored
@@ -36,6 +36,7 @@ interface CustomGlobals {
|
||||
isProtectedSessionAvailable: boolean;
|
||||
isDev: boolean;
|
||||
isMainWindow: boolean;
|
||||
windowId: string;
|
||||
maxEntityChangeIdAtLoad: number;
|
||||
maxEntityChangeSyncIdAtLoad: number;
|
||||
assetPath: string;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import Component from "../../components/component.js";
|
||||
import NoteContext from "../../components/note_context.js";
|
||||
import splitService from "../../services/resizer.js";
|
||||
import { isMobile } from "../../services/utils.js";
|
||||
import type BasicWidget from "../basic_widget.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import FlexContainer from "./flex_container.js";
|
||||
import NoteContext from "../../components/note_context.js";
|
||||
|
||||
interface SplitNoteWidget extends BasicWidget {
|
||||
hasBeenAlreadyShown?: boolean;
|
||||
@@ -75,7 +74,7 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
|
||||
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
let noteContext: NoteContext | undefined;
|
||||
let noteContext: NoteContext | undefined = undefined;
|
||||
if (isMobile() && subContexts.length > 1) {
|
||||
noteContext = subContexts.find(s => s.ntxId !== ntxId);
|
||||
}
|
||||
@@ -202,11 +201,6 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
|
||||
async refresh() {
|
||||
this.toggleExt(true);
|
||||
|
||||
// Mark the active note context.
|
||||
for (const child of this.children as NoteContextAwareWidget[]) {
|
||||
child.$widget.toggleClass("active", !!child.noteContext?.isActive());
|
||||
}
|
||||
}
|
||||
|
||||
toggleInt(show: boolean) {} // not needed
|
||||
@@ -245,16 +239,16 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
|
||||
widget.hasBeenAlreadyShown = true;
|
||||
|
||||
return [widget.handleEvent("noteSwitched", noteSwitchedContext), this.refreshNotShown(noteSwitchedContext)];
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve();
|
||||
|
||||
}
|
||||
|
||||
if (name === "activeContextChanged") {
|
||||
return this.refreshNotShown(data as EventData<"activeContextChanged">);
|
||||
} else {
|
||||
return super.handleEventInChildren(name, data);
|
||||
}
|
||||
return super.handleEventInChildren(name, data);
|
||||
|
||||
}
|
||||
|
||||
refreshNotShown(data: NoteSwitchedContext | EventData<"activeContextChanged">) {
|
||||
|
||||
@@ -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, { NoteContextDataMap } from "../../components/note_context";
|
||||
import NoteContext from "../../components/note_context";
|
||||
import FBlob from "../../entities/fblob";
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
@@ -1192,113 +1192,3 @@ 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<K extends keyof NoteContextDataMap>(
|
||||
noteContext: NoteContext | null | undefined,
|
||||
key: K,
|
||||
value: NoteContextDataMap[K] | undefined
|
||||
) {
|
||||
const valueRef = useRef<NoteContextDataMap[K] | undefined>(value);
|
||||
valueRef.current = value;
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteContext || valueRef.current === undefined) return;
|
||||
|
||||
noteContext.setContextData(key, valueRef.current);
|
||||
|
||||
return () => {
|
||||
noteContext.clearContextData(key);
|
||||
};
|
||||
}, [noteContext, key]);
|
||||
|
||||
// Update when value changes
|
||||
useEffect(() => {
|
||||
if (!noteContext || value === undefined) return;
|
||||
noteContext.setContextData(key, value);
|
||||
}, [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<Heading[]>("toc");
|
||||
* if (!headings) return <div>No headings available</div>;
|
||||
* return <ul>{headings.map(h => <li>{h.text}</li>)}</ul>;
|
||||
* }
|
||||
*/
|
||||
export function useGetContextData<K extends keyof NoteContextDataMap>(key: K): NoteContextDataMap[K] | undefined {
|
||||
const { noteContext } = useActiveNoteContext();
|
||||
const [data, setData] = useState<NoteContextDataMap[K] | undefined>(() =>
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<K extends keyof NoteContextDataMap>(
|
||||
noteContext: NoteContext | null | undefined,
|
||||
key: K
|
||||
): NoteContextDataMap[K] | undefined {
|
||||
const [data, setData] = useState<NoteContextDataMap[K] | undefined>(() =>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,6 @@ 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";
|
||||
|
||||
@@ -60,27 +57,13 @@ 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<string[]>("highlightsList");
|
||||
const isPdf = noteType === "file" && noteMime === "application/pdf";
|
||||
|
||||
if (!rightPaneVisible) return [];
|
||||
const definitions: RightPanelWidgetDefinition[] = [
|
||||
{
|
||||
el: <TableOfContents />,
|
||||
enabled: (noteType === "text" || noteType === "doc" || isPdf),
|
||||
},
|
||||
{
|
||||
el: <PdfPages />,
|
||||
enabled: isPdf,
|
||||
},
|
||||
{
|
||||
el: <PdfAttachments />,
|
||||
enabled: isPdf,
|
||||
},
|
||||
{
|
||||
el: <PdfLayers />,
|
||||
enabled: isPdf,
|
||||
enabled: (noteType === "text" || noteType === "doc"),
|
||||
},
|
||||
{
|
||||
el: <HighlightsList />,
|
||||
|
||||
@@ -29,11 +29,6 @@
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.toc li.active > .item-content {
|
||||
font-weight: bold;
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.toc > ol {
|
||||
--toc-depth-level: 1;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import { randomString } from "../../services/utils";
|
||||
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
|
||||
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import RightPanelWidget from "./RightPanelWidget";
|
||||
|
||||
@@ -21,50 +21,29 @@ 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 (
|
||||
<RightPanelWidget id="toc" title={t("toc.table_of_contents")} grow>
|
||||
{((noteType === "text" && isReadOnly) || (noteType === "doc")) && <ReadOnlyTextTableOfContents />}
|
||||
{noteType === "text" && !isReadOnly && <EditableTextTableOfContents />}
|
||||
{noteType === "file" && noteMime === "application/pdf" && <PdfTableOfContents />}
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfTableOfContents() {
|
||||
const data = useGetContextData("toc");
|
||||
|
||||
return (
|
||||
<AbstractTableOfContents
|
||||
headings={data?.headings || []}
|
||||
scrollToHeading={data?.scrollToHeading || (() => {})}
|
||||
activeHeadingId={data?.activeHeadingId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading, activeHeadingId }: {
|
||||
function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading }: {
|
||||
headings: T[];
|
||||
scrollToHeading(heading: T): void;
|
||||
activeHeadingId?: string | null;
|
||||
}) {
|
||||
const nestedHeadings = buildHeadingTree(headings);
|
||||
return (
|
||||
<span className="toc">
|
||||
{nestedHeadings.length > 0 ? (
|
||||
<ol>
|
||||
{nestedHeadings.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} activeHeadingId={activeHeadingId} />)}
|
||||
{nestedHeadings.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} />)}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="no-headings">{t("toc.no_headings")}</div>
|
||||
@@ -73,16 +52,14 @@ function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeadi
|
||||
);
|
||||
}
|
||||
|
||||
function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: {
|
||||
function TableOfContentsHeading({ heading, scrollToHeading }: {
|
||||
heading: HeadingsWithNesting;
|
||||
scrollToHeading(heading: RawHeading): void;
|
||||
activeHeadingId?: string | null;
|
||||
}) {
|
||||
const [ collapsed, setCollapsed ] = useState(false);
|
||||
const isActive = heading.id === activeHeadingId;
|
||||
return (
|
||||
<>
|
||||
<li className={clsx(collapsed && "collapsed", isActive && "active")}>
|
||||
<li className={clsx(collapsed && "collapsed")}>
|
||||
{heading.children.length > 0 && (
|
||||
<Icon
|
||||
className="collapse-button"
|
||||
@@ -97,7 +74,7 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: {
|
||||
</li>
|
||||
{heading.children && (
|
||||
<ol>
|
||||
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} activeHeadingId={activeHeadingId} />)}
|
||||
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} />)}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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 (
|
||||
<RightPanelWidget id="pdf-attachments" title={t("pdf.attachments", { count: attachmentsData.attachments.length })}>
|
||||
<div className="pdf-attachments-list">
|
||||
{attachmentsData.attachments.map((attachment) => (
|
||||
<PdfAttachmentItem
|
||||
key={attachment.filename}
|
||||
attachment={attachment}
|
||||
onDownload={attachmentsData.downloadAttachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfAttachmentItem({
|
||||
attachment,
|
||||
onDownload
|
||||
}: {
|
||||
attachment: AttachmentInfo;
|
||||
onDownload: (filename: string) => void;
|
||||
}) {
|
||||
const sizeText = formatSize(attachment.size);
|
||||
|
||||
return (
|
||||
<div className="pdf-attachment-item" onClick={() => onDownload(attachment.filename)}>
|
||||
<Icon icon="bx bx-paperclip" />
|
||||
<div className="pdf-attachment-info">
|
||||
<div className="pdf-attachment-filename">{attachment.filename}</div>
|
||||
<div className="pdf-attachment-size">{sizeText}</div>
|
||||
</div>
|
||||
<Icon icon="bx bx-download" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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 &&
|
||||
<RightPanelWidget id="pdf-layers" title={t("pdf.layers", { count: layersData.layers.length })}>
|
||||
<div className="pdf-layers-list">
|
||||
{layersData.layers.map((layer) => (
|
||||
<PdfLayerItem
|
||||
key={layer.id}
|
||||
layer={layer}
|
||||
onToggle={layersData.toggleLayer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfLayerItem({
|
||||
layer,
|
||||
onToggle
|
||||
}: {
|
||||
layer: LayerInfo;
|
||||
onToggle: (layerId: string, visible: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`pdf-layer-item ${layer.visible ? 'visible' : 'hidden'}`}
|
||||
onClick={() => onToggle(layer.id, !layer.visible)}
|
||||
>
|
||||
<Icon icon={layer.visible ? "bx bx-show" : "bx bx-hide"} />
|
||||
<div className="pdf-layer-name">{layer.name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.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%;
|
||||
aspect-ratio: 8.5 / 11; /* Standard page ratio */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--accented-background-color);
|
||||
border: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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 &&
|
||||
<RightPanelWidget id="pdf-pages" title={t("pdf.pages", { count: pagesData?.totalPages || 0 })} grow>
|
||||
<PdfPagesList key={note?.noteId} pagesData={pagesData} />
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfPagesList({ pagesData }: { pagesData: NoteContextDataMap["pdfPages"] }) {
|
||||
const [thumbnails, setThumbnails] = useState<Map<number, string>>(new Map());
|
||||
const requestedThumbnails = useRef<Set<number>>(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 <div className="no-pages">No pages available</div>;
|
||||
}
|
||||
|
||||
const pages = Array.from({ length: pagesData.totalPages }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className="pdf-pages-list">
|
||||
{pages.map(pageNumber => (
|
||||
<PdfPageItem
|
||||
key={pageNumber}
|
||||
pageNumber={pageNumber}
|
||||
isActive={pageNumber === pagesData.currentPage}
|
||||
thumbnail={thumbnails.get(pageNumber)}
|
||||
onRequestThumbnail={requestThumbnail}
|
||||
onPageClick={() => pagesData.scrollToPage(pageNumber)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`pdf-page-item ${isActive ? 'active' : ''}`}
|
||||
onClick={onPageClick}
|
||||
>
|
||||
<div className="pdf-page-number">{pageNumber}</div>
|
||||
<div className="pdf-page-thumbnail">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt={`Page ${pageNumber}`} />
|
||||
) : (
|
||||
<div className="pdf-page-loading">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,13 @@ import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
const TEXT_MAX_NUM_CHARS = 5000;
|
||||
|
||||
export default function FileTypeWidget({ note, parentComponent, noteContext }: TypeWidgetProps) {
|
||||
export default function FileTypeWidget({ note, parentComponent }: TypeWidgetProps) {
|
||||
const blob = useNoteBlob(note, parentComponent?.componentId);
|
||||
|
||||
if (blob?.content) {
|
||||
return <TextPreview content={blob.content} />;
|
||||
} else if (note.mime === "application/pdf") {
|
||||
return noteContext && <PdfPreview blob={blob} note={note} componentId={parentComponent?.componentId} noteContext={noteContext} />;
|
||||
return <PdfPreview blob={blob} note={note} componentId={parentComponent?.componentId} />;
|
||||
} else if (note.mime.startsWith("video/")) {
|
||||
return <VideoPreview note={note} />;
|
||||
} else if (note.mime.startsWith("audio/")) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { useTriliumOption } from "../../react/hooks";
|
||||
|
||||
const VARIABLE_WHITELIST = new Set([
|
||||
"root-background",
|
||||
@@ -16,17 +14,15 @@ const VARIABLE_WHITELIST = new Set([
|
||||
"main-text-color"
|
||||
]);
|
||||
|
||||
export default function PdfPreview({ note, blob, componentId, noteContext }: {
|
||||
note: FNote;
|
||||
noteContext: NoteContext;
|
||||
blob: FBlob | null | undefined;
|
||||
export default function PdfPreview({ note, blob, componentId }: {
|
||||
note: FNote,
|
||||
blob: FBlob | null | undefined,
|
||||
componentId: string | undefined;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const { onLoad } = useStyleInjection(iframeRef);
|
||||
const historyConfig = useViewModeConfig(note, "pdfHistory");
|
||||
const [ locale ] = useTriliumOption("locale");
|
||||
const [ newLayout ] = useTriliumOptionBool("newLayout");
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
@@ -38,111 +34,13 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
|
||||
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
|
||||
}, "*");
|
||||
}
|
||||
});
|
||||
} 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
|
||||
}, "*");
|
||||
},
|
||||
requestThumbnail: (page: number) => {
|
||||
iframeRef.current?.contentWindow?.postMessage({
|
||||
type: "trilium-request-thumbnail",
|
||||
pageNumber: page
|
||||
}, "*");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
}, "*");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [ note, historyConfig, componentId, blob, noteContext ]);
|
||||
}, [ note, historyConfig, componentId, blob ]);
|
||||
|
||||
// Refresh when blob changes.
|
||||
useEffect(() => {
|
||||
@@ -151,31 +49,11 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
|
||||
}
|
||||
}, [ blob ]);
|
||||
|
||||
// Trigger focus when iframe content is clicked (iframe focus doesn't bubble)
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
|
||||
const handleIframeClick = () => {
|
||||
if (noteContext.ntxId) {
|
||||
appContext.tabManager.activateNoteContext(noteContext.ntxId);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for clicks on the iframe's content window
|
||||
const iframeDoc = iframe.contentWindow?.document;
|
||||
if (iframeDoc) {
|
||||
iframeDoc.addEventListener('click', handleIframeClick);
|
||||
return () => iframeDoc.removeEventListener('click', handleIframeClick);
|
||||
}
|
||||
}, [ iframeRef.current?.contentWindow, noteContext ]);
|
||||
|
||||
return (historyConfig &&
|
||||
<iframe
|
||||
tabIndex={300}
|
||||
ref={iframeRef}
|
||||
class="pdf-preview"
|
||||
src={`pdfjs/web/viewer.html?file=../../api/notes/${note.noteId}/open&lang=${locale}&sidebar=${newLayout ? "0" : "1"}`}
|
||||
src={`pdfjs/web/viewer.html?file=../../api/notes/${note.noteId}/open&lang=${locale}`}
|
||||
onLoad={() => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (win) {
|
||||
@@ -238,40 +116,3 @@ function cssVarsToString(vars: Record<string, string>) {
|
||||
.map(([k, v]) => ` ${k}: ${v};`)
|
||||
.join('\n')}\n}`;
|
||||
}
|
||||
|
||||
interface PdfOutlineItem {
|
||||
title: string;
|
||||
level: number;
|
||||
dest: unknown;
|
||||
id: string;
|
||||
items: PdfOutlineItem[];
|
||||
}
|
||||
|
||||
interface PdfHeading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
element: null;
|
||||
}
|
||||
|
||||
function convertPdfOutlineToHeadings(outline: PdfOutlineItem[]): PdfHeading[] {
|
||||
const headings: PdfHeading[] = [];
|
||||
|
||||
function flatten(items: PdfOutlineItem[]) {
|
||||
for (const item of items) {
|
||||
headings.push({
|
||||
level: item.level + 1,
|
||||
text: item.title,
|
||||
id: item.id,
|
||||
element: null // PDFs don't have DOM elements
|
||||
});
|
||||
|
||||
if (item.items && item.items.length > 0) {
|
||||
flatten(item.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(outline);
|
||||
return headings;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 545 B |
|
After Width: | Height: | Size: 727 B |
|
After Width: | Height: | Size: 828 B |
|
After Width: | Height: | Size: 931 B |
BIN
apps/desktop/src/assets/images/tray/closed-windowsTemplate.png
Normal file
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 355 B |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 492 B |
@@ -6,6 +6,8 @@ import sqlInit from "@triliumnext/server/src/services/sql_init.js";
|
||||
import windowService from "@triliumnext/server/src/services/window.js";
|
||||
import tray from "@triliumnext/server/src/services/tray.js";
|
||||
import options from "@triliumnext/server/src/services/options.js";
|
||||
import { randomString } from "@triliumnext/server/src/services/utils.js";
|
||||
|
||||
import electronDebug from "electron-debug";
|
||||
import electronDl from "electron-dl";
|
||||
import { PRODUCT_NAME } from "./app-info";
|
||||
@@ -72,7 +74,8 @@ async function main() {
|
||||
app.on("second-instance", (event, commandLine) => {
|
||||
const lastFocusedWindow = windowService.getLastFocusedWindow();
|
||||
if (commandLine.includes("--new-window")) {
|
||||
windowService.createExtraWindow("");
|
||||
const extraWindowId = randomString(4);
|
||||
windowService.createExtraWindow(extraWindowId, "");
|
||||
} else if (lastFocusedWindow) {
|
||||
if (lastFocusedWindow.isMinimized()) {
|
||||
lastFocusedWindow.restore();
|
||||
|
||||
@@ -382,6 +382,8 @@
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Quit Trilium",
|
||||
"recents": "Recent notes",
|
||||
"recently-closed-windows": "Recently closed windows",
|
||||
"tabs-total": "total {{number}} tabs",
|
||||
"bookmarks": "Bookmarks",
|
||||
"today": "Open today's journal note",
|
||||
"new-note": "New note",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</head>
|
||||
<body
|
||||
id="trilium-app"
|
||||
class="desktop heading-style-<%= headingStyle %> layout-<%= layoutOrientation %> platform-<%= platform %> <%= isElectron ? 'electron' : '' %> <%= hasNativeTitleBar ? 'native-titlebar' : '' %> <%= hasBackgroundEffects ? 'background-effects' : '' %>"
|
||||
class="desktop heading-style-<%= headingStyle %> layout-<%= layoutOrientation %> platform-<%= platform %> <%= isElectron ? 'electron' : '' %> <%= hasNativeTitleBar ? 'native-titlebar' : '' %> <%= hasBackgroundEffects ? 'background-effects' : '' %> <%= isMainWindow ? '' : 'extra-window' %>"
|
||||
lang="<%= currentLocale.id %>" dir="<%= currentLocale.rtl ? 'rtl' : 'ltr' %>"
|
||||
>
|
||||
<noscript><%= t("javascript-required") %></noscript>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
isDev: <%= isDev %>,
|
||||
appCssNoteIds: <%- JSON.stringify(appCssNoteIds) %>,
|
||||
isMainWindow: <%= isMainWindow %>,
|
||||
windowId: "<%= windowId %>",
|
||||
isProtectedSessionAvailable: <%= isProtectedSessionAvailable %>,
|
||||
triliumVersion: "<%= triliumVersion %>",
|
||||
assetPath: "<%= assetPath %>",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import cls from "../services/cls.js";
|
||||
import sql from "../services/sql.js";
|
||||
|
||||
export default () => {
|
||||
cls.init(() => {
|
||||
const row = sql.getRow<{ value: string }>(
|
||||
`SELECT value FROM options WHERE name = 'openNoteContexts'`
|
||||
);
|
||||
|
||||
if (!row || !row.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(row.value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already in new format (array + windowId), skip
|
||||
if (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.length > 0 &&
|
||||
parsed[0] &&
|
||||
typeof parsed[0] === "object" &&
|
||||
parsed[0].windowId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Old format: just contexts
|
||||
const migrated = [
|
||||
{
|
||||
windowId: "main",
|
||||
createdAt: 0,
|
||||
closedAt: null,
|
||||
contexts: parsed
|
||||
}
|
||||
];
|
||||
|
||||
sql.execute(
|
||||
`UPDATE options SET value = ? WHERE name = 'openNoteContexts'`,
|
||||
[JSON.stringify(migrated)]
|
||||
);
|
||||
|
||||
});
|
||||
};
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
// Migrations should be kept in descending order, so the latest migration is first.
|
||||
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
|
||||
// Migrate openNoteContexts option to the new structured format with window metadata
|
||||
{
|
||||
version: 234,
|
||||
module: async () => import("./0234__migrate_open_note_contexts_format")
|
||||
},
|
||||
// Migrate geo map to collection
|
||||
{
|
||||
version: 233,
|
||||
|
||||
@@ -56,6 +56,7 @@ function index(req: Request, res: Response) {
|
||||
appCssNoteIds: getAppCssNoteIds(),
|
||||
isDev,
|
||||
isMainWindow: view === "mobile" ? true : !req.query.extraWindow,
|
||||
windowId: view !== "mobile" && req.query.extraWindow ? req.query.extraWindow : "main",
|
||||
isProtectedSessionAvailable: protectedSessionService.isProtectedSessionAvailable(),
|
||||
triliumVersion: packageJson.version,
|
||||
assetPath,
|
||||
|
||||
@@ -4,7 +4,7 @@ import packageJson from "../../package.json" with { type: "json" };
|
||||
import dataDir from "./data_dir.js";
|
||||
import { AppInfo } from "@triliumnext/commons";
|
||||
|
||||
const APP_DB_VERSION = 233;
|
||||
const APP_DB_VERSION = 234;
|
||||
const SYNC_VERSION = 36;
|
||||
const CLIPPER_PROTOCOL_VERSION = "1.0";
|
||||
|
||||
|
||||
@@ -72,6 +72,19 @@ function getOptionBool(name: FilterOptionsByType<boolean>): boolean {
|
||||
return val === "true";
|
||||
}
|
||||
|
||||
function getOptionJson(name: OptionNames) {
|
||||
const val = getOptionOrNull(name);
|
||||
|
||||
if (typeof val !== "string") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setOption<T extends OptionNames>(name: T, value: string | OptionDefinitions[T]) {
|
||||
const option = becca.getOption(name);
|
||||
|
||||
@@ -137,6 +150,7 @@ export default {
|
||||
getOption,
|
||||
getOptionInt,
|
||||
getOptionBool,
|
||||
getOptionJson,
|
||||
setOption,
|
||||
createOption,
|
||||
getOptions,
|
||||
|
||||
@@ -45,8 +45,15 @@ async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts =
|
||||
"openNoteContexts",
|
||||
JSON.stringify([
|
||||
{
|
||||
notePath: "root",
|
||||
active: true
|
||||
windowId: "main",
|
||||
createdAt: 0,
|
||||
closedAt: null,
|
||||
contexts: [
|
||||
{
|
||||
notePath: "root",
|
||||
active: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]),
|
||||
false
|
||||
@@ -257,8 +264,15 @@ function initStartupOptions() {
|
||||
"openNoteContexts",
|
||||
JSON.stringify([
|
||||
{
|
||||
notePath: process.env.TRILIUM_START_NOTE_ID || "root",
|
||||
active: true
|
||||
windowId: "main",
|
||||
createdAt: 0,
|
||||
closedAt: null,
|
||||
contexts: [
|
||||
{
|
||||
notePath: process.env.TRILIUM_START_NOTE_ID || "root",
|
||||
active: true
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
@@ -147,8 +147,15 @@ async function createInitialDatabase(skipDemoDb?: boolean) {
|
||||
"openNoteContexts",
|
||||
JSON.stringify([
|
||||
{
|
||||
notePath: startNoteId,
|
||||
active: true
|
||||
windowId: "main",
|
||||
createdAt: 0,
|
||||
closedAt: null,
|
||||
contexts: [
|
||||
{
|
||||
notePath: startNoteId,
|
||||
active: true
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
@@ -196,6 +196,44 @@ function updateTrayMenu() {
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
function buildClosedWindowsMenu() {
|
||||
const savedWindows = optionService.getOptionJson("openNoteContexts") || [];
|
||||
const openedWindowIds = windowService.getAllWindowIds();
|
||||
const closedWindows = savedWindows
|
||||
.filter(win => !openedWindowIds.includes(win.windowId))
|
||||
.sort((a, b) => {
|
||||
// If closedAt is null, it indicates an abnormal closure and should be placed at the end
|
||||
if (a.closedAt === null && b.closedAt === null) return 0;
|
||||
if (a.closedAt === null) return 1;
|
||||
if (b.closedAt === null) return -1;
|
||||
// Otherwise, sort by time in descending order
|
||||
return b.closedAt - a.closedAt;
|
||||
});
|
||||
const menuItems: Electron.MenuItemConstructorOptions[] = [];
|
||||
for (const win of closedWindows) {
|
||||
const activeCtx = win.contexts.find(c => c.active === true);
|
||||
const activateNotePath = (activeCtx ?? win.contexts[0])?.notePath;
|
||||
const activateNoteId = activateNotePath?.split("/").pop() ?? null;
|
||||
|
||||
// Get the title of the closed window
|
||||
const rawTitle = activateNoteId ? becca_service.getNoteTitle(activateNoteId) : "";
|
||||
let winTitle = rawTitle.length > 20 ? `${rawTitle.slice(0, 17)}...` : rawTitle;
|
||||
const mainTabCount = win.contexts.filter(ctx => ctx.mainNtxId === null).length;
|
||||
if (mainTabCount > 1) {
|
||||
const tabSuffix = t("tray.tabs-total", { number: mainTabCount });
|
||||
winTitle += ` (${tabSuffix})`;
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: winTitle,
|
||||
type: "normal",
|
||||
click: () => win.windowId !== "main" ? windowService.createExtraWindow(win.windowId, "") : windowService.createMainWindow()
|
||||
});
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
const windowVisibilityMenuItems: Electron.MenuItemConstructorOptions[] = [];
|
||||
|
||||
// Only call getWindowTitle if windowVisibilityMap has more than one window
|
||||
@@ -258,6 +296,12 @@ function updateTrayMenu() {
|
||||
icon: getIconPath("recents"),
|
||||
submenu: buildRecentNotesMenu()
|
||||
},
|
||||
{
|
||||
label: t("tray.recently-closed-windows"),
|
||||
type: "submenu",
|
||||
icon: getIconPath("closed-windows"),
|
||||
submenu: buildClosedWindowsMenu()
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("tray.close"),
|
||||
|
||||
@@ -16,28 +16,45 @@ import { formatDownloadTitle, isMac, isWindows } from "./utils.js";
|
||||
// Prevent the window being garbage collected
|
||||
let mainWindow: BrowserWindow | null;
|
||||
let setupWindow: BrowserWindow | null;
|
||||
let allWindows: BrowserWindow[] = []; // // Used to store all windows, sorted by the order of focus.
|
||||
|
||||
function trackWindowFocus(win: BrowserWindow) {
|
||||
interface WindowEntry {
|
||||
window: BrowserWindow;
|
||||
windowId: string; // custom window ID
|
||||
}
|
||||
let allWindowEntries: WindowEntry[] = [];
|
||||
|
||||
function trackWindowFocus(win: BrowserWindow, windowId: string) {
|
||||
// We need to get the last focused window from allWindows. If the last window is closed, we return the previous window.
|
||||
// Therefore, we need to push the window into the allWindows array every time it gets focused.
|
||||
win.on("focus", () => {
|
||||
allWindows = allWindows.filter(w => !w.isDestroyed() && w !== win);
|
||||
allWindows.push(win);
|
||||
allWindowEntries = allWindowEntries.filter(w => !w.window.isDestroyed() && w.window !== win);
|
||||
allWindowEntries.push({ window: win, windowId: windowId });
|
||||
|
||||
if (!optionService.getOptionBool("disableTray")) {
|
||||
electron.ipcMain.emit("reload-tray");
|
||||
}
|
||||
});
|
||||
|
||||
win.on("closed", () => {
|
||||
allWindows = allWindows.filter(w => !w.isDestroyed());
|
||||
cls.wrap(() => {
|
||||
const savedWindows = optionService.getOptionJson("openNoteContexts") || [];
|
||||
|
||||
const win = savedWindows.find(w => w.windowId === windowId);
|
||||
if (win) {
|
||||
win.closedAt = Date.now();
|
||||
}
|
||||
|
||||
optionService.setOption("openNoteContexts", JSON.stringify(savedWindows));
|
||||
})();
|
||||
|
||||
allWindowEntries = allWindowEntries.filter(w => !w.window.isDestroyed());
|
||||
if (!optionService.getOptionBool("disableTray")) {
|
||||
electron.ipcMain.emit("reload-tray");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createExtraWindow(extraWindowHash: string) {
|
||||
async function createExtraWindow(extraWindowId: string, extraWindowHash: string) {
|
||||
const spellcheckEnabled = optionService.getOptionBool("spellCheckEnabled");
|
||||
|
||||
const { BrowserWindow } = await import("electron");
|
||||
@@ -56,15 +73,15 @@ async function createExtraWindow(extraWindowHash: string) {
|
||||
});
|
||||
|
||||
win.setMenuBarVisibility(false);
|
||||
win.loadURL(`http://127.0.0.1:${port}/?extraWindow=1${extraWindowHash}`);
|
||||
win.loadURL(`http://127.0.0.1:${port}/?extraWindow=${extraWindowId}${extraWindowHash}`);
|
||||
|
||||
configureWebContents(win.webContents, spellcheckEnabled);
|
||||
|
||||
trackWindowFocus(win);
|
||||
trackWindowFocus(win, extraWindowId);
|
||||
}
|
||||
|
||||
electron.ipcMain.on("create-extra-window", (event, arg) => {
|
||||
createExtraWindow(arg.extraWindowHash);
|
||||
createExtraWindow(arg.extraWindowId, arg.extraWindowHash);
|
||||
});
|
||||
|
||||
interface PrintOpts {
|
||||
@@ -168,8 +185,8 @@ async function getBrowserWindowForPrinting(e: IpcMainEvent, notePath: string, ac
|
||||
return { browserWindow, printReport };
|
||||
}
|
||||
|
||||
async function createMainWindow(app: App) {
|
||||
if ("setUserTasks" in app) {
|
||||
async function createMainWindow(app?: App) {
|
||||
if (app && "setUserTasks" in app) {
|
||||
app.setUserTasks([
|
||||
{
|
||||
program: process.execPath,
|
||||
@@ -219,7 +236,7 @@ async function createMainWindow(app: App) {
|
||||
mainWindow.on("closed", () => (mainWindow = null));
|
||||
|
||||
configureWebContents(mainWindow.webContents, spellcheckEnabled);
|
||||
trackWindowFocus(mainWindow);
|
||||
trackWindowFocus(mainWindow, "main");
|
||||
}
|
||||
|
||||
function getWindowExtraOpts() {
|
||||
@@ -381,11 +398,15 @@ function getMainWindow() {
|
||||
}
|
||||
|
||||
function getLastFocusedWindow() {
|
||||
return allWindows.length > 0 ? allWindows[allWindows.length - 1] : null;
|
||||
return allWindowEntries.length > 0 ? allWindowEntries[allWindowEntries.length - 1]?.window : null;
|
||||
}
|
||||
|
||||
function getAllWindows() {
|
||||
return allWindows;
|
||||
return allWindowEntries.map(e => e.window);
|
||||
}
|
||||
|
||||
function getAllWindowIds(): string[] {
|
||||
return allWindowEntries.map(e => e.windowId);
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -396,5 +417,6 @@ export default {
|
||||
registerGlobalShortcuts,
|
||||
getMainWindow,
|
||||
getLastFocusedWindow,
|
||||
getAllWindows
|
||||
getAllWindows,
|
||||
getAllWindowIds
|
||||
};
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
export async function setupPdfAttachments() {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
// Extract immediately since we're called after documentloaded
|
||||
await extractAndSendAttachments();
|
||||
|
||||
// Listen for download requests
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.data?.type === "trilium-download-attachment") {
|
||||
const filename = event.data.filename;
|
||||
await downloadAttachment(filename);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function extractAndSendAttachments() {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
try {
|
||||
const attachments = await app.pdfDocument.getAttachments();
|
||||
console.log("Got attachments:", attachments);
|
||||
|
||||
if (!attachments) {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-attachments",
|
||||
attachments: []
|
||||
}, "*");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert attachments object to array
|
||||
const attachmentList = Object.entries(attachments).map(([filename, data]: [string, any]) => ({
|
||||
filename,
|
||||
content: data.content, // Uint8Array
|
||||
size: data.content?.length || 0
|
||||
}));
|
||||
|
||||
// Send metadata only (not the full content)
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-attachments",
|
||||
attachments: attachmentList.map(att => ({
|
||||
filename: att.filename,
|
||||
size: att.size
|
||||
}))
|
||||
}, "*");
|
||||
} catch (error) {
|
||||
console.error("Error extracting attachments:", error);
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-attachments",
|
||||
attachments: []
|
||||
}, "*");
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAttachment(filename: string) {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
try {
|
||||
const attachments = await app.pdfDocument.getAttachments();
|
||||
const attachment = attachments?.[filename];
|
||||
|
||||
if (!attachment) {
|
||||
console.error("Attachment not found:", filename);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([attachment.content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,3 @@
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
#toolbarViewerLeft > .toolbarButtonSpacer:first-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import interceptPersistence from "./persistence";
|
||||
import { extractAndSendToc, setupScrollToHeading, setupActiveHeadingTracking } from "./toc";
|
||||
import { setupPdfPages } from "./pages";
|
||||
import { setupPdfAttachments } from "./attachments";
|
||||
import { setupPdfLayers } from "./layers";
|
||||
|
||||
async function main() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("sidebar") === "0") {
|
||||
hideSidebar();
|
||||
}
|
||||
|
||||
interceptPersistence(getCustomAppOptions(urlParams));
|
||||
interceptPersistence(getCustomAppOptions());
|
||||
|
||||
// Wait for the PDF viewer application to be available.
|
||||
while (!window.PDFViewerApplication) {
|
||||
@@ -20,37 +11,18 @@ async function main() {
|
||||
|
||||
app.eventBus.on("documentloaded", () => {
|
||||
manageSave();
|
||||
extractAndSendToc();
|
||||
setupScrollToHeading();
|
||||
setupActiveHeadingTracking();
|
||||
setupPdfPages();
|
||||
setupPdfAttachments();
|
||||
setupPdfLayers();
|
||||
});
|
||||
await app.initializedPromise;
|
||||
};
|
||||
|
||||
function hideSidebar() {
|
||||
window.TRILIUM_HIDE_SIDEBAR = true;
|
||||
const toggleButtonEl = document.getElementById("viewsManagerToggleButton");
|
||||
if (toggleButtonEl) {
|
||||
const spacer = toggleButtonEl.nextElementSibling.nextElementSibling;
|
||||
if (spacer.classList.contains("toolbarButtonSpacer")) {
|
||||
spacer.remove();
|
||||
}
|
||||
toggleButtonEl.remove();
|
||||
}
|
||||
}
|
||||
function getCustomAppOptions() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
function getCustomAppOptions(urlParams: URLSearchParams) {
|
||||
return {
|
||||
localeProperties: {
|
||||
// Read from URL query
|
||||
lang: urlParams.get("lang") || "en"
|
||||
},
|
||||
// Control sidebar visibility via query parameter
|
||||
// sidebarViewOnLoad: -1 disables sidebar, 0 = NONE (default)
|
||||
viewsManager: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
export async function setupPdfLayers() {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
// Extract immediately since we're called after documentloaded
|
||||
await extractAndSendLayers();
|
||||
|
||||
// Listen for layer visibility toggle requests
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.data?.type === "trilium-toggle-layer") {
|
||||
const layerId = event.data.layerId;
|
||||
const visible = event.data.visible;
|
||||
await toggleLayer(layerId, visible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function extractAndSendLayers() {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
try {
|
||||
// Get the config from the viewer if available (has updated state), otherwise from document
|
||||
const pdfViewer = app.pdfViewer;
|
||||
const optionalContentConfig = pdfViewer?.optionalContentConfigPromise
|
||||
? await pdfViewer.optionalContentConfigPromise
|
||||
: await app.pdfDocument.getOptionalContentConfig();
|
||||
|
||||
if (!optionalContentConfig) {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-layers",
|
||||
layers: []
|
||||
}, "*");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all layer group IDs from the order
|
||||
const order = optionalContentConfig.getOrder();
|
||||
if (!order || order.length === 0) {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-layers",
|
||||
layers: []
|
||||
}, "*");
|
||||
return;
|
||||
}
|
||||
|
||||
// Flatten the order array (it can be nested) and extract group IDs
|
||||
const groupIds: string[] = [];
|
||||
const flattenOrder = (items: any[]) => {
|
||||
for (const item of items) {
|
||||
if (typeof item === 'string') {
|
||||
groupIds.push(item);
|
||||
} else if (Array.isArray(item)) {
|
||||
flattenOrder(item);
|
||||
} else if (item && typeof item === 'object' && item.id) {
|
||||
groupIds.push(item.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
flattenOrder(order);
|
||||
|
||||
// Get group details for each ID and only include valid, toggleable layers
|
||||
const layers = groupIds.map(id => {
|
||||
const group = optionalContentConfig.getGroup(id);
|
||||
|
||||
// Only include groups that have a name and usage property (actual layers)
|
||||
if (!group || !group.name || !group.usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use group.visible property like PDF.js viewer does
|
||||
return {
|
||||
id,
|
||||
name: group.name,
|
||||
visible: group.visible
|
||||
};
|
||||
}).filter(layer => layer !== null); // Filter out invalid layers
|
||||
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-layers",
|
||||
layers
|
||||
}, "*");
|
||||
} catch (error) {
|
||||
console.error("Error extracting layers:", error);
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-layers",
|
||||
layers: []
|
||||
}, "*");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLayer(layerId: string, visible: boolean) {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
try {
|
||||
const pdfViewer = app.pdfViewer;
|
||||
if (!pdfViewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionalContentConfig = await pdfViewer.optionalContentConfigPromise;
|
||||
if (!optionalContentConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set visibility on the config (like PDF.js viewer does)
|
||||
optionalContentConfig.setVisibility(layerId, visible);
|
||||
|
||||
// Dispatch optionalcontentconfig event with the existing config
|
||||
app.eventBus.dispatch("optionalcontentconfig", {
|
||||
source: app,
|
||||
promise: Promise.resolve(optionalContentConfig)
|
||||
});
|
||||
|
||||
// Send updated layer state back
|
||||
await extractAndSendLayers();
|
||||
} catch (error) {
|
||||
console.error("Error toggling layer:", error);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
export function setupPdfPages() {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
// Send initial page info when pages are initialized
|
||||
app.eventBus.on("pagesinit", () => {
|
||||
sendPageInfo();
|
||||
});
|
||||
|
||||
// Also send immediately if document is already loaded
|
||||
if (app.pdfDocument && app.pdfViewer) {
|
||||
sendPageInfo();
|
||||
}
|
||||
|
||||
// Track current page changes
|
||||
app.eventBus.on("pagechanging", (evt: any) => {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-current-page",
|
||||
currentPage: evt.pageNumber
|
||||
}, "*");
|
||||
});
|
||||
|
||||
// Listen for scroll-to-page requests
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "trilium-scroll-to-page") {
|
||||
const pageNumber = event.data.pageNumber;
|
||||
app.pdfViewer.currentPageNumber = pageNumber;
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for thumbnail requests
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.data?.type === "trilium-request-thumbnail") {
|
||||
const pageNumber = event.data.pageNumber;
|
||||
await generateThumbnail(pageNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendPageInfo() {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-page-info",
|
||||
totalPages: app.pdfDocument.numPages,
|
||||
currentPage: app.pdfViewer.currentPageNumber
|
||||
}, "*");
|
||||
}
|
||||
|
||||
async function generateThumbnail(pageNumber: number) {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
try {
|
||||
const page = await app.pdfDocument.getPage(pageNumber);
|
||||
|
||||
// Create canvas for thumbnail
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
// Set thumbnail size (smaller than actual page)
|
||||
const viewport = page.getViewport({ scale: 0.2 });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
// Render page to canvas
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport: viewport
|
||||
}).promise;
|
||||
|
||||
// Convert to data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
||||
|
||||
// Send thumbnail to parent
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-thumbnail",
|
||||
pageNumber,
|
||||
dataUrl
|
||||
}, "*");
|
||||
} catch (error) {
|
||||
console.error(`Error generating thumbnail for page ${pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
let outlineMap: Map<string, any> | null = null;
|
||||
let headingPositions: Array<{ id: string; pageIndex: number; y: number }> | null = null;
|
||||
|
||||
export async function extractAndSendToc() {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
try {
|
||||
const outline = await app.pdfDocument.getOutline();
|
||||
|
||||
if (!outline || outline.length === 0) {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-toc",
|
||||
data: null
|
||||
}, "*");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store outline items with their destinations for later scrolling
|
||||
outlineMap = new Map();
|
||||
headingPositions = [];
|
||||
const toc = convertOutlineToToc(outline, 0, outlineMap);
|
||||
|
||||
// Build position mapping for active heading detection
|
||||
await buildPositionMapping(outlineMap);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-toc",
|
||||
data: toc
|
||||
}, "*");
|
||||
} catch (error) {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-toc",
|
||||
data: null
|
||||
}, "*");
|
||||
}
|
||||
}
|
||||
|
||||
function convertOutlineToToc(outline: any[], level = 0, outlineMap?: Map<string, any>, parentId = ""): any[] {
|
||||
return outline.map((item, index) => {
|
||||
const id = parentId ? `${parentId}-${index}` : `pdf-outline-${index}`;
|
||||
|
||||
if (outlineMap) {
|
||||
outlineMap.set(id, item);
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
level: level,
|
||||
dest: item.dest,
|
||||
id: id,
|
||||
items: item.items && item.items.length > 0 ? convertOutlineToToc(item.items, level + 1, outlineMap, id) : []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function setupScrollToHeading() {
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.data?.type === "trilium-scroll-to-heading") {
|
||||
const headingId = event.data.headingId;
|
||||
|
||||
if (!outlineMap) return;
|
||||
|
||||
const outlineItem = outlineMap.get(headingId);
|
||||
if (!outlineItem || !outlineItem.dest) return;
|
||||
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
// Navigate to the destination
|
||||
try {
|
||||
const dest = typeof outlineItem.dest === 'string'
|
||||
? await app.pdfDocument.getDestination(outlineItem.dest)
|
||||
: outlineItem.dest;
|
||||
|
||||
if (dest) {
|
||||
app.pdfLinkService.goToDestination(dest);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error navigating to heading:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function buildPositionMapping(outlineMap: Map<string, any>) {
|
||||
const app = window.PDFViewerApplication;
|
||||
|
||||
for (const [id, item] of outlineMap.entries()) {
|
||||
if (!item.dest) continue;
|
||||
|
||||
try {
|
||||
const dest = typeof item.dest === 'string'
|
||||
? await app.pdfDocument.getDestination(item.dest)
|
||||
: item.dest;
|
||||
|
||||
if (dest && dest[0]) {
|
||||
const pageRef = dest[0];
|
||||
const pageIndex = await app.pdfDocument.getPageIndex(pageRef);
|
||||
|
||||
// Extract Y coordinate from destination (dest[3] is typically the y-coordinate)
|
||||
const y = typeof dest[3] === 'number' ? dest[3] : 0;
|
||||
|
||||
headingPositions?.push({ id, pageIndex, y });
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip items with invalid destinations
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by page and then by Y position (descending, since PDF coords are bottom-up)
|
||||
headingPositions?.sort((a, b) => {
|
||||
if (a.pageIndex !== b.pageIndex) {
|
||||
return a.pageIndex - b.pageIndex;
|
||||
}
|
||||
return b.y - a.y; // Higher Y comes first (top of page)
|
||||
});
|
||||
}
|
||||
|
||||
export function setupActiveHeadingTracking() {
|
||||
const app = window.PDFViewerApplication;
|
||||
let lastActiveHeading: string | null = null;
|
||||
|
||||
// Offset from top of viewport to consider a heading "active"
|
||||
// This makes the heading active when it's near the top, not when fully scrolled past
|
||||
const ACTIVE_HEADING_OFFSET = 100;
|
||||
|
||||
function updateActiveHeading() {
|
||||
if (!headingPositions || headingPositions.length === 0) return;
|
||||
|
||||
const currentPage = app.page - 1; // PDF.js uses 1-based, we need 0-based
|
||||
const viewer = app.pdfViewer;
|
||||
const container = viewer.container;
|
||||
const scrollTop = container.scrollTop;
|
||||
|
||||
// Find the heading closest to the top of the viewport
|
||||
let activeHeadingId: string | null = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
for (const heading of headingPositions) {
|
||||
// Get the page view to calculate actual position
|
||||
const pageView = viewer.getPageView(heading.pageIndex);
|
||||
if (!pageView || !pageView.div) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageTop = pageView.div.offsetTop;
|
||||
const pageHeight = pageView.div.clientHeight;
|
||||
|
||||
// Convert PDF Y coordinate (bottom-up) to screen position (top-down)
|
||||
const headingScreenY = pageTop + (pageHeight - heading.y);
|
||||
|
||||
// Calculate distance from top of viewport
|
||||
const distance = Math.abs(headingScreenY - scrollTop);
|
||||
|
||||
// If this heading is closer to the top of viewport, and it's not too far below
|
||||
if (headingScreenY <= scrollTop + ACTIVE_HEADING_OFFSET && distance < bestDistance) {
|
||||
activeHeadingId = heading.id;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeHeadingId !== lastActiveHeading) {
|
||||
lastActiveHeading = activeHeadingId;
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-active-heading",
|
||||
headingId: activeHeadingId
|
||||
}, "*");
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced scroll handler
|
||||
let scrollTimeout: number | null = null;
|
||||
const debouncedUpdate = () => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
scrollTimeout = window.setTimeout(updateActiveHeading, 100);
|
||||
};
|
||||
|
||||
app.eventBus.on("pagechanging", debouncedUpdate);
|
||||
|
||||
// Also listen to scroll events for more granular updates within a page
|
||||
const container = app.pdfViewer.container;
|
||||
container.addEventListener("scroll", debouncedUpdate);
|
||||
|
||||
// Initial update
|
||||
updateActiveHeading();
|
||||
}
|
||||
@@ -18609,7 +18609,7 @@ function getViewerConfiguration() {
|
||||
imageAltTextSettingsSeparator: document.getElementById("imageAltTextSettingsSeparator"),
|
||||
documentPropertiesButton: document.getElementById("documentProperties")
|
||||
},
|
||||
viewsManager: window.TRILIUM_HIDE_SIDEBAR ? null : {
|
||||
viewsManager: {
|
||||
outerContainer: document.getElementById("outerContainer"),
|
||||
toggleButton: document.getElementById("viewsManagerToggleButton"),
|
||||
sidebarContainer: document.getElementById("viewsManager"),
|
||||
|
||||