mirror of
https://github.com/zadam/trilium.git
synced 2026-01-12 09:34:26 +01:00
Merge branch 'main' into feat/extra-window
This commit is contained in:
commit
8087ed5688
@ -6,7 +6,7 @@ import { ColumnComponent } from "tabulator-tables";
|
||||
|
||||
import type { Attribute } from "../services/attribute_parser.js";
|
||||
import froca from "../services/froca.js";
|
||||
import { initLocale,t } from "../services/i18n.js";
|
||||
import { initLocale, t } from "../services/i18n.js";
|
||||
import keyboardActionsService from "../services/keyboard_actions.js";
|
||||
import linkService, { type ViewScope } from "../services/link.js";
|
||||
import type LoadResults from "../services/load_results.js";
|
||||
@ -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: {
|
||||
|
||||
@ -57,6 +57,18 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a child component from this component's children array.
|
||||
* This is used for cleanup when a widget is unmounted to prevent event listener accumulation.
|
||||
*/
|
||||
removeChild(component: ChildT) {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
component.parent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null | undefined {
|
||||
try {
|
||||
const callMethodPromise = this.initialized ? this.initialized.then(() => this.callMethod((this as any)[`${name}Event`], data)) : this.callMethod((this as any)[`${name}Event`], data);
|
||||
|
||||
@ -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<string, unknown> = 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<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) {
|
||||
|
||||
@ -8,7 +8,7 @@ import search from "../services/search.js";
|
||||
import server from "../services/server.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type FAttachment from "./fattachment.js";
|
||||
import type { AttributeType,default as FAttribute } from "./fattribute.js";
|
||||
import type { AttributeType, default as FAttribute } from "./fattribute.js";
|
||||
|
||||
const LABEL = "label";
|
||||
const RELATION = "relation";
|
||||
|
||||
14
apps/client/src/services/css_class_manager.spec.ts
Normal file
14
apps/client/src/services/css_class_manager.spec.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getReadableTextColor } from "./css_class_manager";
|
||||
|
||||
describe("getReadableTextColor", () => {
|
||||
it("doesn't crash for invalid color", () => {
|
||||
expect(getReadableTextColor("RandomColor")).toBe("#000");
|
||||
});
|
||||
|
||||
it("tolerates different casing", () => {
|
||||
expect(getReadableTextColor("Blue"))
|
||||
.toBe(getReadableTextColor("blue"));
|
||||
});
|
||||
});
|
||||
@ -1,21 +1,22 @@
|
||||
import clsx from "clsx";
|
||||
import {readCssVar} from "../utils/css-var";
|
||||
import Color, { ColorInstance } from "color";
|
||||
|
||||
import {readCssVar} from "../utils/css-var";
|
||||
|
||||
const registeredClasses = new Set<string>();
|
||||
const colorsWithHue = new Set<string>();
|
||||
|
||||
// Read the color lightness limits defined in the theme as CSS variables
|
||||
|
||||
const lightThemeColorMaxLightness = readCssVar(
|
||||
document.documentElement,
|
||||
"tree-item-light-theme-max-color-lightness"
|
||||
).asNumber(70);
|
||||
document.documentElement,
|
||||
"tree-item-light-theme-max-color-lightness"
|
||||
).asNumber(70);
|
||||
|
||||
const darkThemeColorMinLightness = readCssVar(
|
||||
document.documentElement,
|
||||
"tree-item-dark-theme-min-color-lightness"
|
||||
).asNumber(50);
|
||||
document.documentElement,
|
||||
"tree-item-dark-theme-min-color-lightness"
|
||||
).asNumber(50);
|
||||
|
||||
function createClassForColor(colorString: string | null) {
|
||||
if (!colorString?.trim()) return "";
|
||||
@ -27,7 +28,7 @@ function createClassForColor(colorString: string | null) {
|
||||
|
||||
if (!registeredClasses.has(className)) {
|
||||
const adjustedColor = adjustColorLightness(color, lightThemeColorMaxLightness!,
|
||||
darkThemeColorMinLightness!);
|
||||
darkThemeColorMinLightness!);
|
||||
const hue = getHue(color);
|
||||
|
||||
$("head").append(`<style>
|
||||
@ -50,7 +51,7 @@ function createClassForColor(colorString: string | null) {
|
||||
|
||||
function parseColor(color: string) {
|
||||
try {
|
||||
return Color(color);
|
||||
return Color(color.toLowerCase());
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
}
|
||||
@ -84,8 +85,8 @@ function getHue(color: ColorInstance) {
|
||||
}
|
||||
|
||||
export function getReadableTextColor(bgColor: string) {
|
||||
const colorInstance = Color(bgColor);
|
||||
return colorInstance.isLight() ? "#000" : "#fff";
|
||||
const colorInstance = parseColor(bgColor);
|
||||
return !colorInstance || colorInstance?.isLight() ? "#000" : "#fff";
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@ -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<T, R>(array: T[], fn: (arg0: T) => [key: string, value: R]) {
|
||||
|
||||
@ -767,7 +767,7 @@ body.mobile .fancytree-node > span {
|
||||
background: var(--left-pane-item-hover-background);
|
||||
}
|
||||
|
||||
#left-pane span.fancytree-node.shared .fancytree-title::after {
|
||||
#left-pane .note-indicator-icon.shared-indicator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
||||
@ -148,29 +148,28 @@ span.fancytree-node.protected > span.fancytree-custom-icon {
|
||||
filter: drop-shadow(2px 2px 2px var(--main-text-color));
|
||||
}
|
||||
|
||||
span.fancytree-node.multiple-parents.shared .fancytree-title::after {
|
||||
/* Note indicator icons (clone, shared) - real DOM elements for tooltip support */
|
||||
.note-indicator-icon {
|
||||
font-family: "boxicons" !important;
|
||||
font-size: smaller;
|
||||
content: " \eb3d \ec03";
|
||||
margin-inline-start: 4px;
|
||||
opacity: 0.8;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
span.fancytree-node.multiple-parents .fancytree-title::after {
|
||||
font-family: "boxicons" !important;
|
||||
font-size: smaller;
|
||||
content: " \eb3d"; /* lookup code for "link-alt" in boxicons.css */
|
||||
.note-indicator-icon.clone-indicator::before {
|
||||
content: "\eb3d"; /* bx-link-alt */
|
||||
}
|
||||
|
||||
body.experimental-feature-new-layout span.fancytree-node.multiple-parents .fancytree-title::after {
|
||||
content: " \ed82";
|
||||
.note-indicator-icon.shared-indicator::before {
|
||||
content: "\ec03"; /* bx-share-alt */
|
||||
}
|
||||
|
||||
body.experimental-feature-new-layout .note-indicator-icon.clone-indicator::before {
|
||||
content: "\ed82";
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
span.fancytree-node.shared .fancytree-title::after {
|
||||
font-family: "boxicons" !important;
|
||||
font-size: smaller;
|
||||
content: " \ec03"; /* lookup code for "share-alt" in boxicons.css */
|
||||
}
|
||||
|
||||
span.fancytree-node.fancytree-active-clone:not(.fancytree-active) .fancytree-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@ -692,7 +692,11 @@
|
||||
"convert_into_attachment_successful": "Notiz '{{title}}' wurde als Anhang konvertiert.",
|
||||
"convert_into_attachment_prompt": "Bist du dir sicher, dass du die Notiz '{{title}}' in ein Anhang der übergeordneten Notiz konvertieren möchtest?",
|
||||
"print_pdf": "Export als PDF...",
|
||||
"open_note_on_server": "Öffne Notiz auf dem Server"
|
||||
"open_note_on_server": "Öffne Notiz auf dem Server",
|
||||
"export_as_image": "Als Bild exportieren",
|
||||
"export_as_image_png": "PNG (Raster)",
|
||||
"export_as_image_svg": "SVG (Vektor)",
|
||||
"note_map": "Notizen Karte"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "Das Schaltflächen-Widget „{{componentId}}“ hat keinen definierten Klick-Handler"
|
||||
@ -750,7 +754,15 @@
|
||||
"note_icon": {
|
||||
"change_note_icon": "Notiz-Icon ändern",
|
||||
"search": "Suche:",
|
||||
"reset-default": "Standard wiederherstellen"
|
||||
"reset-default": "Standard wiederherstellen",
|
||||
"search_placeholder_one": "Suche {{number}} Icons über {{count}} Pakete",
|
||||
"search_placeholder_other": "Suche {{number}} Icons über {{count}} Pakete",
|
||||
"search_placeholder_filtered": "Suche {{number}} Icons in {{name}}",
|
||||
"filter": "Filter",
|
||||
"filter-none": "Alle Icons",
|
||||
"filter-default": "Standard Icons",
|
||||
"icon_tooltip": "{{name}}\nIcon Paket: {{iconPack}}",
|
||||
"no_results": "Keine Icons gefunden."
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Notiztyp",
|
||||
@ -810,7 +822,8 @@
|
||||
},
|
||||
"inherited_attribute_list": {
|
||||
"title": "Geerbte Attribute",
|
||||
"no_inherited_attributes": "Keine geerbten Attribute."
|
||||
"no_inherited_attributes": "Keine geerbten Attribute.",
|
||||
"none": "Keine"
|
||||
},
|
||||
"note_info_widget": {
|
||||
"note_id": "Notiz-ID",
|
||||
@ -821,7 +834,9 @@
|
||||
"note_size_info": "Die Notizgröße bietet eine grobe Schätzung des Speicherbedarfs für diese Notiz. Es berücksichtigt den Inhalt der Notiz und den Inhalt ihrer Notizrevisionen.",
|
||||
"calculate": "berechnen",
|
||||
"subtree_size": "(Teilbaumgröße: {{size}} in {{count}} Notizen)",
|
||||
"title": "Notizinfo"
|
||||
"title": "Notizinfo",
|
||||
"mime": "MIME Typ",
|
||||
"show_similar_notes": "Zeige ähnliche Notizen"
|
||||
},
|
||||
"note_map": {
|
||||
"open_full": "Vollständig erweitern",
|
||||
@ -884,7 +899,8 @@
|
||||
"search_parameters": "Suchparameter",
|
||||
"unknown_search_option": "Unbekannte Suchoption {{searchOptionName}}",
|
||||
"search_note_saved": "Suchnotiz wurde in {{-notePathTitle}} gespeichert",
|
||||
"actions_executed": "Aktionen wurden ausgeführt."
|
||||
"actions_executed": "Aktionen wurden ausgeführt.",
|
||||
"view_options": "Anzeigeoptionen:"
|
||||
},
|
||||
"similar_notes": {
|
||||
"title": "Ähnliche Notizen",
|
||||
@ -988,7 +1004,12 @@
|
||||
"editable_text": {
|
||||
"placeholder": "Gebe hier den Inhalt deiner Notiz ein...",
|
||||
"auto-detect-language": "Automatisch erkannt",
|
||||
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht."
|
||||
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht.",
|
||||
"editor_crashed_title": "Der Text Editor ist abgestürzt",
|
||||
"editor_crashed_content": "Ihr Inhalt wurde erfolgreich wiederhergestellt, aber einzelne Ihrer letzten Änderungen waren möglicherweise noch nicht gespeichert.",
|
||||
"editor_crashed_details_button": "Zeige mehr Details…",
|
||||
"editor_crashed_details_intro": "Falls Sie diesen Fehler mehrmals sehen, melden Sie dies auf GitHub mit den folgenden Informationen.",
|
||||
"editor_crashed_details_title": "Technische Informationen"
|
||||
},
|
||||
"empty": {
|
||||
"open_note_instruction": "Öffne eine Notiz, indem du den Titel der Notiz in die Eingabe unten eingibst oder eine Notiz in der Baumstruktur auswählst.",
|
||||
@ -1503,7 +1524,12 @@
|
||||
},
|
||||
"highlights_list_2": {
|
||||
"title": "Hervorhebungs-Liste",
|
||||
"options": "Optionen"
|
||||
"options": "Optionen",
|
||||
"title_with_count_one": "{{count}} Highlight",
|
||||
"title_with_count_other": "{{count}} Highlights",
|
||||
"modal_title": "Highlight Liste konfigurieren",
|
||||
"menu_configure": "Highlight Liste konfigurieren…",
|
||||
"no_highlights": "Keine Highlights gefunden."
|
||||
},
|
||||
"quick-search": {
|
||||
"placeholder": "Schnellsuche",
|
||||
@ -1535,10 +1561,21 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Konnte typeWidget für Typ ‚{{type}}‘ nicht finden",
|
||||
"printing": "Druckvorgang läuft…",
|
||||
"printing_pdf": "PDF-Export läuft…"
|
||||
"printing_pdf": "PDF-Export läuft…",
|
||||
"print_report_title": "Druckreport",
|
||||
"print_report_collection_details_button": "Details anzeigen",
|
||||
"print_report_collection_details_ignored_notes": "Ignorierte Notizen"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "Titel der Notiz hier eingeben…"
|
||||
"placeholder": "Titel der Notiz hier eingeben…",
|
||||
"created_on": "Erstellt am <Value />",
|
||||
"last_modified": "Bearbeitet am <Value />",
|
||||
"note_type_switcher_label": "Ändere von {{type}} zu:",
|
||||
"note_type_switcher_others": "Andere Notizart",
|
||||
"note_type_switcher_templates": "Template",
|
||||
"note_type_switcher_collection": "Sammlung",
|
||||
"edited_notes": "Notizen, bearbeitet an diesem Tag",
|
||||
"promoted_attributes": "Hervorgehobene Attribute"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "Es wurden keine Notizen mit den angegebenen Suchparametern gefunden.",
|
||||
@ -1567,7 +1604,8 @@
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Inhaltsverzeichnis",
|
||||
"options": "Optionen"
|
||||
"options": "Optionen",
|
||||
"no_headings": "Keine Überschriften."
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"file_last_modified": "Datei <code class=\"file-path\"></code> wurde zuletzt geändert am <span class=\"file-last-modified\"></span>.",
|
||||
@ -2106,5 +2144,10 @@
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "Wechsele zum vollständigen Editor"
|
||||
},
|
||||
"experimental_features": {
|
||||
"title": "Experimentelle Optionen",
|
||||
"disclaimer": "Diese Optionen sind experimentell und können Instabilitäten verursachen. Achtsam zu verwenden.",
|
||||
"new_layout_name": "Neues Layout"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1769,7 +1769,11 @@
|
||||
"create-child-note": "Create child note",
|
||||
"unhoist": "Unhoist",
|
||||
"toggle-sidebar": "Toggle sidebar",
|
||||
"dropping-not-allowed": "Dropping notes into this location is not allowed."
|
||||
"dropping-not-allowed": "Dropping notes into this location is not allowed.",
|
||||
"clone-indicator-tooltip": "This note has {{- count}} parents: {{- parents}}",
|
||||
"clone-indicator-tooltip-single": "This note is cloned (1 additional parent: {{- parent}})",
|
||||
"shared-indicator-tooltip": "This note is shared publicly",
|
||||
"shared-indicator-tooltip-with-url": "This note is shared publicly at: {{- url}}"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Keep Window on Top"
|
||||
@ -2234,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..."
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,12 @@
|
||||
"bundle-error": {
|
||||
"title": "Echec du chargement d'un script personnalisé",
|
||||
"message": "Le script de la note avec l'ID \"{{id}}\", intitulé \"{{title}}\" n'a pas pu être exécuté à cause de\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "Impossible d'obtenir la liste des widgets depuis le serveur"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "Rendu impossible d'un widget React custom"
|
||||
}
|
||||
},
|
||||
"add_link": {
|
||||
@ -757,7 +763,11 @@
|
||||
"note_icon": {
|
||||
"change_note_icon": "Changer l'icône de note",
|
||||
"search": "Recherche :",
|
||||
"reset-default": "Réinitialiser l'icône par défaut"
|
||||
"reset-default": "Réinitialiser l'icône par défaut",
|
||||
"filter": "Filtre",
|
||||
"filter-none": "Toutes les icônes",
|
||||
"filter-default": "Icônes par défaut",
|
||||
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Type de note",
|
||||
@ -1541,7 +1551,8 @@
|
||||
"refresh-saved-search-results": "Rafraîchir les résultats de recherche enregistrée",
|
||||
"create-child-note": "Créer une note enfant",
|
||||
"unhoist": "Désactiver le focus",
|
||||
"toggle-sidebar": "Basculer la barre latérale"
|
||||
"toggle-sidebar": "Basculer la barre latérale",
|
||||
"dropping-not-allowed": "Lâcher des notes à cet endroit n'est pas autorisé"
|
||||
},
|
||||
"title_bar_buttons": {
|
||||
"window-on-top": "Épingler cette fenêtre au premier plan"
|
||||
@ -1549,10 +1560,19 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Impossible de trouver typeWidget pour le type '{{type}}'",
|
||||
"printing": "Impression en cours...",
|
||||
"printing_pdf": "Export au format PDF en cours..."
|
||||
"printing_pdf": "Export au format PDF en cours...",
|
||||
"print_report_title": "Imprimer le rapport",
|
||||
"print_report_collection_details_button": "Consulter les détails",
|
||||
"print_report_collection_details_ignored_notes": "Notes ignorées"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "saisir le titre de la note ici..."
|
||||
"placeholder": "saisir le titre de la note ici...",
|
||||
"created_on": "Créé le <Value />",
|
||||
"last_modified": "Modifié le <Value />",
|
||||
"note_type_switcher_label": "Basculer de {{type}} à :",
|
||||
"note_type_switcher_others": "Autre type de note",
|
||||
"note_type_switcher_templates": "Modèle",
|
||||
"note_type_switcher_collection": "Collection"
|
||||
},
|
||||
"search_result": {
|
||||
"no_notes_found": "Aucune note n'a été trouvée pour les paramètres de recherche donnés.",
|
||||
@ -1581,7 +1601,8 @@
|
||||
},
|
||||
"toc": {
|
||||
"table_of_contents": "Table des matières",
|
||||
"options": "Options"
|
||||
"options": "Options",
|
||||
"no_headings": "Pas d'en-tête."
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"file_last_modified": "Le fichier <code class=\"file-path\"></code> a été modifié pour la dernière fois le <span class=\"file-last-modified\"></span>.",
|
||||
@ -1682,7 +1703,8 @@
|
||||
"copy-link": "Copier le lien",
|
||||
"paste": "Coller",
|
||||
"paste-as-plain-text": "Coller comme texte brut",
|
||||
"search_online": "Rechercher «{{term}}» avec {{searchEngine}}"
|
||||
"search_online": "Rechercher «{{term}}» avec {{searchEngine}}",
|
||||
"search_in_trilium": "Rechercher \"{{term}}\" dans Trilium"
|
||||
},
|
||||
"image_context_menu": {
|
||||
"copy_reference_to_clipboard": "Copier la référence dans le presse-papiers",
|
||||
@ -1991,7 +2013,8 @@
|
||||
"add-column": "Ajouter une colonne",
|
||||
"add-column-placeholder": "Entrez le nom de la colonne...",
|
||||
"edit-note-title": "Cliquez pour modifier le titre de la note",
|
||||
"edit-column-title": "Cliquez pour modifier le titre de la colonne"
|
||||
"edit-column-title": "Cliquez pour modifier le titre de la colonne",
|
||||
"column-already-exists": "Cette colonne existe déjà dans le tableau."
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Modifier cette diapositive",
|
||||
@ -2075,7 +2098,8 @@
|
||||
"button_title": "Exporter le diagramme au format PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "Le diagramme n'a pas pu être exporté au format PNG."
|
||||
"export_to_png": "Le diagramme n'a pas pu être exporté au format PNG.",
|
||||
"export_to_svg": "Le diagramme n'a pas pu être exporté en SVG."
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "Apparence",
|
||||
@ -2108,6 +2132,10 @@
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "Vous consultez actuellement une note en lecture seule.",
|
||||
"auto-read-only-note": "Cette note s'affiche en mode lecture seule pour un chargement plus rapide."
|
||||
"auto-read-only-note": "Cette note s'affiche en mode lecture seule pour un chargement plus rapide.",
|
||||
"edit-note": "Editer la note"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "Effacer la note..."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,28 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "ट्रिलियम नोट्स के बारें में"
|
||||
"title": "ट्रिलियम नोट्स के बारें में",
|
||||
"build_date": "निर्माण की तारीख:"
|
||||
},
|
||||
"toast": {
|
||||
"widget-error": {
|
||||
"title": "एक विजेट को इनिशियलाइज़ करने में विफल रहा"
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "एक कस्टम स्क्रिप्ट लोड करने में विफल रहा"
|
||||
}
|
||||
},
|
||||
"update_available": {
|
||||
"update_available": "उपलब्ध अद्यतन"
|
||||
},
|
||||
"code_buttons": {
|
||||
"execute_button_title": "स्क्रिप्ट एक्सीक्यूट करें",
|
||||
"trilium_api_docs_button_title": "ट्रिलियम एपीआई डॉक्स खोलें",
|
||||
"save_to_note_button_title": "नोट में सेव करें"
|
||||
},
|
||||
"hide_floating_buttons_button": {
|
||||
"button_title": "बटन छुपाएं"
|
||||
},
|
||||
"show_floating_buttons_button": {
|
||||
"button_title": "बटन दिखाएं"
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/client/src/types-pdfjs.d.ts
vendored
12
apps/client/src/types-pdfjs.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
@ -88,6 +88,7 @@ export default function PopupEditor() {
|
||||
onHidden={() => setShown(false)}
|
||||
keepInDom // needed for faster loading
|
||||
noFocus // automatic focus breaks block popup
|
||||
stackable
|
||||
>
|
||||
{!isNewLayout && <ReadOnlyNoteInfoBar />}
|
||||
<PromotedAttributes />
|
||||
|
||||
@ -624,6 +624,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
const $span = $(node.span);
|
||||
|
||||
$span.find(".tree-item-button").remove();
|
||||
$span.find(".note-indicator-icon").remove();
|
||||
|
||||
const isHoistedNote = activeNoteContext && activeNoteContext.hoistedNoteId === note.noteId && note.noteId !== "root";
|
||||
|
||||
@ -664,6 +665,34 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
|
||||
$span.append($unhoistButton);
|
||||
}
|
||||
|
||||
// Add clone indicator with tooltip if note has multiple parents
|
||||
const parentNotes = note.getParentNotes();
|
||||
const realParents = parentNotes.filter(
|
||||
(parent) => !["_share", "_lbBookmarks"].includes(parent.noteId) && parent.type !== "search"
|
||||
);
|
||||
|
||||
if (realParents.length > 1) {
|
||||
const parentTitles = realParents.map((p) => p.title).join(", ");
|
||||
const tooltipText = realParents.length === 2
|
||||
? t("note_tree.clone-indicator-tooltip-single", { parent: realParents[1].title })
|
||||
: t("note_tree.clone-indicator-tooltip", { count: realParents.length, parents: parentTitles });
|
||||
|
||||
const $cloneIndicator = $(`<span class="note-indicator-icon clone-indicator"></span>`);
|
||||
$cloneIndicator.attr("title", tooltipText);
|
||||
$span.find(".fancytree-title").append($cloneIndicator);
|
||||
}
|
||||
|
||||
// Add shared indicator with tooltip if note is shared
|
||||
if (note.isShared()) {
|
||||
const shareId = note.getOwnedLabelValue("shareAlias") || note.noteId;
|
||||
const shareUrl = `${location.origin}${location.pathname}share/${shareId}`;
|
||||
const tooltipText = t("note_tree.shared-indicator-tooltip-with-url", { url: shareUrl });
|
||||
|
||||
const $sharedIndicator = $(`<span class="note-indicator-icon shared-indicator"></span>`);
|
||||
$sharedIndicator.attr("title", tooltipText);
|
||||
$span.find(".fancytree-title").append($sharedIndicator);
|
||||
}
|
||||
},
|
||||
// this is done to automatically lazy load all expanded notes after tree load
|
||||
loadChildren: (event, data) => {
|
||||
|
||||
@ -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<NoteType, "launcher" | "text" | "code"> | "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 {
|
||||
|
||||
@ -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";
|
||||
@ -634,7 +634,8 @@ export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const parentComponent = useContext(ParentComponent);
|
||||
|
||||
// Render the widget once.
|
||||
// Render the widget once - note that noteContext is intentionally NOT a dependency
|
||||
// to prevent creating new widget instances on every note switch.
|
||||
const [ widget, renderedWidget ] = useMemo(() => {
|
||||
const widget = widgetFactory();
|
||||
|
||||
@ -642,14 +643,21 @@ export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, {
|
||||
parentComponent.child(widget);
|
||||
}
|
||||
|
||||
if (noteContext && widget instanceof NoteContextAwareWidget) {
|
||||
widget.setNoteContextEvent({ noteContext });
|
||||
}
|
||||
|
||||
const renderedWidget = widget.render();
|
||||
return [ widget, renderedWidget ];
|
||||
}, [ noteContext, parentComponent ]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// widgetFactory() is intentionally left out
|
||||
}, [ parentComponent ]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// widgetFactory() and noteContext are intentionally left out - widget should be created once
|
||||
// and updated via activeContextChangedEvent when noteContext changes.
|
||||
|
||||
// Cleanup: remove widget from parent's children when unmounted
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (parentComponent) {
|
||||
parentComponent.removeChild(widget);
|
||||
}
|
||||
widget.cleanup();
|
||||
};
|
||||
}, [ parentComponent, widget ]);
|
||||
|
||||
// Attach the widget to the parent.
|
||||
useEffect(() => {
|
||||
@ -660,10 +668,17 @@ export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, {
|
||||
}
|
||||
}, [ renderedWidget ]);
|
||||
|
||||
// Inject the note context.
|
||||
// Inject the note context - this updates the existing widget without recreating it.
|
||||
// We check if the context actually changed to avoid double refresh when the event system
|
||||
// also delivers activeContextChanged to the widget through component tree propagation.
|
||||
useEffect(() => {
|
||||
if (noteContext && widget instanceof NoteContextAwareWidget) {
|
||||
widget.activeContextChangedEvent({ noteContext });
|
||||
// Only trigger refresh if the context actually changed.
|
||||
// The event system may have already updated the widget, in which case
|
||||
// widget.noteContext will already equal noteContext.
|
||||
if (widget.noteContext !== noteContext) {
|
||||
widget.activeContextChangedEvent({ noteContext });
|
||||
}
|
||||
}
|
||||
}, [ noteContext, widget ]);
|
||||
|
||||
@ -1192,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<K extends keyof NoteContextDataMap>(
|
||||
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<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();
|
||||
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<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,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<string[]>("highlightsList");
|
||||
const isPdf = noteType === "file" && noteMime === "application/pdf";
|
||||
|
||||
if (!rightPaneVisible) return [];
|
||||
const definitions: RightPanelWidgetDefinition[] = [
|
||||
{
|
||||
el: <TableOfContents />,
|
||||
enabled: (noteType === "text" || noteType === "doc"),
|
||||
enabled: (noteType === "text" || noteType === "doc" || isPdf),
|
||||
},
|
||||
{
|
||||
el: <PdfPages />,
|
||||
enabled: isPdf,
|
||||
},
|
||||
{
|
||||
el: <PdfAttachments />,
|
||||
enabled: isPdf,
|
||||
},
|
||||
{
|
||||
el: <PdfLayers />,
|
||||
enabled: isPdf,
|
||||
},
|
||||
{
|
||||
el: <HighlightsList />,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<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 AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading }: {
|
||||
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 }: {
|
||||
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} />)}
|
||||
{nestedHeadings.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} activeHeadingId={activeHeadingId} />)}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="no-headings">{t("toc.no_headings")}</div>
|
||||
@ -52,14 +73,16 @@ function AbstractTableOfContents<T extends RawHeading>({ 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 (
|
||||
<>
|
||||
<li className={clsx(collapsed && "collapsed")}>
|
||||
<li className={clsx(collapsed && "collapsed", isActive && "active")}>
|
||||
{heading.children.length > 0 && (
|
||||
<Icon
|
||||
className="collapse-button"
|
||||
@ -74,7 +97,7 @@ function TableOfContentsHeading({ heading, scrollToHeading }: {
|
||||
</li>
|
||||
{heading.children && (
|
||||
<ol>
|
||||
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} />)}
|
||||
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} activeHeadingId={activeHeadingId} />)}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
|
||||
57
apps/client/src/widgets/sidebar/pdf/PdfAttachments.css
Normal file
57
apps/client/src/widgets/sidebar/pdf/PdfAttachments.css
Normal file
@ -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);
|
||||
}
|
||||
62
apps/client/src/widgets/sidebar/pdf/PdfAttachments.tsx
Normal file
62
apps/client/src/widgets/sidebar/pdf/PdfAttachments.tsx
Normal file
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
54
apps/client/src/widgets/sidebar/pdf/PdfLayers.css
Normal file
54
apps/client/src/widgets/sidebar/pdf/PdfLayers.css
Normal file
@ -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);
|
||||
}
|
||||
55
apps/client/src/widgets/sidebar/pdf/PdfLayers.tsx
Normal file
55
apps/client/src/widgets/sidebar/pdf/PdfLayers.tsx
Normal file
@ -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 &&
|
||||
<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>
|
||||
);
|
||||
}
|
||||
67
apps/client/src/widgets/sidebar/pdf/PdfPages.css
Normal file
67
apps/client/src/widgets/sidebar/pdf/PdfPages.css
Normal file
@ -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);
|
||||
}
|
||||
111
apps/client/src/widgets/sidebar/pdf/PdfPages.tsx
Normal file
111
apps/client/src/widgets/sidebar/pdf/PdfPages.tsx
Normal file
@ -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 &&
|
||||
<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={t("pdf.pages_alt", { pageNumber })} />
|
||||
) : (
|
||||
<div className="pdf-page-loading">{t("pdf.pages_loading")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -16,7 +16,7 @@ export default function FileTypeWidget({ note, parentComponent, noteContext }: T
|
||||
if (blob?.content) {
|
||||
return <TextPreview content={blob.content} />;
|
||||
} else if (note.mime === "application/pdf") {
|
||||
return <PdfPreview blob={blob} note={note} ntxId={noteContext?.ntxId} componentId={parentComponent?.componentId} />;
|
||||
return noteContext && <PdfPreview blob={blob} note={note} componentId={parentComponent?.componentId} noteContext={noteContext} />;
|
||||
} else if (note.mime.startsWith("video/")) {
|
||||
return <VideoPreview note={note} />;
|
||||
} else if (note.mime.startsWith("audio/")) {
|
||||
|
||||
@ -29,6 +29,7 @@ export default function Mermaid(props: TypeWidgetProps) {
|
||||
<SvgSplitEditor
|
||||
attachmentName="mermaid-export"
|
||||
renderSvg={renderSvg}
|
||||
noteType="mermaid"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import "./code.css";
|
||||
|
||||
import { default as VanillaCodeMirror, getThemeById } from "@triliumnext/codemirror";
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext, { CommandListenerData } from "../../../components/app_context";
|
||||
@ -24,6 +25,7 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit<CodeEditorProps
|
||||
debounceUpdate?: boolean;
|
||||
lineWrapping?: boolean;
|
||||
updateInterval?: number;
|
||||
noteType?: NoteType;
|
||||
/** Invoked when the content of the note is changed, such as a different revision or a note switch. */
|
||||
onContentChanged?: (content: string) => void;
|
||||
/** Invoked after the content of the note has been uploaded to the server, using a spaced update. */
|
||||
@ -72,14 +74,14 @@ function formatViewSource(note: FNote, content: string) {
|
||||
return content;
|
||||
}
|
||||
|
||||
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, onContentChanged, dataSaved, ...editorProps }: EditableCodeProps) {
|
||||
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, ...editorProps }: EditableCodeProps) {
|
||||
const editorRef = useRef<VanillaCodeMirror>(null);
|
||||
const containerRef = useRef<HTMLPreElement>(null);
|
||||
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
|
||||
const mime = useNoteProperty(note, "mime");
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteType: "code",
|
||||
noteType,
|
||||
noteContext,
|
||||
getData: () => ({ content: editorRef.current?.getText() ?? "" }),
|
||||
onContentChange: (content) => {
|
||||
|
||||
@ -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<HTMLIFrameElement>(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 &&
|
||||
<iframe
|
||||
tabIndex={300}
|
||||
ref={iframeRef}
|
||||
class="pdf-preview"
|
||||
src={`pdfjs/web/viewer.html?file=../../api/notes/${note.noteId}/open&lang=${locale}`}
|
||||
src={`pdfjs/web/viewer.html?file=../../api/notes/${note.noteId}/open&lang=${locale}&sidebar=${newLayout ? "0" : "1"}`}
|
||||
onLoad={() => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (win) {
|
||||
@ -138,3 +238,40 @@ 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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { t } from "../../../services/i18n";
|
||||
import SplitEditor, { PreviewButton, SplitEditorProps } from "./SplitEditor";
|
||||
import { RawHtmlBlock } from "../../react/RawHtml";
|
||||
@ -55,7 +55,9 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
|
||||
}
|
||||
|
||||
// Save as attachment.
|
||||
function onSave() {
|
||||
const onSave = useCallback(() => {
|
||||
if (!svg) return; // Don't save if SVG hasn't been rendered yet
|
||||
|
||||
const payload = {
|
||||
role: "image",
|
||||
title: `${attachmentName}.svg`,
|
||||
@ -65,16 +67,18 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
|
||||
};
|
||||
|
||||
server.post(`notes/${note.noteId}/attachments?matchBy=title`, payload);
|
||||
}
|
||||
}, [ svg, attachmentName, note.noteId ]);
|
||||
|
||||
// Save the SVG when entering a note only when it does not have an attachment.
|
||||
useEffect(() => {
|
||||
if (!svg) return; // Wait until SVG is rendered
|
||||
|
||||
note?.getAttachments().then((attachments) => {
|
||||
if (!attachments.find((a) => a.title === `${attachmentName}.svg`)) {
|
||||
onSave();
|
||||
}
|
||||
});
|
||||
}, [ note ]);
|
||||
}).catch(e => console.error("Failed to get attachments for SVGSplitEditor", e));
|
||||
}, [ note, svg, attachmentName, onSave ]);
|
||||
|
||||
// Import/export
|
||||
useTriliumEvent("exportSvg", async({ ntxId: eventNtxId }) => {
|
||||
|
||||
@ -35,5 +35,5 @@ describe("CK config", () => {
|
||||
expect(config.translations, locale.id).toHaveLength(2);
|
||||
}
|
||||
}
|
||||
}, 10_000);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import BuildHelper from "../../../scripts/build-utils";
|
||||
import originalPackageJson from "../package.json" with { type: "json" };
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
const build = new BuildHelper("apps/desktop");
|
||||
|
||||
@ -18,9 +19,7 @@ async function main() {
|
||||
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path", "@electron/remote" ]);
|
||||
build.copy("/node_modules/ckeditor5/dist/ckeditor5-content.css", "ckeditor5-content.css");
|
||||
|
||||
// Integrate the client.
|
||||
build.triggerBuildAndCopyTo("apps/client", "public/");
|
||||
build.deleteFromOutput("public/webpack-stats.json");
|
||||
build.buildFrontend();
|
||||
|
||||
generatePackageJson();
|
||||
}
|
||||
|
||||
111
apps/server-e2e/src/note_types/pdf.spec.ts
Normal file
111
apps/server-e2e/src/note_types/pdf.spec.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import test, { BrowserContext, expect, Page } from "@playwright/test";
|
||||
|
||||
import App from "../support/app";
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const app = await setLayout({ page, context }, true);
|
||||
await app.setOption("rightPaneCollapsedItems", "[]");
|
||||
});
|
||||
test.afterEach(async ({ page, context }) => await setLayout({ page, context }, false));
|
||||
|
||||
test("Table of contents works", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.goToNoteInNewTab("Dacia Logan.pdf");
|
||||
|
||||
const toc = app.sidebar.locator(".toc");
|
||||
|
||||
await expect(toc.locator("li")).toHaveCount(48);
|
||||
await expect(toc.locator("li", { hasText: "Logan Van" })).toHaveCount(1);
|
||||
|
||||
const pdfHelper = new PdfHelper(app);
|
||||
await toc.locator("li", { hasText: "Logan Pick-Up" }).click();
|
||||
await pdfHelper.expectPageToBe(13);
|
||||
|
||||
await app.clickNoteOnNoteTreeByTitle("Layers test.pdf");
|
||||
await expect(toc.locator("li")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Page navigation works", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.goToNoteInNewTab("Dacia Logan.pdf");
|
||||
|
||||
const pagesList = app.sidebar.locator(".pdf-pages-list");
|
||||
|
||||
// Check count is correct.
|
||||
await expect(app.sidebar).toContainText("28 pages");
|
||||
expect(await pagesList.locator(".pdf-page-item").count()).toBe(28);
|
||||
|
||||
// Go to page 3.
|
||||
await pagesList.locator(".pdf-page-item").nth(2).click();
|
||||
|
||||
const pdfHelper = new PdfHelper(app);
|
||||
await pdfHelper.expectPageToBe(3);
|
||||
|
||||
await app.clickNoteOnNoteTreeByTitle("Layers test.pdf");
|
||||
await expect(pagesList.locator(".pdf-page-item")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Attachments listing works", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.goToNoteInNewTab("Dacia Logan.pdf");
|
||||
|
||||
const attachmentsList = app.sidebar.locator(".pdf-attachments-list");
|
||||
await expect(app.sidebar).toContainText("2 attachments");
|
||||
await expect(attachmentsList.locator(".pdf-attachment-item")).toHaveCount(2);
|
||||
|
||||
const attachmentInfo = attachmentsList.locator(".pdf-attachment-item", { hasText: "Note.trilium" });
|
||||
await expect(attachmentInfo).toContainText("3.36 MiB");
|
||||
|
||||
// Download the attachment and check its size.
|
||||
const [ download ] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
attachmentInfo.locator(".bx-download").click()
|
||||
]);
|
||||
expect(download).toBeDefined();
|
||||
|
||||
await app.clickNoteOnNoteTreeByTitle("Layers test.pdf");
|
||||
await expect(attachmentsList.locator(".pdf-attachment-item")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Layers listing works", async ({ page, context }) => {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.goToNoteInNewTab("Layers test.pdf");
|
||||
|
||||
// Check count is correct.
|
||||
await expect(app.sidebar).toContainText("2 layers");
|
||||
const layersList = app.sidebar.locator(".pdf-layers-list");
|
||||
await expect(layersList.locator(".pdf-layer-item")).toHaveCount(2);
|
||||
|
||||
// Toggle visibility of the first layer.
|
||||
const firstLayer = layersList.locator(".pdf-layer-item").first();
|
||||
await expect(firstLayer).toContainText("Tongue out");
|
||||
await expect(firstLayer).toContainClass("hidden");
|
||||
await firstLayer.click();
|
||||
await expect(firstLayer).not.toContainClass("visible");
|
||||
|
||||
await app.clickNoteOnNoteTreeByTitle("Dacia Logan.pdf");
|
||||
await expect(layersList.locator(".pdf-layer-item")).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function setLayout({ page, context}: { page: Page; context: BrowserContext }, newLayout: boolean) {
|
||||
const app = new App(page, context);
|
||||
await app.goto();
|
||||
await app.setOption("newLayout", newLayout ? "true" : "false");
|
||||
return app;
|
||||
}
|
||||
|
||||
class PdfHelper {
|
||||
private contentFrame: ReturnType<Page["frameLocator"]>;
|
||||
|
||||
constructor(app: App) {
|
||||
this.contentFrame = app.currentNoteSplit.frameLocator("iframe");
|
||||
}
|
||||
|
||||
async expectPageToBe(expectedPageNumber: number) {
|
||||
await expect(this.contentFrame.locator("#pageNumber")).toHaveValue(`${expectedPageNumber}`);
|
||||
}
|
||||
}
|
||||
@ -14,12 +14,7 @@ async function main() {
|
||||
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path" ]);
|
||||
build.copy("/node_modules/ckeditor5/dist/ckeditor5-content.css", "ckeditor5-content.css");
|
||||
|
||||
// Integrate the client.
|
||||
build.triggerBuildAndCopyTo("apps/client", "public/");
|
||||
build.deleteFromOutput("public/webpack-stats.json");
|
||||
|
||||
// pdf.js
|
||||
build.triggerBuildAndCopyTo("packages/pdfjs-viewer", "pdfjs-viewer");
|
||||
build.buildFrontend();
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
Binary file not shown.
@ -1 +1,12 @@
|
||||
{}
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "इतिहास में पिछले नोट पर जाएं",
|
||||
"forward-in-note-history": "इतिहास में अगले नोट पर जाएं",
|
||||
"open-jump-to-note-dialog": "\"Jump to Note\" डायलॉग खोलें",
|
||||
"open-command-palette": "कमांड पैलेट खोलें",
|
||||
"collapse-tree": "पूरे नोट ट्री को बंद करता है",
|
||||
"collapse-subtree": "वर्तमान नोट के सबट्री को बंद करता है",
|
||||
"sort-child-notes": "चाइल्ड नोट्स को सॉर्ट करें",
|
||||
"creating-and-moving-notes": "नोट्स बनाना और स्थानांतरित करना"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import BRevision from "./brevision.js";
|
||||
|
||||
describe("Revision", () => {
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNote } from "../test/becca_easy_mocking";
|
||||
import { buildRewardMap } from "./similarity";
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import BNote from '../../becca/entities/bnote.js';
|
||||
import cls from "../../services/cls";
|
||||
import { buildNote } from "../../test/becca_easy_mocking";
|
||||
@ -17,7 +19,7 @@ describe("processContent", () => {
|
||||
return {
|
||||
attachmentId: "foo",
|
||||
title: "encodedTitle",
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}));
|
||||
@ -25,12 +27,12 @@ describe("processContent", () => {
|
||||
|
||||
it("processes basic note", () => {
|
||||
const processed = cls.init(() => processContent([], note, "<p>Hello world.</p>"));
|
||||
expect(processed).toStrictEqual("<p>Hello world.</p>")
|
||||
expect(processed).toStrictEqual("<p>Hello world.</p>");
|
||||
});
|
||||
|
||||
it("processes plain text", () => {
|
||||
const processed = cls.init(() => processContent([], note, "Hello world."));
|
||||
expect(processed).toStrictEqual("<p>Hello world.</p>")
|
||||
expect(processed).toStrictEqual("<p>Hello world.</p>");
|
||||
});
|
||||
|
||||
it("replaces images", () => {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { Application } from "express";
|
||||
import { beforeAll, describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import supertest from "supertest";
|
||||
import config from "../../services/config.js";
|
||||
import { refreshAuth } from "../../services/auth.js";
|
||||
import { sleepFor } from "@triliumnext/commons";
|
||||
import { Application } from "express";
|
||||
import supertest from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { refreshAuth } from "../../services/auth.js";
|
||||
import config from "../../services/config.js";
|
||||
|
||||
// Mock the CSRF protection middleware to allow tests to pass
|
||||
vi.mock("../csrf_protection.js", () => ({
|
||||
@ -841,7 +842,7 @@ describe("LLM API Tests", () => {
|
||||
try {
|
||||
await supertest(app)
|
||||
.delete(`/api/llm/chat/${createdChatId}`)
|
||||
;
|
||||
;
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNote, buildNotes } from "../../test/becca_easy_mocking";
|
||||
import note_map from "./note_map";
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import supertest, { type Response } from "supertest";
|
||||
import type { Application } from "express";
|
||||
import { dayjs } from "@triliumnext/commons";
|
||||
import { type SQLiteSessionStore } from "./session_parser.js";
|
||||
import type { Application } from "express";
|
||||
import { SessionData } from "express-session";
|
||||
import supertest, { type Response } from "supertest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import cls from "../services/cls.js";
|
||||
import { type SQLiteSessionStore } from "./session_parser.js";
|
||||
|
||||
let app: Application;
|
||||
let sessionStore: SQLiteSessionStore;
|
||||
@ -24,7 +25,7 @@ describe("Login Route test", () => {
|
||||
// RegExp for login page specific string in HTML
|
||||
const res = await supertest(app)
|
||||
.get("/login")
|
||||
.expect(200)
|
||||
.expect(200);
|
||||
|
||||
expect(res.text).toMatch(/assets\/v[0-9.a-z]+\/src\/login\.js/);
|
||||
|
||||
@ -35,7 +36,7 @@ describe("Login Route test", () => {
|
||||
await supertest(app)
|
||||
.post("/login")
|
||||
.send({ password: "fakePassword" })
|
||||
.expect(401)
|
||||
.expect(401);
|
||||
|
||||
});
|
||||
|
||||
@ -69,7 +70,7 @@ describe("Login Route test", () => {
|
||||
|
||||
// ignore the seconds in the comparison, just to avoid flakiness in tests,
|
||||
// if for some reason execution is slow between calculation of expected and actual
|
||||
expect(actualExpiresDate.slice(0,23)).toBe(expectedExpiresDate.slice(0,23))
|
||||
expect(actualExpiresDate.slice(0,23)).toBe(expectedExpiresDate.slice(0,23));
|
||||
});
|
||||
|
||||
it("sets the correct sesssion data", async () => {
|
||||
@ -121,14 +122,14 @@ describe("Login Route test", () => {
|
||||
res = await supertest(app)
|
||||
.post("/login")
|
||||
.send({ password: "demo1234" })
|
||||
.expect(302)
|
||||
.expect(302);
|
||||
|
||||
setCookieHeader = res.headers["set-cookie"][0];
|
||||
});
|
||||
|
||||
it("does not set Expires", async () => {
|
||||
// match for e.g. "Expires=Wed, 07 May 2025 07:02:59 GMT;"
|
||||
expect(setCookieHeader).not.toMatch(/Expires=(?<date>[\w\s,:]+)/)
|
||||
expect(setCookieHeader).not.toMatch(/Expires=(?<date>[\w\s,:]+)/);
|
||||
});
|
||||
|
||||
it("stores the session in the database", async () => {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import supertest from "supertest";
|
||||
import options from "./options";
|
||||
import cls from "./cls";
|
||||
import { Application } from "express";
|
||||
import config from "./config";
|
||||
import supertest from "supertest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { refreshAuth } from "./auth";
|
||||
import cls from "./cls";
|
||||
import config from "./config";
|
||||
import options from "./options";
|
||||
|
||||
let app: Application;
|
||||
|
||||
@ -39,7 +41,7 @@ describe("Auth", () => {
|
||||
const response = await supertest(app)
|
||||
.get("/")
|
||||
.redirects(1)
|
||||
.expect(200)
|
||||
.expect(200);
|
||||
expect(response.text).not.toContain(`id="totpToken"`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -295,6 +295,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
return url ? `href="${url}"` : match;
|
||||
});
|
||||
|
||||
if (format === "share") {
|
||||
content = content.replace(/src="[^"]*api\/notes\/([a-zA-Z0-9_]+)\/download"/g, (match, targetNoteId) => {
|
||||
const url = getNoteTargetUrl(targetNoteId, noteMeta);
|
||||
|
||||
return url ? `src="${url}"` : match;
|
||||
});
|
||||
}
|
||||
|
||||
return content;
|
||||
|
||||
function findAttachment(targetAttachmentId: string) {
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deferred, LOCALES } from "@triliumnext/commons";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import branches from "./branches.js";
|
||||
import cls from "./cls.js";
|
||||
import hiddenSubtreeService from "./hidden_subtree.js";
|
||||
import sql_init from "./sql_init.js";
|
||||
import branches from "./branches.js";
|
||||
import becca from "../becca/becca.js";
|
||||
import { deferred, LOCALES } from "@triliumnext/commons";
|
||||
import { changeLanguage } from "./i18n.js";
|
||||
import sql_init from "./sql_init.js";
|
||||
|
||||
describe("Hidden Subtree", () => {
|
||||
beforeAll(async () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("i18n", () => {
|
||||
it("translations are valid JSON", () => {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNote } from "../test/becca_easy_mocking";
|
||||
import { determineBestFontAttachment, generateCss, generateIconRegistry, IconPackManifest, processIconPack } from "./icon_packs";
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import dateUtils from "./date_utils.js";
|
||||
import keyboardActions from "./keyboard_actions.js";
|
||||
import log from "./log.js";
|
||||
import optionService from "./options.js";
|
||||
import { isWindows,randomSecureToken } from "./utils.js";
|
||||
import { isWindows, randomSecureToken } from "./utils.js";
|
||||
|
||||
function initDocumentOptions() {
|
||||
optionService.createOption("documentId", randomSecureToken(16), false);
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import { buildNote } from "../test/becca_easy_mocking.js";
|
||||
import { note, NoteBuilder } from "../test/becca_mocking.js";
|
||||
import { NoteBuilder } from "../test/becca_mocking.js";
|
||||
import cls from "./cls.js";
|
||||
import { buildJsx, executeBundle, getScriptBundle } from "./script.js";
|
||||
|
||||
|
||||
describe("Script", () => {
|
||||
let rootNote!: NoteBuilder;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
becca.reset();
|
||||
|
||||
rootNote = new NoteBuilder(
|
||||
new NoteBuilder(
|
||||
new BNote({
|
||||
noteId: "root",
|
||||
title: "root",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
import { describe, expect,it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildShareNote, buildShareNotes } from "../test/shaca_mocking.js";
|
||||
import { getContent, renderCode, type Result } from "./content_renderer.js";
|
||||
|
||||
@ -4,7 +4,7 @@ import ejs from "ejs";
|
||||
import escapeHtml from "escape-html";
|
||||
import { readFileSync } from "fs";
|
||||
import { t } from "i18next";
|
||||
import { HTMLElement, Options,parse, TextNode } from "node-html-parser";
|
||||
import { HTMLElement, Options, parse, TextNode } from "node-html-parser";
|
||||
import { join } from "path";
|
||||
|
||||
import becca from "../becca/becca.js";
|
||||
@ -75,6 +75,14 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath
|
||||
note: parentBranch.getNote()
|
||||
};
|
||||
|
||||
// Determine JS to load.
|
||||
const jsToLoad: string[] = [
|
||||
`${basePath}assets/scripts.js`
|
||||
];
|
||||
for (const jsRelation of note.getRelations("shareJs")) {
|
||||
jsToLoad.push(`api/notes/${jsRelation.value}/download`);
|
||||
}
|
||||
|
||||
return renderNoteContentInternal(note, {
|
||||
subRoot,
|
||||
rootNoteId: parentBranch.noteId,
|
||||
@ -82,9 +90,7 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath
|
||||
`${basePath}assets/styles.css`,
|
||||
`${basePath}assets/scripts.css`,
|
||||
],
|
||||
jsToLoad: [
|
||||
`${basePath}assets/scripts.js`
|
||||
],
|
||||
jsToLoad,
|
||||
logoUrl: `${basePath}icon-color.svg`,
|
||||
faviconUrl: `${basePath}favicon.ico`,
|
||||
ancestors,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Application, NextFunction,Request, Response } from "express";
|
||||
import supertest from "supertest";
|
||||
import type { Application, Request, Response, NextFunction } from "express";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
let app: Application;
|
||||
|
||||
@ -6,9 +6,6 @@
|
||||
"target": "ES2020",
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
{
|
||||
"get-started": {
|
||||
"title": "शुरू करें"
|
||||
"title": "शुरू करें",
|
||||
"desktop_title": "डेस्कटॉप एप्लिकेशन डाउनलोड करें (v{{version}})",
|
||||
"older_releases": "पुराने रिलीज़ देखें"
|
||||
},
|
||||
"hero_section": {
|
||||
"title": "अपने विचारों को व्यवस्थित करें। अपना व्यक्तिगत नॉलेज बेस बनाएं।",
|
||||
"screenshot_alt": "ट्रिलियम नोट्स डेस्कटॉप एप्लिकेशन का स्क्रीनशॉट"
|
||||
},
|
||||
"organization_benefits": {
|
||||
"note_structure_title": "नोट संरचना",
|
||||
"note_structure_description": "नोटों को पदानुक्रमिक रूप से व्यवस्थित किया जा सकता है। फ़ोल्डर्स की कोई आवश्यकता नहीं है, क्योंकि प्रत्येक नोट में उप-नोट हो सकते हैं। एक एकल नोट को पदानुक्रम में कई स्थानों पर जोड़ा जा सकता है।"
|
||||
}
|
||||
}
|
||||
|
||||
2
docs/README-hi.md
vendored
2
docs/README-hi.md
vendored
@ -34,7 +34,7 @@ application with focus on building large personal knowledge bases.
|
||||
|
||||
<img src="./app.png" alt="Trilium Screenshot" width="1000">
|
||||
|
||||
## ⏬ Download
|
||||
## ⏬ डाउनलोड कीजिए
|
||||
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) –
|
||||
stable version, recommended for most users.
|
||||
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) –
|
||||
|
||||
10
docs/README-nb_NO.md
vendored
10
docs/README-nb_NO.md
vendored
@ -34,14 +34,14 @@ application with focus on building large personal knowledge bases.
|
||||
|
||||
<img src="./app.png" alt="Trilium Screenshot" width="1000">
|
||||
|
||||
## ⏬ Download
|
||||
## ⏬ Last ned
|
||||
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) –
|
||||
stable version, recommended for most users.
|
||||
stabil versjon, anbefalt for de fleste brukere.
|
||||
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) –
|
||||
unstable development version, updated daily with the latest features and
|
||||
fixes.
|
||||
ustabil utviklingsversjon, oppdateres daglig med de nyeste funksjonene og
|
||||
feilfiksene.
|
||||
|
||||
## 📚 Documentation
|
||||
## 📚 Dokumentasjon
|
||||
|
||||
**Visit our comprehensive documentation at
|
||||
[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
|
||||
|
||||
@ -3,9 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
import { LOCALES } from "./i18n.js";
|
||||
import { DAYJS_LOADER, dayjs } from "./dayjs.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("dayjs", () => {
|
||||
it("all dayjs locales are valid", async () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { deferred } from "./utils.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("#deferred", () => {
|
||||
it("should return a promise", () => {
|
||||
|
||||
@ -3,9 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
|
||||
@ -8,6 +8,7 @@ import type { Content } from "./Content.js";
|
||||
import { Stream } from "stream";
|
||||
import type { Range } from "./Range.js";
|
||||
import type { MockInstance } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("createPartialContentHandler tests", () => {
|
||||
let logger: Logger;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { parseRangeHeader } from "./parseRangeHeader.js";
|
||||
import type { Logger } from "./Logger.js";
|
||||
import { RangeParserError } from "./RangeParserError.js";
|
||||
import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
|
||||
|
||||
describe("parseRangeHeader tests", () => {
|
||||
let logger: Logger;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { expect, type Mock } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import {
|
||||
getHeader,
|
||||
|
||||
@ -3,9 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
|
||||
@ -3,9 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/vitest",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
|
||||
80
packages/pdfjs-viewer/src/attachments.ts
Normal file
80
packages/pdfjs-viewer/src/attachments.ts
Normal file
@ -0,0 +1,80 @@
|
||||
export async function setupPdfAttachments() {
|
||||
// Extract immediately since we're called after documentloaded
|
||||
await extractAndSendAttachments();
|
||||
|
||||
// Listen for download requests
|
||||
window.addEventListener("message", async (event) => {
|
||||
// Only accept messages from the same origin to prevent malicious iframes
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
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();
|
||||
|
||||
if (!attachments) {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-attachments",
|
||||
attachments: []
|
||||
}, window.location.origin);
|
||||
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
|
||||
}))
|
||||
}, window.location.origin);
|
||||
} catch (error) {
|
||||
console.error("Error extracting attachments:", error);
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-attachments",
|
||||
attachments: []
|
||||
}, window.location.origin);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,16 @@
|
||||
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() {
|
||||
interceptPersistence(getCustomAppOptions());
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("sidebar") === "0") {
|
||||
hideSidebar();
|
||||
}
|
||||
|
||||
interceptPersistence(getCustomAppOptions(urlParams));
|
||||
|
||||
// Wait for the PDF viewer application to be available.
|
||||
while (!window.PDFViewerApplication) {
|
||||
@ -11,13 +20,29 @@ async function main() {
|
||||
|
||||
app.eventBus.on("documentloaded", () => {
|
||||
manageSave();
|
||||
extractAndSendToc();
|
||||
setupScrollToHeading();
|
||||
setupActiveHeadingTracking();
|
||||
setupPdfPages();
|
||||
setupPdfAttachments();
|
||||
setupPdfLayers();
|
||||
});
|
||||
await app.initializedPromise;
|
||||
};
|
||||
|
||||
function getCustomAppOptions() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
function hideSidebar() {
|
||||
window.TRILIUM_HIDE_SIDEBAR = true;
|
||||
const toggleButtonEl = document.getElementById("viewsManagerToggleButton");
|
||||
if (toggleButtonEl) {
|
||||
const spacer = toggleButtonEl.nextElementSibling.nextElementSibling;
|
||||
if (spacer instanceof HTMLElement && spacer.classList.contains("toolbarButtonSpacer")) {
|
||||
spacer.remove();
|
||||
}
|
||||
toggleButtonEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomAppOptions(urlParams: URLSearchParams) {
|
||||
return {
|
||||
localeProperties: {
|
||||
// Read from URL query
|
||||
|
||||
119
packages/pdfjs-viewer/src/layers.ts
Normal file
119
packages/pdfjs-viewer/src/layers.ts
Normal file
@ -0,0 +1,119 @@
|
||||
export async function setupPdfLayers() {
|
||||
// Extract immediately since we're called after documentloaded
|
||||
await extractAndSendLayers();
|
||||
|
||||
// Listen for layer visibility toggle requests
|
||||
window.addEventListener("message", async (event) => {
|
||||
// Only accept messages from the same origin to prevent malicious iframes
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
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: []
|
||||
}, window.location.origin);
|
||||
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: []
|
||||
}, window.location.origin);
|
||||
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
|
||||
}, window.location.origin);
|
||||
} catch (error) {
|
||||
console.error("Error extracting layers:", error);
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-layers",
|
||||
layers: []
|
||||
}, window.location.origin);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
82
packages/pdfjs-viewer/src/pages.ts
Normal file
82
packages/pdfjs-viewer/src/pages.ts
Normal file
@ -0,0 +1,82 @@
|
||||
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
|
||||
}, window.location.origin);
|
||||
});
|
||||
|
||||
window.addEventListener("message", async(event) => {
|
||||
// Only accept messages from the same origin to prevent malicious iframes
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data?.type === "trilium-scroll-to-page") {
|
||||
const pageNumber = event.data.pageNumber;
|
||||
app.pdfViewer.currentPageNumber = pageNumber;
|
||||
}
|
||||
|
||||
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
|
||||
}, window.location.origin);
|
||||
}
|
||||
|
||||
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({
|
||||
canvas: canvas,
|
||||
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
|
||||
}, window.location.origin);
|
||||
} catch (error) {
|
||||
console.error(`Error generating thumbnail for page %d:`, pageNumber, error);
|
||||
}
|
||||
}
|
||||
189
packages/pdfjs-viewer/src/toc.ts
Normal file
189
packages/pdfjs-viewer/src/toc.ts
Normal file
@ -0,0 +1,189 @@
|
||||
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
|
||||
}, window.location.origin);
|
||||
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
|
||||
}, window.location.origin);
|
||||
} catch (error) {
|
||||
window.parent.postMessage({
|
||||
type: "pdfjs-viewer-toc",
|
||||
data: null
|
||||
}, window.location.origin);
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Only accept messages from the same origin to prevent malicious iframes
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
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 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
|
||||
}, window.location.origin);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
22
packages/pdfjs-viewer/src/typings.d.ts
vendored
22
packages/pdfjs-viewer/src/typings.d.ts
vendored
@ -14,10 +14,32 @@ declare global {
|
||||
_readFromStorage: () => Promise<string>;
|
||||
}
|
||||
|
||||
interface PdfJsDestination {
|
||||
|
||||
}
|
||||
|
||||
interface Window {
|
||||
PDFViewerApplication?: {
|
||||
initializedPromise: Promise<void>;
|
||||
pdfDocument: PDFDocumentProxy;
|
||||
pdfViewer: {
|
||||
currentPageNumber: number;
|
||||
optionalContentConfigPromise: {
|
||||
setVisibility(groupId: string, visible: boolean);
|
||||
getGroup(groupId: string): {
|
||||
name: string;
|
||||
usage: {};
|
||||
};
|
||||
getOrder(): {}[]
|
||||
};
|
||||
getPageView(pageIndex: number): {
|
||||
div: HTMLDivElement;
|
||||
};
|
||||
container: HTMLElement;
|
||||
};
|
||||
pdfLinkService: {
|
||||
goToDestination(dest: PdfJsDestination);
|
||||
};
|
||||
eventBus: {
|
||||
on(event: string, listener: (...args: any[]) => void): void;
|
||||
dispatch(event: string, data?: any): void;
|
||||
|
||||
@ -18609,7 +18609,7 @@ function getViewerConfiguration() {
|
||||
imageAltTextSettingsSeparator: document.getElementById("imageAltTextSettingsSeparator"),
|
||||
documentPropertiesButton: document.getElementById("documentProperties")
|
||||
},
|
||||
viewsManager: {
|
||||
viewsManager: window.TRILIUM_HIDE_SIDEBAR ? null : {
|
||||
outerContainer: document.getElementById("outerContainer"),
|
||||
toggleButton: document.getElementById("viewsManagerToggleButton"),
|
||||
sidebarContainer: document.getElementById("viewsManager"),
|
||||
|
||||
@ -68,6 +68,14 @@ export default class BuildHelper {
|
||||
writeFileSync(join(this.outDir, "meta.json"), JSON.stringify(result.metafile));
|
||||
}
|
||||
|
||||
buildFrontend() {
|
||||
this.triggerBuildAndCopyTo("apps/client", "public/");
|
||||
this.deleteFromOutput("public/webpack-stats.json");
|
||||
|
||||
// pdf.js
|
||||
this.triggerBuildAndCopyTo("packages/pdfjs-viewer", "pdfjs-viewer");
|
||||
}
|
||||
|
||||
triggerBuildAndCopyTo(projectToBuild: string, destPath: string) {
|
||||
const projectDir = join(this.rootDir, projectToBuild);
|
||||
execSync("pnpm build", { cwd: projectDir, stdio: "inherit" });
|
||||
|
||||
13
vitest.config.ts
Normal file
13
vitest.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
"packages/*/vitest.config.ts",
|
||||
"packages/*/vite.config.ts",
|
||||
"apps/*/vitest.config.ts",
|
||||
"apps/*/vite.config.ts",
|
||||
"apps/*/vite.config.mts",
|
||||
],
|
||||
},
|
||||
})
|
||||
@ -1 +0,0 @@
|
||||
export default ['**/vite.config.{mjs,js,ts,mts}', '**/vitest.config.{mjs,js,ts,mts}'];
|
||||
Loading…
x
Reference in New Issue
Block a user