Files
chatpapers/src/modules/ui/itemPaneSection.ts
T
2026-09-01 08:38:36 +08:00

286 lines
8.8 KiB
TypeScript

/** 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<ItemPaneKind, keyof _ZoteroTypes.Prefs["PluginPrefsMap"]> =
{
chat: "chatPaneAdaptiveHeight",
lecture: "lecturePaneAdaptiveHeight",
};
const FIXED_HEIGHT: Record<ItemPaneKind, number> = {
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<ItemPaneKind, FluentMessageId> = {
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)
);
}