/** Helpers for Zotero ItemPane collapsible sections. */ import { config } from "../../../package.json"; import { getLocaleID, getString } from "../../utils/locale"; import type { FluentMessageId } from "../../../typings/i10n"; import { getPref, setPref } from "../../utils/prefs"; import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow"; export type ItemPaneKind = "chat" | "lecture"; const ADAPTIVE_PREF: Record = { chat: "chatPaneAdaptiveHeight", lecture: "lecturePaneAdaptiveHeight", }; const FIXED_HEIGHT: Record = { chat: 360, lecture: 420, }; /** Lucide-derived toolbar icons (see addon/content/icons/pane-*.svg). */ function paneIconURL(file: string): string { return `chrome://${config.addonRef}/content/icons/${file}`; } const OPEN_WINDOW_ICON = paneIconURL("pane-open-window.svg"); const ADAPTIVE_ICON = paneIconURL("pane-adaptive-height.svg"); const ADAPTIVE_ICON_OFF = paneIconURL("pane-adaptive-height-fixed.svg"); export function prepareItemPaneBody(body: HTMLElement): void { body.classList.add("chatpapers-pane-body"); body.style.minHeight = "0"; } export function isAdaptiveHeight(kind: ItemPaneKind): boolean { const val = getPref(ADAPTIVE_PREF[kind]); return val !== false; } function sectionButtonTooltip(messageId: FluentMessageId): string { const fromBranch = getString(messageId, { branch: "tooltiptext" }); const l10nId = getLocaleID(messageId); if (fromBranch && fromBranch !== l10nId) return fromBranch; return getString(messageId); } function applySectionButtonTooltip( btn: HTMLElement, messageId: FluentMessageId, ): void { const l10nId = getLocaleID(messageId); const tooltip = sectionButtonTooltip(messageId); btn.setAttribute("data-l10n-id", l10nId); btn.setAttribute("title", tooltip); btn.setAttribute("tooltiptext", tooltip); try { btn.ownerDocument?.l10n?.setAttributes?.(btn, l10nId); } catch { // ignore if Fluent is not ready on this document yet } } function styleSectionButtonIcon(btn: HTMLElement, icon: string): void { const url = `url('${icon}')`; btn.style.setProperty("--custom-button-icon-light", url); btn.style.setProperty("--custom-button-icon-dark", url); btn.style.setProperty("list-style-image", url); btn.style.setProperty("-moz-context-properties", "fill, fill-opacity"); btn.style.setProperty("fill", "currentColor"); } function findSectionButton( section: HTMLElement | null, type: string, ): HTMLElement | null { if (!section) return null; return (section.querySelector(`.${type}.section-custom-button`) ?? section.querySelector(`.${type}`)) as HTMLElement | null; } function applyOpenInWindowButtonIcon(body: HTMLElement): boolean { const section = resolveCollapsibleSection(body); const btn = findSectionButton(section, "open-in-window"); if (!btn) return false; styleSectionButtonIcon(btn, OPEN_WINDOW_ICON); applySectionButtonTooltip(btn, "item-pane-open-window"); return true; } export function updateAdaptiveButtonState( body: HTMLElement, adaptive: boolean, ): boolean { const section = resolveCollapsibleSection(body); const btn = findSectionButton(section, "adaptive-height"); if (!btn) return false; btn.setAttribute("aria-pressed", adaptive ? "true" : "false"); btn.classList.toggle("chatpapers-adaptive-active", adaptive); styleSectionButtonIcon(btn, adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF); applySectionButtonTooltip( btn, adaptive ? "item-pane-adaptive-height-on" : "item-pane-adaptive-height-off", ); return true; } export function applyItemPaneSectionButtonIcons( body: HTMLElement, adaptive = isAdaptiveHeight( (body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind, ), attempt = 0, ): void { const openReady = applyOpenInWindowButtonIcon(body); const adaptiveReady = updateAdaptiveButtonState(body, adaptive); if ((openReady && adaptiveReady) || attempt >= 8) return; const win = body.ownerDocument?.defaultView; if (!win) return; win.requestAnimationFrame(() => { applyItemPaneSectionButtonIcons(body, adaptive, attempt + 1); }); } export function applyAdaptiveHeight( body: HTMLElement, kind: ItemPaneKind, adaptive = isAdaptiveHeight(kind), ): void { body.classList.toggle("chatpapers-pane-adaptive", adaptive); body.classList.toggle("chatpapers-pane-fixed", !adaptive); body.dataset.chatpapersPaneKind = kind; const section = resolveCollapsibleSection(body); section?.classList.toggle("chatpapers-adaptive-height", adaptive); section?.classList.toggle("chatpapers-fixed-height", !adaptive); applyItemPaneSectionButtonIcons(body, adaptive); } export function toggleAdaptiveHeight( body: HTMLElement, kind: ItemPaneKind, ): boolean { const next = !isAdaptiveHeight(kind); setPref(ADAPTIVE_PREF[kind], next); applyAdaptiveHeight(body, kind, next); refreshSectionOpenHeight(body); return next; } export function refreshSectionOpenHeight(body: HTMLElement): void { const section = resolveCollapsibleSection(body); if (!section?.hasAttribute("open")) return; if (section.classList.contains("chatpapers-adaptive-height")) { section.style.setProperty("--open-height", "auto"); body.style.removeProperty("max-height"); body.style.removeProperty("overflow"); return; } const kind = (body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind; const maxH = FIXED_HEIGHT[kind] ?? 360; body.style.maxHeight = `${maxH}px`; body.style.overflowY = "auto"; section.style.setProperty("--open-height", `${maxH}px`); } export function onItemPaneSectionToggle(options: { body: HTMLElement; event?: Event; }): void { const section = resolveCollapsibleSection(options.body, options.event); if (!section) return; const open = section.hasAttribute("open"); const adaptive = section.classList.contains("chatpapers-adaptive-height"); if (open) { options.body.style.removeProperty("height"); options.body.style.minHeight = "0"; if (adaptive) { options.body.style.removeProperty("overflow"); options.body.style.removeProperty("max-height"); section.style.setProperty("--open-height", "auto"); return; } options.body.style.overflowY = "auto"; const kind = (options.body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind; const maxH = FIXED_HEIGHT[kind] ?? 360; options.body.style.maxHeight = `${maxH}px`; section.style.setProperty("--open-height", `${maxH}px`); return; } options.body.style.minHeight = "0"; options.body.style.height = "0"; options.body.style.overflow = "hidden"; options.body.style.removeProperty("max-height"); section.style.setProperty("--open-height", "0px"); } export function buildItemPaneSectionButtons(kind: ItemPaneKind) { return [ { type: "open-in-window", icon: OPEN_WINDOW_ICON, l10nID: getLocaleID("item-pane-open-window"), onClick: ({ item }: { item?: Zotero.Item }) => { if (!item) return; if (kind === "chat") openChatPaneWindow(item); else openLecturePaneWindow(item); }, }, { type: "adaptive-height", icon: ADAPTIVE_ICON, l10nID: getLocaleID("item-pane-adaptive-height"), onClick: ({ body }: { body: HTMLElement }) => { toggleAdaptiveHeight(body, kind); }, }, ]; } export function isItemPaneSectionBody(body: HTMLElement): boolean { return Boolean(body.closest("collapsible-section")); } export type SectionSummaryUpdater = (summary: string) => void; export function clearSectionSummary( body: HTMLElement, updater?: SectionSummaryUpdater, ): void { updater?.(""); const section = body.closest("collapsible-section"); if (section) section.setAttribute("summary", ""); } const SECTION_HEAD_MESSAGE: Record = { chat: "item-section-chat-head", lecture: "item-section-lecture-head", }; /** Ensure native section head shows plugin name (not just the icon). */ export function applyItemPaneSectionHeadLabel( body: HTMLElement, kind: ItemPaneKind, ): void { const section = body.closest("collapsible-section") as HTMLElement | null; if (!section) return; const messageId = SECTION_HEAD_MESSAGE[kind]; const l10nId = getLocaleID(messageId); section.dataset.l10nId = l10nId; const label = getString(messageId, "label"); if (label && label !== l10nId) { section.setAttribute("label", label); return; } try { body.ownerDocument?.l10n?.setAttributes?.(section, l10nId); } catch { // Fluent may not be ready yet; label stays empty until next render hook. } } function resolveCollapsibleSection( body: HTMLElement, event?: Event, ): HTMLElement | null { const fromEvent = (event?.currentTarget ?? event?.target) as | HTMLElement | undefined; return ( (fromEvent?.closest?.("collapsible-section") as HTMLElement | null) ?? (body.closest("collapsible-section") as HTMLElement | null) ); }