286 lines
8.8 KiB
TypeScript
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)
|
|
);
|
|
}
|