增加pdf中划句或段ai伴读功能
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user