diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index aba2920d89..fd84b7ed85 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -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: { diff --git a/apps/client/src/components/component.ts b/apps/client/src/components/component.ts index 56c9e7b798..1538aff76d 100644 --- a/apps/client/src/components/component.ts +++ b/apps/client/src/components/component.ts @@ -57,6 +57,18 @@ export class TypedComponent> { 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(name: T, data: EventData): Promise | 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); diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 5295007a7d..7672061678 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -12,6 +12,7 @@ import server from "../services/server.js"; import treeService from "../services/tree.js"; import utils from "../services/utils.js"; import { ReactWrappedWidget } from "../widgets/basic_widget.js"; +import type { HeadingContext } from "../widgets/sidebar/TableOfContents.js"; import appContext, { type EventData, type EventListener } from "./app_context.js"; import Component from "./component.js"; @@ -22,6 +23,26 @@ export interface SetNoteOpts { export type GetTextEditorCallback = (editor: CKTextEditor) => void; +export interface NoteContextDataMap { + toc: HeadingContext; + pdfPages: { + totalPages: number; + currentPage: number; + scrollToPage(page: number): void; + requestThumbnail(page: number): void; + }; + pdfAttachments: { + attachments: Array<{ filename: string; size: number }>; + downloadAttachment(filename: string): void; + }; + pdfLayers: { + layers: Array<{ id: string; name: string; visible: boolean }>; + toggleLayer(layerId: string, visible: boolean): void; + }; +} + +type ContextDataKey = keyof NoteContextDataMap; + class NoteContext extends Component implements EventListener<"entitiesReloaded"> { ntxId: string | null; hoistedNoteId: string; @@ -32,6 +53,13 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> parentNoteId?: string | null; viewScope?: ViewScope; + /** + * Metadata storage for UI components (e.g., table of contents, PDF page list, code outline). + * This allows type widgets to publish data that sidebar/toolbar components can consume. + * Data is automatically cleared when navigating to a different note. + */ + private contextData: Map = new Map(); + constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) { super(); @@ -91,6 +119,22 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> this.viewScope = opts.viewScope; ({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath)); + // Clear context data when switching notes and notify subscribers + const oldKeys = Array.from(this.contextData.keys()); + this.contextData.clear(); + if (oldKeys.length > 0) { + // Notify subscribers asynchronously to avoid blocking navigation + window.setTimeout(() => { + for (const key of oldKeys) { + this.triggerEvent("contextDataChanged", { + noteContext: this, + key, + value: undefined + }); + } + }, 0); + } + this.saveToRecentNotes(resolvedNotePath); protectedSessionHolder.touchProtectedSessionIfNecessary(this.note); @@ -443,6 +487,52 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return title; } + + /** + * Set metadata for this note context (e.g., table of contents, PDF pages, code outline). + * This data can be consumed by sidebar/toolbar components. + * + * @param key - Unique identifier for the data type (e.g., "toc", "pdfPages", "codeOutline") + * @param value - The data to store (will be cleared when switching notes) + */ + setContextData(key: K, value: NoteContextDataMap[K]): void { + this.contextData.set(key, value); + // Trigger event so subscribers can react + this.triggerEvent("contextDataChanged", { + noteContext: this, + key, + value + }); + } + + /** + * Get metadata for this note context. + * + * @param key - The data key to retrieve + * @returns The stored data, or undefined if not found + */ + getContextData(key: K): NoteContextDataMap[K] | undefined { + return this.contextData.get(key) as NoteContextDataMap[K] | undefined; + } + + /** + * Check if context data exists for a given key. + */ + hasContextData(key: ContextDataKey): boolean { + return this.contextData.has(key); + } + + /** + * Clear specific context data. + */ + clearContextData(key: ContextDataKey): void { + this.contextData.delete(key); + this.triggerEvent("contextDataChanged", { + noteContext: this, + key, + value: undefined + }); + } } export function openInCurrentNoteContext(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent | null, notePath: string, viewScope?: ViewScope) { diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 4fcdbf806d..048d175db8 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -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"; diff --git a/apps/client/src/services/css_class_manager.spec.ts b/apps/client/src/services/css_class_manager.spec.ts new file mode 100644 index 0000000000..b49e8c0ab2 --- /dev/null +++ b/apps/client/src/services/css_class_manager.spec.ts @@ -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")); + }); +}); diff --git a/apps/client/src/services/css_class_manager.ts b/apps/client/src/services/css_class_manager.ts index 5aa73e32bd..de1c98b87d 100644 --- a/apps/client/src/services/css_class_manager.ts +++ b/apps/client/src/services/css_class_manager.ts @@ -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(); const colorsWithHue = new Set(); // 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(`