增加pdf中划句或段ai伴读功能

This commit is contained in:
yhy
2026-08-29 23:07:19 +08:00
parent 1475f8b3c5
commit 0ed8f42029
22 changed files with 2045 additions and 15 deletions
+2
View File
@@ -1,6 +1,7 @@
import { initLocale } from "./utils/locale";
import { createZToolkit } from "./utils/ztoolkit";
import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane";
import { registerSelectionExplainPopup } from "./modules/lecture/ui/registerSelectionExplainPopup";
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
import { registerPrefsScripts } from "./modules/ui/prefs";
import { registerItemMenus } from "./modules/ui/itemMenu";
@@ -18,6 +19,7 @@ async function onStartup() {
registerChatPane();
registerLecturePane();
registerReaderSelectionHook();
registerSelectionExplainPopup();
await Promise.all(
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
@@ -0,0 +1,246 @@
import type { SummaryJson } from "../domain/types";
import { isPrimarilyEnglish } from "../infrastructure/llm/paragraphLlm";
import { generateSentenceExplanation } from "../infrastructure/llm/sentenceLlm";
import { ensureAudioDir } from "../infrastructure/storage/audioStorage";
import {
loadLectureData,
mergeLectureData,
type AdHocSelectionExplain,
type StoredParagraph,
} from "../infrastructure/storage/lectureStore";
import { audioFilePath } from "../infrastructure/storage/paths";
import { synthesizeSpeech } from "../infrastructure/tts/client";
import { TtsError } from "../infrastructure/tts/types";
import {
getRegularItem,
resolvePaperAttachment,
} from "../infrastructure/zotero/adapter";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import type { TextChunk } from "../infrastructure/pdf/chunker";
import {
findMatchingPreparedSentence,
normalizeSelectionText,
} from "./matchPreparedSentence";
const TTS_ZH_MAX = 280;
const TTS_EN_MAX = 600;
export interface SelectionExplainResult {
original: string;
translation: string;
syntaxNote: string;
contextRole: string;
audioPathEn?: string;
audioPathZh: string;
page?: number;
fromCache: boolean;
fromPrepared: boolean;
matched?: {
paragraphId: string;
sentenceId: string;
};
}
function selectionUnitId(text: string): string {
const norm = normalizeSelectionText(text);
let hash = 0;
for (let i = 0; i < norm.length; i++) {
hash = (hash * 31 + norm.charCodeAt(i)) | 0;
}
return `adhoc-${Math.abs(hash).toString(36)}`;
}
function findSurroundingContext(
chunks: TextChunk[] | undefined,
selectedText: string,
): string {
if (!chunks?.length || !selectedText.trim()) return "";
const needle = normalizeSelectionText(selectedText).slice(0, 48);
if (!needle) return "";
for (const chunk of chunks) {
const hay = normalizeSelectionText(chunk.text);
const idx = hay.indexOf(needle);
if (idx < 0) continue;
const start = Math.max(0, idx - 220);
const end = Math.min(chunk.text.length, idx + selectedText.length + 220);
return chunk.text.slice(start, end);
}
return "";
}
function findAdHocCache(
entries: AdHocSelectionExplain[] | undefined,
selectedText: string,
): AdHocSelectionExplain | undefined {
if (!entries?.length) return undefined;
const norm = normalizeSelectionText(selectedText);
return entries.find(
(entry) => normalizeSelectionText(entry.original) === norm && entry.audioPathZh,
);
}
function preparedToResult(
match: NonNullable<ReturnType<typeof findMatchingPreparedSentence>>,
): SelectionExplainResult {
const { paragraph, sentence } = match;
return {
original: sentence.text,
translation: sentence.translation,
syntaxNote: "",
contextRole: paragraph.roleInPaper || paragraph.contextLink || "",
audioPathEn:
sentence.ttsEnStatus === "ready" ? sentence.audioPathEn : undefined,
audioPathZh: sentence.audioPathZh!,
page: sentence.page ?? paragraph.page,
fromCache: true,
fromPrepared: true,
matched: {
paragraphId: paragraph.id,
sentenceId: sentence.id,
},
};
}
function adHocToResult(entry: AdHocSelectionExplain): SelectionExplainResult {
return {
original: entry.original,
translation: entry.translation,
syntaxNote: entry.syntaxNote,
contextRole: entry.contextRole,
audioPathEn: entry.audioPathEn,
audioPathZh: entry.audioPathZh!,
page: entry.page,
fromCache: true,
fromPrepared: false,
};
}
async function synthesizeSelectionAudio(options: {
cacheKey: string;
unitId: string;
original: string;
translation: string;
}): Promise<{ audioPathEn?: string; audioPathZh: string }> {
await ensureAudioDir(options.cacheKey);
const en = isPrimarilyEnglish(options.original);
let audioPathEn: string | undefined;
if (en) {
const outPath = audioFilePath(options.cacheKey, `${options.unitId}-en`, "wav");
const result = await synthesizeSpeech({
text: options.original.slice(0, TTS_EN_MAX),
outputPath: outPath,
lang: "en",
});
audioPathEn = result.audioPath;
}
const zhPath = audioFilePath(options.cacheKey, `${options.unitId}-zh`, "wav");
const zhResult = await synthesizeSpeech({
text: options.translation.slice(0, TTS_ZH_MAX),
outputPath: zhPath,
lang: "zh",
});
return { audioPathEn, audioPathZh: zhResult.audioPath };
}
export async function explainSelectionLecture(options: {
item: Zotero.Item;
selectedText: string;
onProgress?: (message: string) => void;
}): Promise<SelectionExplainResult> {
const selectedText = options.selectedText.trim();
if (!selectedText) {
throw new Error("请先选中 PDF 中的文本。");
}
const ctx = await resolvePaperAttachment(options.item);
const data = await loadLectureData(ctx.cacheKey);
const paragraphs = data?.paragraphs;
const prepared = findMatchingPreparedSentence(paragraphs, selectedText);
if (prepared?.sentence.audioPathZh) {
return preparedToResult(prepared);
}
const cached = findAdHocCache(data?.adHocSelections, selectedText);
if (cached?.audioPathZh) {
return adHocToResult(cached);
}
options.onProgress?.("正在生成单句讲解…");
const parent = getRegularItem(options.item);
const title = String(parent.getField("title") || "");
const summary = data?.summary as SummaryJson | undefined;
const surroundingContext = findSurroundingContext(data?.chunks, selectedText);
const llm = await generateSentenceExplanation({
paperTitle: title,
summary,
selectedText,
surroundingContext,
});
options.onProgress?.("正在合成语音…");
const unitId = selectionUnitId(selectedText);
let audioPathEn: string | undefined;
let audioPathZh: string;
try {
const audio = await synthesizeSelectionAudio({
cacheKey: ctx.cacheKey,
unitId,
original: selectedText,
translation: llm.translation,
});
audioPathEn = audio.audioPathEn;
audioPathZh = audio.audioPathZh;
} catch (e) {
if (e instanceof TtsError) throw e;
throw new Error(e instanceof Error ? e.message : String(e));
}
const entry: AdHocSelectionExplain = {
id: unitId,
original: selectedText,
translation: llm.translation,
syntaxNote: llm.syntaxNote,
contextRole: llm.contextRole,
audioPathEn,
audioPathZh,
createdAt: Date.now(),
};
const existing = data?.adHocSelections ?? [];
const nextEntries = [
entry,
...existing.filter(
(e) => normalizeSelectionText(e.original) !== normalizeSelectionText(selectedText),
),
].slice(0, 40);
await mergeLectureData(ctx.cacheKey, paperIdFromCacheKey(ctx.cacheKey), {
adHocSelections: nextEntries,
});
return {
original: selectedText,
translation: llm.translation,
syntaxNote: llm.syntaxNote,
contextRole: llm.contextRole,
audioPathEn,
audioPathZh,
fromCache: false,
fromPrepared: false,
};
}
export function findPreparedMatchForSelection(
paragraphs: StoredParagraph[] | undefined,
selectedText: string,
) {
return findMatchingPreparedSentence(paragraphs, selectedText);
}
@@ -0,0 +1,87 @@
import type {
StoredParagraph,
StoredSentence,
} from "../infrastructure/storage/lectureStore";
export function normalizeSelectionText(text: string): string {
return text
.replace(/[\u0000-\u001f\f]/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
export interface PreparedSentenceMatch {
paragraph: StoredParagraph;
sentence: StoredSentence;
}
function overlapScore(a: string, b: string): number {
const na = normalizeSelectionText(a);
const nb = normalizeSelectionText(b);
if (!na || !nb) return 0;
if (na === nb) return 100;
if (na.includes(nb) || nb.includes(na)) {
const shorter = Math.min(na.length, nb.length);
const longer = Math.max(na.length, nb.length);
return Math.round((shorter / longer) * 90);
}
const aWords = new Set(na.split(/\s+/).filter((w) => w.length > 2));
const bWords = nb.split(/\s+/).filter((w) => w.length > 2);
if (!aWords.size || !bWords.length) return 0;
let shared = 0;
for (const w of bWords) {
if (aWords.has(w)) shared += 1;
}
return Math.round((shared / bWords.length) * 70);
}
export function findMatchingPreparedSentence(
paragraphs: StoredParagraph[] | undefined,
selectedText: string,
options?: { requireTts?: boolean },
): PreparedSentenceMatch | undefined {
if (!paragraphs?.length || !selectedText.trim()) return undefined;
const requireTts = options?.requireTts !== false;
let best: PreparedSentenceMatch | undefined;
let bestScore = 0;
for (const paragraph of paragraphs) {
if (paragraph.genStatus !== "ready") continue;
for (const sentence of paragraph.sentences ?? []) {
if (requireTts && sentence.ttsZhStatus !== "ready") continue;
const score = overlapScore(selectedText, sentence.text);
if (score > bestScore) {
bestScore = score;
best = { paragraph, sentence };
}
}
if (!paragraph.sentences?.length) {
const score = overlapScore(selectedText, paragraph.originalText);
if (score > bestScore && (!requireTts || paragraph.ttsZhStatus === "ready")) {
bestScore = score;
best = {
paragraph,
sentence: {
id: `${paragraph.id}-whole`,
orderIndex: 0,
text: paragraph.originalText,
translation: paragraph.translation,
page: paragraph.page,
searchText: paragraph.searchText,
audioPathEn: paragraph.audioPathEn,
audioPathZh: paragraph.audioPathZh,
ttsEnStatus: paragraph.ttsEnStatus,
ttsZhStatus: paragraph.ttsZhStatus,
},
};
}
}
}
return bestScore >= 55 ? best : undefined;
}
@@ -0,0 +1,65 @@
import { pathToFileUrl } from "../infrastructure/platform/paths";
import type { SelectionExplainResult } from "./explainSelectionLecture";
function waitAudioEnded(audio: HTMLAudioElement): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
audio.onended = null;
audio.onerror = null;
};
audio.onended = () => {
cleanup();
resolve();
};
audio.onerror = () => {
cleanup();
reject(new Error("Audio playback failed"));
};
});
}
function audioPaths(result: SelectionExplainResult): string[] {
const paths: string[] = [];
if (result.audioPathEn) paths.push(result.audioPathEn);
if (result.audioPathZh) paths.push(result.audioPathZh);
return paths;
}
export async function playSelectionExplainAudio(
audio: HTMLAudioElement,
result: SelectionExplainResult,
): Promise<void> {
const paths = audioPaths(result);
if (!paths.length) return;
audio.pause();
for (const path of paths) {
audio.src = pathToFileUrl(path);
audio.load();
await audio.play();
await waitAudioEnded(audio);
}
}
let standaloneAudio: HTMLAudioElement | undefined;
function getStandaloneAudio(): HTMLAudioElement {
if (standaloneAudio) return standaloneAudio;
const win = Zotero.getMainWindow?.() as Window | undefined;
const doc = win?.document ?? document;
standaloneAudio = doc.createElement("audio");
standaloneAudio.preload = "none";
standaloneAudio.style.display = "none";
doc.body?.appendChild(standaloneAudio);
return standaloneAudio;
}
export async function playSelectionExplainStandalone(
result: SelectionExplainResult,
): Promise<void> {
await playSelectionExplainAudio(getStandaloneAudio(), result);
}
export function stopSelectionExplainStandalone(): void {
standaloneAudio?.pause();
}
@@ -9,6 +9,12 @@ export type LectureEventMap = {
lectureStatus: string;
message?: string;
};
"lecture:selection_explain": {
paperId: string;
cacheKey: string;
itemId: string;
result: import("../application/explainSelectionLecture").SelectionExplainResult;
};
};
type Handler<K extends keyof LectureEventMap> = (
@@ -0,0 +1,79 @@
import { parseJsonLoose } from "../../../llm/jsonParse";
import { LlmError } from "../../../llm/types";
import type { SummaryJson } from "../../domain/types";
import { requestStructuredOutput } from "./structuredOutput";
const SENTENCE_SYSTEM = `你是 ChatPapers 论文伴读助手。用户选中论文中的一句原文,需要你解释并用于语音朗读。
必须只输出一个 JSON 对象,不要 markdown 代码块。
格式:{"translation":"...","syntaxNote":"...","contextRole":"..."}
要求:
- translation:准确中文翻译,并用 30–80 字口语解释该句含义(不要念 LaTeX,公式转述为口语)。
- syntaxNote:1–2 句,拆解句法结构或关键术语(英文句为主;中文句可简述逻辑)。
- contextRole:1 句,说明该句在段落或全文论证中的作用。
- 术语翻译与全文保持一致。`;
export interface SentenceLlmResult {
translation: string;
syntaxNote: string;
contextRole: string;
}
function parseSentenceJson(raw: string): SentenceLlmResult {
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
const translation = String(parsed.translation || parsed.zh || "").trim();
if (!translation) {
throw new LlmError("provider", "单句讲解 JSON 缺少 translation 字段。");
}
return {
translation,
syntaxNote: String(parsed.syntaxNote || parsed.syntax || "").trim(),
contextRole: String(parsed.contextRole || parsed.role || parsed.context || "").trim(),
};
}
function fallbackSentence(selectedText: string): SentenceLlmResult {
return {
translation: selectedText.slice(0, 200),
syntaxNote: "",
contextRole: "(模型未能生成上下文说明,请重试或更换模型。)",
};
}
function formatSummaryBlock(summary?: SummaryJson): string {
if (!summary) return "(暂无全文摘要)";
return [
`问题:${summary.problem}`,
`方法:${summary.method}`,
`结果:${summary.result}`,
`局限:${summary.limitation}`,
].join("\n");
}
export async function generateSentenceExplanation(options: {
paperTitle: string;
summary?: SummaryJson;
selectedText: string;
surroundingContext?: string;
}): Promise<SentenceLlmResult> {
const user = `【论文标题】${options.paperTitle}
【全文摘要】
${formatSummaryBlock(options.summary)}
【选区附近上下文(可能为空)】
${options.surroundingContext?.trim() || "(无)"}
【用户选中的句子】
${options.selectedText.trim()}
请输出 JSON。`;
return requestStructuredOutput({
system: SENTENCE_SYSTEM,
user,
parse: parseSentenceJson,
jsonMode: true,
fallback: () => fallbackSentence(options.selectedText),
});
}
@@ -44,6 +44,18 @@ export interface StoredParagraph {
genStatus: "pending" | "ready" | "failed";
}
export interface AdHocSelectionExplain {
id: string;
original: string;
translation: string;
syntaxNote: string;
contextRole: string;
page?: number;
audioPathEn?: string;
audioPathZh?: string;
createdAt: number;
}
export interface LectureData {
paperId: string;
cacheKey: string;
@@ -53,6 +65,7 @@ export interface LectureData {
chunks?: TextChunk[];
beats?: StoredBeat[];
paragraphs?: StoredParagraph[];
adHocSelections?: AdHocSelectionExplain[];
noteItemId?: string;
extractChars?: number;
extractSource?: string;
+60 -2
View File
@@ -1,4 +1,4 @@
import { Headphones, Pause, Play, SkipBack, SkipForward, Sparkles, Volume2 } from "lucide";
import { Headphones, Highlighter, Pause, Play, SkipBack, SkipForward, Sparkles, Volume2 } from "lucide";
import type { LectureStatus, PaperCache } from "../domain/types";
import { prepareLecture } from "../application/prepareLecture";
import {
@@ -42,6 +42,12 @@ import {
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import { chunkById } from "../infrastructure/pdf/chunker";
import { highlightTextInPdf } from "../../pdf/readerSync";
import { getReaderSelectedText } from "../../pdf/selection";
import {
explainSelectionLecture,
type SelectionExplainResult,
} from "../application/explainSelectionLecture";
import { playSelectionExplainAudio } from "../application/playSelectionExplain";
import { getString } from "../../../utils/locale";
import { createLucideIcon } from "../../../utils/icons";
@@ -62,12 +68,15 @@ export class LecturePaneView {
private activeParagraphId?: string;
private activeSentenceId?: string;
private pdfSyncEnabled = true;
private explainingSelection = false;
private lastSelectionResult?: SelectionExplainResult;
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
private statusEl!: HTMLElement;
private statusTextEl!: HTMLElement;
private ttsMetaEl!: HTMLElement;
private actionBtn!: HTMLButtonElement;
private explainSelectionBtn!: HTMLButtonElement;
private ttsTestBtn!: HTMLButtonElement;
private noteBtn!: HTMLButtonElement;
private hintEl!: HTMLElement;
@@ -76,6 +85,7 @@ export class LecturePaneView {
private granularityTabsEl!: HTMLElement;
private beatListEl!: HTMLElement;
private paragraphDetailEl!: HTMLElement;
private selectionExplainEl!: HTMLElement;
private playerTitleEl!: HTMLElement;
private playBtn!: HTMLButtonElement;
private pauseBtn!: HTMLButtonElement;
@@ -154,6 +164,13 @@ export class LecturePaneView {
this.noteBtn.textContent = getString("lecture-save-note");
this.noteBtn.addEventListener("click", () => void this.onSaveNote());
this.explainSelectionBtn = this.makeActionBtn(
Highlighter,
getString("lecture-explain-selection"),
() => void this.onExplainSelection(),
{ preserveSelection: true },
);
this.playerSection = this.el("div", "chatpapers-lecture-player");
this.playerSection.hidden = true;
@@ -175,6 +192,8 @@ export class LecturePaneView {
this.playerTitleEl = playerTitle;
this.beatListEl = this.el("div", "chatpapers-lecture-beat-list");
this.paragraphDetailEl = this.el("div", "chatpapers-lecture-para-detail");
this.selectionExplainEl = this.el("div", "chatpapers-lecture-selection-explain");
this.selectionExplainEl.hidden = true;
const playerControls = this.el("div", "chatpapers-lecture-player-controls");
@@ -203,6 +222,7 @@ export class LecturePaneView {
playerTitle,
this.beatListEl,
this.paragraphDetailEl,
this.selectionExplainEl,
playerControls,
);
@@ -215,7 +235,12 @@ export class LecturePaneView {
this.updatePlatformHint();
const actions = this.el("div", "chatpapers-lecture-actions");
actions.append(this.actionBtn, this.noteBtn, this.ttsTestBtn);
actions.append(
this.actionBtn,
this.explainSelectionBtn,
this.noteBtn,
this.ttsTestBtn,
);
this.body.append(
header,
@@ -277,9 +302,42 @@ export class LecturePaneView {
if (!this.shouldHandleEvent(d.paperId)) return;
void this.refreshPlayer();
}),
lectureEvents.on("lecture:selection_explain", (d) => {
if (String(getRegularItem(this.item).id) !== d.itemId) return;
void this.applySelectionExplainResult(d.result);
}),
);
}
private makeActionBtn(
icon: typeof Play,
label: string,
onClick: () => void,
options?: { preserveSelection?: boolean },
): HTMLButtonElement {
const btn = this.doc.createElement("button");
btn.type = "button";
btn.className = "chatpapers-lecture-secondary";
btn.title = label;
btn.append(
createLucideIcon(this.doc, icon, {
size: 14,
className: "chatpapers-lecture-btn-icon",
}),
this.doc.createTextNode(label),
);
if (options?.preserveSelection) {
btn.addEventListener("mousedown", (ev) => {
if ((ev as MouseEvent).button !== 0) return;
ev.preventDefault();
onClick();
});
} else {
btn.addEventListener("click", onClick);
}
return btn;
}
private makeModeTab(
mode: "overview" | "paragraph",
label: string,
@@ -3,8 +3,11 @@ import { getLocaleID, getString } from "../../../utils/locale";
import { findPdfAttachment } from "../../pdf/extractor";
import { ensureChatPapersStyles } from "../../ui/readerPane";
import {
applyAdaptiveHeight,
buildItemPaneSectionButtons,
onItemPaneSectionToggle,
prepareItemPaneBody,
refreshSectionOpenHeight,
} from "../../ui/itemPaneSection";
import { LecturePaneView } from "./lecturePane";
@@ -26,10 +29,12 @@ export function registerLecturePane() {
l10nID: getLocaleID("item-section-lecture-sidenav"),
icon: iconURL("lecture.svg"),
},
sectionButtons: buildItemPaneSectionButtons("lecture"),
onInit: ({ body }) => {
const doc = body.ownerDocument;
if (doc) ensureChatPapersStyles(doc);
prepareItemPaneBody(body);
applyAdaptiveHeight(body, "lecture");
},
onDestroy: ({ body }) => {
views.get(body)?.destroy();
@@ -65,8 +70,11 @@ export function registerLecturePane() {
err.textContent = getString("lecture-mount-error");
body.append(err);
}
applyAdaptiveHeight(body, "lecture");
const section = body.closest("collapsible-section");
if (section && !section.hasAttribute("open")) {
if (section?.hasAttribute("open")) {
refreshSectionOpenHeight(body);
} else if (section) {
onItemPaneSectionToggle({ body });
}
},
@@ -0,0 +1,114 @@
import { config } from "../../../../package.json";
import { getString } from "../../../utils/locale";
import { cacheReaderSelection } from "../../pdf/selection";
import { highlightTextInPdf } from "../../pdf/readerSync";
import { getRegularItem, resolvePaperAttachment } from "../infrastructure/zotero/adapter";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import { explainSelectionLecture } from "../application/explainSelectionLecture";
import { playSelectionExplainStandalone } from "../application/playSelectionExplain";
import { lectureEvents } from "../infrastructure/events";
function tryString(value: unknown): string {
if (typeof value === "string") return value.trim();
if (value == null) return "";
return String(value).trim();
}
function itemFromReader(reader: any): Zotero.Item | undefined {
const attachmentId = reader?.itemID ?? reader?._itemID;
if (!attachmentId) return undefined;
const attachment = Zotero.Items.get(attachmentId);
if (!attachment) return undefined;
return getRegularItem(attachment);
}
function showHeadline(text: string): void {
try {
const pw = new Zotero.ProgressWindow({ closeOnClick: true });
pw.changeHeadline(text);
pw.show();
pw.startCloseTimer(4000);
} catch {
// ignore
}
}
export async function runSelectionExplainFromReader(
reader: any,
selectedText: string,
): Promise<void> {
const text = tryString(selectedText);
if (!text) return;
const item = itemFromReader(reader);
if (!item) {
showHeadline(getString("lecture-selection-no-item"));
return;
}
cacheReaderSelection(text);
showHeadline(getString("lecture-selection-explaining"));
try {
const ctx = await resolvePaperAttachment(item);
const result = await explainSelectionLecture({
item,
selectedText: text,
onProgress: (message) => showHeadline(message),
});
await highlightTextInPdf(item, {
text: result.original,
page: result.page,
});
lectureEvents.emit("lecture:selection_explain", {
paperId: paperIdFromCacheKey(ctx.cacheKey),
cacheKey: ctx.cacheKey,
itemId: String(getRegularItem(item).id),
result,
});
await playSelectionExplainStandalone(result);
showHeadline(getString("lecture-selection-done"));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
ztoolkit.log("[ChatPapers:Lecture] selection explain failed", e);
showHeadline(msg);
}
}
export function registerSelectionExplainPopup(): void {
try {
Zotero.Reader.registerEventListener(
"renderTextSelectionPopup",
(event: any) => {
try {
const { doc, params, append, reader } = event;
const text =
tryString(params?.annotation?.text) || tryString(params?.text);
if (!text) return;
cacheReaderSelection(text);
const btn = doc.createElement("button");
btn.type = "button";
btn.className =
"toolbar-button wide-button chatpapers-selection-explain-btn";
btn.textContent = getString("lecture-explain-selection-short");
btn.title = getString("lecture-explain-selection");
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
void runSelectionExplainFromReader(reader, text);
});
append(btn);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e);
}
},
config.addonID,
);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] registerSelectionExplainPopup failed", e);
}
}
+133 -10
View File
@@ -1,10 +1,104 @@
/** Helpers for Zotero ItemPane collapsible sections. */
import { getLocaleID, getString } from "../../utils/locale";
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,
};
const OPEN_WINDOW_ICON = "chrome://zotero/skin/16/universal/open.svg";
const ADAPTIVE_ICON = "chrome://zotero/skin/16/universal/expand-all.svg";
const ADAPTIVE_ICON_OFF = "chrome://zotero/skin/16/universal/collapse-all.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;
}
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);
updateAdaptiveButtonState(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 updateAdaptiveButtonState(
body: HTMLElement,
adaptive: boolean,
): void {
const section = resolveCollapsibleSection(body);
const btn = section?.querySelector(
".adaptive-height.section-custom-button",
) as HTMLElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", adaptive ? "true" : "false");
btn.classList.toggle("chatpapers-adaptive-active", adaptive);
btn.style.setProperty(
"--custom-button-icon-light",
`url('${adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF}')`,
);
btn.style.setProperty(
"--custom-button-icon-dark",
`url('${adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF}')`,
);
btn.title = getString(
adaptive ? "item-pane-adaptive-height-on" : "item-pane-adaptive-height-off",
);
}
export function onItemPaneSectionToggle(options: {
body: HTMLElement;
event?: Event;
@@ -13,28 +107,57 @@ export function onItemPaneSectionToggle(options: {
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.removeProperty("overflow");
options.body.style.minHeight = "0";
const raf = options.body.ownerDocument?.defaultView?.requestAnimationFrame;
const updateHeight = () => {
const h = options.body.scrollHeight;
if (h > 0) {
section.style.setProperty("--open-height", `${h}px`);
}
};
if (raf) raf(updateHeight);
else updateHeight();
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);
},
},
];
}
function resolveCollapsibleSection(
body: HTMLElement,
event?: Event,
+227
View File
@@ -0,0 +1,227 @@
import { getString } from "../../utils/locale";
import { ChatView } from "./chatView";
import { LecturePaneView } from "../lecture/ui/lecturePane";
import { ensureChatPapersStyles } from "./readerPane";
type PaneKind = "chat" | "lecture";
interface OpenPaneWindow {
dialog: any;
view: ChatView | LecturePaneView;
onResize?: () => void;
}
const openWindows = new Map<string, OpenPaneWindow>();
function windowKey(kind: PaneKind, itemId: number): string {
return `${kind}:${itemId}`;
}
function ensureDialogStyles(doc: Document): void {
ensureChatPapersStyles(doc);
}
function getPreferredDialogSize(): { width: number; height: number } {
try {
const win = Zotero.getMainWindow?.() as Window | undefined;
const screen = win?.screen;
const aw = screen?.availWidth || win?.outerWidth || 1280;
const ah = screen?.availHeight || win?.outerHeight || 800;
return {
width: Math.max(720, Math.min(Math.round(aw * 0.82), aw - 48)),
height: Math.max(560, Math.min(Math.round(ah * 0.82), ah - 48)),
};
} catch {
return { width: 920, height: 680 };
}
}
function hideDialogButtonBox(doc: Document): void {
for (const sel of [
"#dialog-button-box",
".dialog-button-box",
"dialog > vbox.dialog-button-box",
"[anonid='buttons']",
]) {
for (const node of Array.from(doc.querySelectorAll(sel))) {
(node as HTMLElement).style.display = "none";
}
}
}
function fitPaneRoot(win: Window, rootId: string): void {
const doc = win.document;
hideDialogButtonBox(doc);
const root = doc.getElementById(rootId) as HTMLElement | null;
if (!root) return;
for (const el of [doc.documentElement, doc.body]) {
if (!el) continue;
(el as HTMLElement).style.height = "100%";
(el as HTMLElement).style.margin = "0";
(el as HTMLElement).style.padding = "0";
(el as HTMLElement).style.overflow = "hidden";
(el as HTMLElement).style.display = "flex";
(el as HTMLElement).style.flexDirection = "column";
}
for (const node of Array.from(doc.querySelectorAll("dialog"))) {
(node as HTMLElement).style.padding = "0";
(node as HTMLElement).style.margin = "0";
(node as HTMLElement).style.height = "100%";
}
const h = Math.max(400, (win.innerHeight || 600) - 8);
root.style.width = "100%";
root.style.height = `${h}px`;
root.style.maxHeight = `${h}px`;
root.style.minHeight = "0";
root.style.flex = "1 1 auto";
root.style.overflow = "hidden";
}
function focusExisting(key: string): boolean {
const existing = openWindows.get(key);
const win = existing?.dialog?.window as Window | undefined;
if (!win) return false;
try {
win.focus();
return true;
} catch {
openWindows.delete(key);
return false;
}
}
function closeWindow(key: string): void {
const existing = openWindows.get(key);
if (!existing) return;
try {
if (existing.dialog?.window && existing.onResize) {
existing.dialog.window.removeEventListener("resize", existing.onResize);
}
existing.view.destroy();
existing.dialog?.window?.close?.();
} catch {
// ignore
}
openWindows.delete(key);
}
function openPaneWindow(options: {
kind: PaneKind;
item: Zotero.Item;
rootId: string;
title: string;
mount: (doc: Document, root: HTMLElement) => Promise<ChatView | LecturePaneView>;
}): void {
const key = windowKey(options.kind, options.item.id);
if (focusExisting(key)) return;
closeWindow(key);
const size = getPreferredDialogSize();
let activeView: ChatView | LecturePaneView | null = null;
let onResize: (() => void) | null = null;
const dialogHelper = new ztoolkit.Dialog(1, 1)
.addCell(0, 0, {
tag: "div",
namespace: "html",
id: options.rootId,
styles: {
width: "100%",
height: "100%",
minWidth: "560px",
minHeight: "0",
overflow: "hidden",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
},
})
.setDialogData({
loadCallback: () => {
try {
const win = dialogHelper.window as Window;
const doc = win.document;
ensureDialogStyles(doc);
fitPaneRoot(win, options.rootId);
onResize = () => fitPaneRoot(win, options.rootId);
win.addEventListener("resize", onResize);
const root = doc.getElementById(options.rootId) as HTMLElement | null;
if (!root) return;
void options.mount(doc, root).then((view) => {
activeView = view;
openWindows.set(key, {
dialog: dialogHelper,
view,
onResize: onResize ?? undefined,
});
fitPaneRoot(win, options.rootId);
});
} catch (e) {
ztoolkit.log("[ChatPapers] openPaneWindow load failed", e);
}
},
unloadCallback: () => {
try {
if (dialogHelper.window && onResize) {
dialogHelper.window.removeEventListener("resize", onResize);
}
} catch {
// ignore
}
activeView?.destroy();
activeView = null;
onResize = null;
openWindows.delete(key);
},
});
openWindows.set(key, {
dialog: dialogHelper,
view: null as unknown as ChatView,
onResize: onResize ?? undefined,
});
dialogHelper.open(options.title, {
centerscreen: true,
resizable: true,
noDialogMode: true,
fitContent: false,
width: size.width,
height: size.height,
});
}
export function openChatPaneWindow(item: Zotero.Item): void {
const title = String(item.getField("title") || "ChatPapers");
openPaneWindow({
kind: "chat",
item,
rootId: "chatpapers-window-chat-root",
title: `ChatPapers — ${title}`,
mount: async (doc, root) => {
const view = new ChatView(doc, root, item);
await view.mount();
return view;
},
});
}
export function openLecturePaneWindow(item: Zotero.Item): void {
const title = String(item.getField("title") || getString("item-section-lecture-head"));
openPaneWindow({
kind: "lecture",
item,
rootId: "chatpapers-window-lecture-root",
title: `${getString("item-section-lecture-head")}${title}`,
mount: async (doc, root) => {
const view = new LecturePaneView(doc, root, item);
await view.mount();
return view;
},
});
}
+13 -2
View File
@@ -1,7 +1,13 @@
import { config } from "../../../package.json";
import { getLocaleID, getString } from "../../utils/locale";
import { ChatView } from "./chatView";
import { onItemPaneSectionToggle, prepareItemPaneBody } from "./itemPaneSection";
import {
applyAdaptiveHeight,
buildItemPaneSectionButtons,
onItemPaneSectionToggle,
prepareItemPaneBody,
refreshSectionOpenHeight,
} from "./itemPaneSection";
const views = new WeakMap<HTMLElement, ChatView>();
@@ -21,10 +27,12 @@ export function registerChatPane() {
l10nID: getLocaleID("item-section-chat-sidenav"),
icon: iconURL("chat.svg"),
},
sectionButtons: buildItemPaneSectionButtons("chat"),
onInit: ({ body }) => {
const doc = body.ownerDocument;
if (doc) ensureStyles(doc);
prepareItemPaneBody(body);
applyAdaptiveHeight(body, "chat");
},
onDestroy: ({ body }) => {
views.get(body)?.destroy();
@@ -53,8 +61,11 @@ export function registerChatPane() {
const view = new ChatView(doc, body, item);
views.set(body, view);
await view.mount();
applyAdaptiveHeight(body, "chat");
const section = body.closest("collapsible-section");
if (section && !section.hasAttribute("open")) {
if (section?.hasAttribute("open")) {
refreshSectionOpenHeight(body);
} else if (section) {
onItemPaneSectionToggle({ body });
}
},