From 913e3675aeab427aaa4a3fb240891e82c45a3d6c Mon Sep 17 00:00:00 2001 From: yhy Date: Sat, 29 Aug 2026 23:18:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0pdf=E4=B8=AD=E5=88=92?= =?UTF-8?q?=E5=8F=A5=E5=AD=90ai=E4=BC=B4=E8=AF=BB=E7=9A=84=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=BF=AE=E5=A4=8Dpdf=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addon/content/chatpapers.css | 29 ++ .../icons/pane-adaptive-height-fixed.svg | 11 + addon/content/icons/pane-adaptive-height.svg | 11 + addon/content/icons/pane-open-window.svg | 7 + addon/locale/en-US/addon.ftl | 10 + addon/locale/zh-CN/addon.ftl | 10 + .../application/playSelectionExplain.ts | 94 ++++- src/modules/lecture/ui/lecturePane.ts | 154 +++++++- .../ui/registerSelectionExplainPopup.ts | 20 +- src/modules/pdf/readerSync.ts | 373 ++++++++++++++++-- src/modules/pdf/selection.ts | 96 ++++- src/modules/ui/itemPaneSection.ts | 12 +- src/utils/icons.ts | 20 + typings/i10n.d.ts | 10 + 14 files changed, 789 insertions(+), 68 deletions(-) create mode 100644 addon/content/icons/pane-adaptive-height-fixed.svg create mode 100644 addon/content/icons/pane-adaptive-height.svg create mode 100644 addon/content/icons/pane-open-window.svg diff --git a/addon/content/chatpapers.css b/addon/content/chatpapers.css index 5523ac5..5d4b806 100644 --- a/addon/content/chatpapers.css +++ b/addon/content/chatpapers.css @@ -1038,6 +1038,35 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { overflow-y: auto; } +.chatpapers-lecture-selection-explain { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border-radius: 8px; + background: color-mix(in srgb, #2563eb 6%, transparent); + border: 1px solid color-mix(in srgb, #2563eb 18%, transparent); + font-size: 12px; + line-height: 1.45; + max-height: 220px; + overflow-y: auto; +} + +.chatpapers-lecture-selection-title { + font-size: 12px; + font-weight: 700; + color: #1d4ed8; +} + +.chatpapers-lecture-selection-replay { + align-self: flex-start; + margin-top: 4px; +} + +.chatpapers-selection-explain-btn { + white-space: nowrap; +} + .chatpapers-lecture-para-kind { font-size: 11px; font-weight: 600; diff --git a/addon/content/icons/pane-adaptive-height-fixed.svg b/addon/content/icons/pane-adaptive-height-fixed.svg new file mode 100644 index 0000000..639aec6 --- /dev/null +++ b/addon/content/icons/pane-adaptive-height-fixed.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addon/content/icons/pane-adaptive-height.svg b/addon/content/icons/pane-adaptive-height.svg new file mode 100644 index 0000000..f9a9f1b --- /dev/null +++ b/addon/content/icons/pane-adaptive-height.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addon/content/icons/pane-open-window.svg b/addon/content/icons/pane-open-window.svg new file mode 100644 index 0000000..910d625 --- /dev/null +++ b/addon/content/icons/pane-open-window.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl index 7b6ce87..a1c0564 100644 --- a/addon/locale/en-US/addon.ftl +++ b/addon/locale/en-US/addon.ftl @@ -101,6 +101,16 @@ lecture-sentence-list-title = Sentences (original, then explanation) lecture-sentence-label = P{ $p }·S{ $s } lecture-sentence-list-hint = Sentences in this paragraph lecture-pdf-sync-hint = The matching passage is highlighted in the PDF reader while playing. +lecture-explain-selection = Explain & read selection +lecture-explain-selection-short = Explain +lecture-selection-empty = Select a sentence in the PDF first +lecture-selection-explaining = Generating explanation for selection… +lecture-selection-done = Selection explanation ready +lecture-selection-no-item = Cannot resolve the current PDF item +lecture-selection-title = Selection explain +lecture-selection-syntax = Syntax / terms +lecture-selection-context = Context role +lecture-selection-replay = Replay explanation lecture-playing-en = Playing: original lecture-playing-zh = Playing: translation lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl index 8f68ee1..649f7c9 100644 --- a/addon/locale/zh-CN/addon.ftl +++ b/addon/locale/zh-CN/addon.ftl @@ -101,6 +101,16 @@ lecture-sentence-list-title = 逐句精读(先读原文,再听讲解) lecture-sentence-label = 段{ $p }·句{ $s } lecture-sentence-list-hint = 本段各句 lecture-pdf-sync-hint = 播放时会在 PDF 阅读器中高亮对应原文。 +lecture-explain-selection = 解释朗读选区 +lecture-explain-selection-short = 解释朗读 +lecture-selection-empty = 请先在 PDF 中选中要解释的句子 +lecture-selection-explaining = 正在生成选区讲解… +lecture-selection-done = 选区讲解完成 +lecture-selection-no-item = 无法识别当前 PDF 条目 +lecture-selection-title = 选区讲解 +lecture-selection-syntax = 句法/术语 +lecture-selection-context = 上下文作用 +lecture-selection-replay = 重播讲解 lecture-playing-en = 正在播放:原文朗读 lecture-playing-zh = 正在播放:中文讲解 lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步 diff --git a/src/modules/lecture/application/playSelectionExplain.ts b/src/modules/lecture/application/playSelectionExplain.ts index e5dab4a..fc69c2e 100644 --- a/src/modules/lecture/application/playSelectionExplain.ts +++ b/src/modules/lecture/application/playSelectionExplain.ts @@ -1,6 +1,14 @@ import { pathToFileUrl } from "../infrastructure/platform/paths"; import type { SelectionExplainResult } from "./explainSelectionLecture"; +export interface LecturePlaybackHandle { + pause: () => void; +} + +let playbackGeneration = 0; +let explainSessionId = 0; +const panePlaybackHandles = new Set(); + function waitAudioEnded(audio: HTMLAudioElement): Promise { return new Promise((resolve, reject) => { const cleanup = () => { @@ -25,18 +33,70 @@ function audioPaths(result: SelectionExplainResult): string[] { return paths; } +/** Stop standalone + all registered lecture pane players. */ +export function stopAllLecturePlayback(): void { + playbackGeneration += 1; + standaloneAudio?.pause(); + if (standaloneAudio) { + standaloneAudio.onended = null; + standaloneAudio.removeAttribute("src"); + } + for (const handle of panePlaybackHandles) { + try { + handle.pause(); + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] pause registered player failed", e); + } + } +} + +export function registerLecturePlaybackHandle( + handle: LecturePlaybackHandle, +): () => void { + panePlaybackHandles.add(handle); + return () => { + panePlaybackHandles.delete(handle); + }; +} + +/** Begin a new explain flow; cancels any in-flight playback and explain session. */ +export function beginSelectionExplainSession(): number { + explainSessionId += 1; + stopAllLecturePlayback(); + return explainSessionId; +} + +export function isSelectionExplainSessionActive(sessionId: number): boolean { + return sessionId === explainSessionId; +} + +export function cancelSelectionExplainSessions(): void { + explainSessionId += 1; + stopAllLecturePlayback(); +} + export async function playSelectionExplainAudio( audio: HTMLAudioElement, result: SelectionExplainResult, + options?: { generation?: number }, ): Promise { const paths = audioPaths(result); if (!paths.length) return; + const generation = options?.generation ?? playbackGeneration; audio.pause(); + audio.onended = null; + for (const path of paths) { + if (generation !== playbackGeneration) return; audio.src = pathToFileUrl(path); audio.load(); - await audio.play(); + try { + await audio.play(); + } catch (e) { + if (generation !== playbackGeneration) return; + throw e; + } await waitAudioEnded(audio); } } @@ -46,20 +106,36 @@ 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; + const doc = win?.document; + if (!doc) { + throw new Error("Cannot resolve main window document for audio playback"); + } + const audio = doc.createElement("audio"); + audio.preload = "none"; + audio.style.display = "none"; + doc.body?.appendChild(audio); + standaloneAudio = audio; + return audio; +} + +/** Play selection explain on one audio element, stopping every other lecture audio first. */ +export async function playSelectionExplainExclusive( + result: SelectionExplainResult, + audio?: HTMLAudioElement, +): Promise { + stopAllLecturePlayback(); + const generation = playbackGeneration; + await playSelectionExplainAudio(audio ?? getStandaloneAudio(), result, { + generation, + }); } export async function playSelectionExplainStandalone( result: SelectionExplainResult, ): Promise { - await playSelectionExplainAudio(getStandaloneAudio(), result); + await playSelectionExplainExclusive(result); } export function stopSelectionExplainStandalone(): void { - standaloneAudio?.pause(); + stopAllLecturePlayback(); } diff --git a/src/modules/lecture/ui/lecturePane.ts b/src/modules/lecture/ui/lecturePane.ts index 5f605d0..4a78a64 100644 --- a/src/modules/lecture/ui/lecturePane.ts +++ b/src/modules/lecture/ui/lecturePane.ts @@ -47,7 +47,14 @@ import { explainSelectionLecture, type SelectionExplainResult, } from "../application/explainSelectionLecture"; -import { playSelectionExplainAudio } from "../application/playSelectionExplain"; +import { + beginSelectionExplainSession, + isSelectionExplainSessionActive, + playSelectionExplainAudio, + playSelectionExplainExclusive, + registerLecturePlaybackHandle, + stopAllLecturePlayback, +} from "../application/playSelectionExplain"; import { getString } from "../../../utils/locale"; import { createLucideIcon } from "../../../utils/icons"; @@ -70,6 +77,8 @@ export class LecturePaneView { private pdfSyncEnabled = true; private explainingSelection = false; private lastSelectionResult?: SelectionExplainResult; + private unregisterPlayback?: () => void; + private explainSessionId = 0; private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[]; private statusEl!: HTMLElement; @@ -802,6 +811,139 @@ export class LecturePaneView { ); } + private async onExplainSelection(): Promise { + if (this.explainingSelection) return; + + let selectedText = ""; + try { + selectedText = getReaderSelectedText(); + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] get selection failed", e); + } + + if (!selectedText.trim()) { + this.setStatus("idle", getString("lecture-selection-empty"), "warn"); + return; + } + + this.explainingSelection = true; + this.explainSelectionBtn.disabled = true; + this.setStatus("summarizing", getString("lecture-selection-explaining")); + + try { + const result = await explainSelectionLecture({ + item: this.item, + selectedText, + onProgress: (message) => this.setStatus("summarizing", message), + }); + await this.applySelectionExplainResult(result); + this.setStatus("ready", getString("lecture-selection-done")); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + this.setStatus("failed", msg); + } finally { + this.explainingSelection = false; + this.explainSelectionBtn.disabled = false; + } + } + + private async applySelectionExplainResult( + result: SelectionExplainResult, + ): Promise { + this.lastSelectionResult = result; + this.renderSelectionExplain(result); + + await this.syncPdfHighlight(result.original, result.page); + + if (result.fromPrepared && result.matched) { + if (!this.paragraphPlayer) { + await this.refreshPlayer(); + } + if (this.paragraphPlayer) { + this.playMode = "paragraph"; + this.readGranularity = "sentence"; + this.updateModeTabs(); + this.paragraphPlayer.setGranularity("sentence"); + const kind = result.audioPathEn ? "en" : "zh"; + if (result.matched.sentenceId.endsWith("-whole")) { + await this.paragraphPlayer.jumpToParagraphId( + result.matched.paragraphId, + kind, + ); + } else { + await this.paragraphPlayer.jumpToSentenceId( + result.matched.sentenceId, + kind, + ); + } + return; + } + } + + if (!this.audioEl) return; + this.paragraphPlayer?.pause(); + this.player?.pause(); + this.playBtn.hidden = true; + this.pauseBtn.hidden = false; + try { + await playSelectionExplainAudio(this.audioEl, result); + } catch (e) { + ztoolkit.log("[ChatPapers:Lecture] selection audio failed", e); + } finally { + this.playBtn.hidden = false; + this.pauseBtn.hidden = true; + } + } + + private renderSelectionExplain(result: SelectionExplainResult): void { + this.selectionExplainEl.replaceChildren(); + this.selectionExplainEl.hidden = false; + + const title = this.el( + "div", + "chatpapers-lecture-selection-title", + getString("lecture-selection-title"), + ); + this.selectionExplainEl.append(title); + + this.selectionExplainEl.append( + this.el("div", "chatpapers-lecture-para-original-label", getString("lecture-para-original")), + this.el("div", "chatpapers-lecture-para-original is-active-sentence", result.original), + this.el("div", "chatpapers-lecture-para-translation-label", getString("lecture-para-translation")), + this.el("div", "chatpapers-lecture-para-translation", result.translation), + ); + + if (result.syntaxNote) { + this.selectionExplainEl.append( + this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-selection-syntax")), + this.el("div", "chatpapers-lecture-para-context", result.syntaxNote), + ); + } + + if (result.contextRole) { + this.selectionExplainEl.append( + this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-selection-context")), + this.el("div", "chatpapers-lecture-para-context", result.contextRole), + ); + } + + const replayBtn = this.doc.createElement("button"); + replayBtn.type = "button"; + replayBtn.className = "chatpapers-lecture-secondary chatpapers-lecture-selection-replay"; + replayBtn.append( + createLucideIcon(this.doc, Volume2, { + size: 14, + className: "chatpapers-lecture-btn-icon", + }), + this.doc.createTextNode(getString("lecture-selection-replay")), + ); + replayBtn.addEventListener("click", () => { + if (!this.audioEl || !this.lastSelectionResult) return; + void playSelectionExplainAudio(this.audioEl, this.lastSelectionResult); + }); + this.selectionExplainEl.append(replayBtn); + } + private async onSaveNote(): Promise { if (!this.cacheKey) return; const data = await loadLectureData(this.cacheKey); @@ -906,8 +1048,14 @@ export class LecturePaneView { } } - private setStatus(status: LectureStatus | "idle", message?: string): void { + private setStatus( + status: LectureStatus | "idle", + message?: string, + kind?: "warn" | "error" | "info", + ): void { this.statusEl.dataset.status = status; + if (kind) this.statusEl.dataset.kind = kind; + else this.statusEl.removeAttribute("data-kind"); this.statusTextEl.textContent = message || getString("lecture-status-idle"); const busy = [ @@ -918,6 +1066,8 @@ export class LecturePaneView { "tts_generating", ].includes(status); this.actionBtn.disabled = this.preparing || busy; + this.explainSelectionBtn.disabled = + this.preparing || busy || this.explainingSelection; this.ttsTestBtn.disabled = this.ttsTesting || this.preparing; } diff --git a/src/modules/lecture/ui/registerSelectionExplainPopup.ts b/src/modules/lecture/ui/registerSelectionExplainPopup.ts index cee48dc..59c8afd 100644 --- a/src/modules/lecture/ui/registerSelectionExplainPopup.ts +++ b/src/modules/lecture/ui/registerSelectionExplainPopup.ts @@ -1,11 +1,15 @@ import { config } from "../../../../package.json"; import { getString } from "../../../utils/locale"; -import { cacheReaderSelection } from "../../pdf/selection"; +import { cacheReaderSelection, getCachedReaderSelectionPosition } 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 { + beginSelectionExplainSession, + isSelectionExplainSessionActive, + playSelectionExplainExclusive, +} from "../application/playSelectionExplain"; import { lectureEvents } from "../infrastructure/events"; function tryString(value: unknown): string { @@ -47,6 +51,7 @@ export async function runSelectionExplainFromReader( } cacheReaderSelection(text); + const sessionId = beginSelectionExplainSession(); showHeadline(getString("lecture-selection-explaining")); try { @@ -57,9 +62,12 @@ export async function runSelectionExplainFromReader( onProgress: (message) => showHeadline(message), }); + if (!isSelectionExplainSessionActive(sessionId)) return; + await highlightTextInPdf(item, { text: result.original, page: result.page, + position: getCachedReaderSelectionPosition(result.original), }); lectureEvents.emit("lecture:selection_explain", { @@ -69,7 +77,9 @@ export async function runSelectionExplainFromReader( result, }); - await playSelectionExplainStandalone(result); + if (!isSelectionExplainSessionActive(sessionId)) return; + + await playSelectionExplainExclusive(result); showHeadline(getString("lecture-selection-done")); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -96,11 +106,11 @@ export function registerSelectionExplainPopup(): void { "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) => { + btn.addEventListener("click", (ev: Event) => { ev.preventDefault(); ev.stopPropagation(); void runSelectionExplainFromReader(reader, text); - }); + }, { once: true }); append(btn); } catch (e) { ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e); diff --git a/src/modules/pdf/readerSync.ts b/src/modules/pdf/readerSync.ts index f4e02e7..c7c85c5 100644 --- a/src/modules/pdf/readerSync.ts +++ b/src/modules/pdf/readerSync.ts @@ -1,9 +1,19 @@ /** - * Sync lecture playback to Zotero PDF reader: navigate + in-document find/highlight. + * Sync lecture playback to Zotero PDF reader: navigate + multi-rect highlight. * Uses internal reader APIs (best-effort; may break across Zotero versions). */ import { findPdfAttachment } from "./extractor"; +import { + getCachedReaderSelectionPosition, + normalizeHighlightText, +} from "./selection"; + +export interface PdfPosition { + pageIndex: number; + rects?: number[][]; + nextPageRects?: number[][]; +} function getInternalReader(reader: any): any { if (!reader) return undefined; @@ -53,10 +63,7 @@ function findReaderForAttachment(attachmentId: number): any { /** Build a short phrase suitable for PDF in-document search. */ export function buildPdfSearchQuery(text: string, maxLen = 72): string { - const normalized = text - .replace(/[\u0000-\u001f\f]/g, " ") - .replace(/\s+/g, " ") - .trim(); + const normalized = normalizeHighlightText(text); if (!normalized) return ""; if (normalized.length <= maxLen) return normalized; const snippet = normalized.slice(0, maxLen); @@ -64,25 +71,124 @@ export function buildPdfSearchQuery(text: string, maxLen = 72): string { return lastSpace > 32 ? snippet.slice(0, lastSpace) : snippet; } -/** Multiple progressively shorter queries — PDF text may differ from extraction. */ +function trimQuery(text: string, maxLen: number): string { + const normalized = normalizeHighlightText(text); + if (!normalized) return ""; + if (normalized.length <= maxLen) return normalized; + const snippet = normalized.slice(0, maxLen); + const lastSpace = snippet.lastIndexOf(" "); + return lastSpace > 24 ? snippet.slice(0, lastSpace) : snippet; +} + +/** Prefer full text first — PDF page content joins line breaks as spaces. */ function buildQueryCandidates(text: string): string[] { - const normalized = text - .replace(/[\u0000-\u001f\f]/g, " ") - .replace(/\s+/g, " ") - .trim(); - const words = normalized.split(/\s+/).filter((w) => w.length > 1); + const normalized = normalizeHighlightText(text); + if (!normalized) return []; + const out: string[] = []; + const push = (q: string) => { + const t = q.trim(); + if (t.length >= 6) out.push(t); + }; - if (words.length >= 8) out.push(words.slice(0, 10).join(" ")); - if (words.length >= 5) out.push(words.slice(0, 6).join(" ")); - if (words.length >= 3) out.push(words.slice(0, 4).join(" ")); + push(trimQuery(normalized, 480)); + push(trimQuery(normalized, 320)); + push(trimQuery(normalized, 220)); + push(trimQuery(normalized, 140)); + push(buildPdfSearchQuery(normalized, 96)); + push(buildPdfSearchQuery(normalized, 72)); - const q50 = buildPdfSearchQuery(text, 50); - const q30 = buildPdfSearchQuery(text, 30); - if (q50) out.push(q50); - if (q30 && q30 !== q50) out.push(q30); + const words = normalized.split(/\s+/).filter((w) => w.length > 1); + if (words.length >= 12) push(words.slice(0, 14).join(" ")); + if (words.length >= 8) push(words.slice(0, 10).join(" ")); + if (words.length >= 5) push(words.slice(0, 6).join(" ")); - return [...new Set(out.filter((q) => q.length >= 8))]; + return [...new Set(out)]; +} + +/** Split wrapped sentences into line-sized chunks for fallback per-line find. */ +function splitLineSegments(text: string): string[] { + const normalized = normalizeHighlightText(text); + if (!normalized) return []; + + const explicitLines = text + .split(/\n+/) + .map((line) => normalizeHighlightText(line)) + .filter((line) => line.length >= 6); + + const source = explicitLines.length > 1 ? explicitLines : [normalized]; + const segments: string[] = []; + + for (const line of source) { + if (line.length <= 88) { + segments.push(line); + continue; + } + const words = line.split(/\s+/).filter(Boolean); + let buf: string[] = []; + let len = 0; + for (const word of words) { + const nextLen = len ? len + 1 + word.length : word.length; + if (nextLen > 72 && buf.length) { + segments.push(buf.join(" ")); + buf = [word]; + len = word.length; + } else { + buf.push(word); + len = nextLen; + } + } + if (buf.length) segments.push(buf.join(" ")); + } + + return [...new Set(segments.filter((s) => s.length >= 6))]; +} + +function cloneIntoFrame(value: T, win: Window | undefined): T { + if (!win) return value; + try { + const utils = (Components as any)?.utils; + if (utils?.cloneInto) return utils.cloneInto(value, win); + } catch { + // ignore + } + return value; +} + +function positionRectCount(position?: PdfPosition | null): number { + if (!position) return 0; + return (position.rects?.length ?? 0) + (position.nextPageRects?.length ?? 0); +} + +function mergePositions(primary: PdfPosition, extra: PdfPosition): PdfPosition { + const merged: PdfPosition = { + pageIndex: primary.pageIndex, + rects: [...(primary.rects ?? [])], + }; + if (primary.nextPageRects?.length) { + merged.nextPageRects = [...primary.nextPageRects]; + } + + if (extra.pageIndex === primary.pageIndex) { + merged.rects!.push(...(extra.rects ?? [])); + return merged; + } + if (extra.pageIndex === primary.pageIndex + 1) { + merged.nextPageRects = [ + ...(merged.nextPageRects ?? []), + ...(extra.rects ?? []), + ...(extra.nextPageRects ?? []), + ]; + return merged; + } + if (extra.pageIndex === primary.pageIndex - 1) { + return { + pageIndex: extra.pageIndex, + rects: [...(extra.rects ?? []), ...(merged.rects ?? [])], + nextPageRects: merged.nextPageRects, + }; + } + return merged; } async function waitForReaderReady(reader: any): Promise { @@ -138,12 +244,7 @@ function applyFindHighlight(reader: any, query: string): boolean { try { if (internal?._updateState) { - const win = reader?._iframeWindow; - const state = - win && typeof Components !== "undefined" - ? Components.utils.cloneInto(payload, win) - : payload; - internal._updateState(state); + internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow)); try { internal.findNext?.(true); } catch { @@ -180,12 +281,7 @@ function clearFindHighlight(reader: any): void { try { const internal = getInternalReader(reader); if (internal?._updateState) { - const win = reader?._iframeWindow; - const state = - win && typeof Components !== "undefined" - ? Components.utils.cloneInto(payload, win) - : payload; - internal._updateState(state); + internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow)); return; } getPrimaryView(reader)?.setFindState?.(cleared); @@ -194,9 +290,188 @@ function clearFindHighlight(reader: any): void { } } +function clearPositionHighlight(reader: any): void { + const view = getPrimaryView(reader); + if (!view) return; + try { + view._readAloudHighlightedPosition = null; + view._readAloudSentenceHighlightedPosition = null; + view._highlightedPosition = null; + view._render?.(); + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] clear position highlight failed", e); + } +} + +function applyPositionHighlight(reader: any, position: PdfPosition): boolean { + const view = getPrimaryView(reader); + if (!view || !positionRectCount(position)) return false; + + try { + if (typeof view.navigateToPosition === "function") { + void view.navigateToPosition(position, { + ifNeeded: true, + block: "nearest", + inline: "nearest", + }); + } + + view._readAloudHighlightedPosition = position; + view._readAloudSentenceHighlightedPosition = null; + view._highlightedPosition = null; + view._render?.(); + return true; + } catch (e) { + ztoolkit.log("[ChatPapers:ReaderSync] applyPositionHighlight failed", e); + return false; + } +} + +function normalizePdfPosition(raw: any, pageHint?: number): PdfPosition | undefined { + if (!raw || typeof raw !== "object") return undefined; + const pageIndex = + typeof raw.pageIndex === "number" + ? raw.pageIndex + : pageHint && pageHint > 0 + ? pageHint - 1 + : undefined; + if (pageIndex == null || pageIndex < 0) return undefined; + + const rects = Array.isArray(raw.rects) + ? raw.rects.filter((r: unknown) => Array.isArray(r) && r.length === 4) + : undefined; + const nextPageRects = Array.isArray(raw.nextPageRects) + ? raw.nextPageRects.filter((r: unknown) => Array.isArray(r) && r.length === 4) + : undefined; + + if (!rects?.length && !nextPageRects?.length) return undefined; + return { + pageIndex, + rects: rects?.length ? rects : undefined, + nextPageRects: nextPageRects?.length ? nextPageRects : undefined, + }; +} + +function pickBestMatchPosition( + positions: PdfPosition[], + preferredPageIndex?: number, +): PdfPosition | undefined { + if (!positions.length) return undefined; + + let best = positions[0]; + let bestScore = -1; + for (const pos of positions) { + let score = positionRectCount(pos) * 10; + if ( + preferredPageIndex != null && + pos.pageIndex === preferredPageIndex - 1 + ) { + score += 100; + } + if (pos.nextPageRects?.length) score += 5; + if (score > bestScore) { + bestScore = score; + best = pos; + } + } + return best; +} + +async function waitForFindPosition( + view: any, + pageHint?: number, +): Promise { + const preferredPageIndex = pageHint && pageHint > 0 ? pageHint : undefined; + + for (let i = 0; i < 18; i++) { + const fromResult = normalizePdfPosition( + view?._findState?.result?.annotation?.position, + preferredPageIndex, + ); + if (fromResult && positionRectCount(fromResult) > 0) { + return fromResult; + } + + try { + const pageIndexes = new Set(); + if (preferredPageIndex) pageIndexes.add(preferredPageIndex - 1); + const current = + view?._iframeWindow?.PDFViewerApplication?.pdfViewer?.currentPageNumber; + if (typeof current === "number" && current > 0) { + pageIndexes.add(current - 1); + } + + const collected: PdfPosition[] = []; + for (const pageIndex of pageIndexes) { + const matches = + (await view?._findController?.getMatchPositionsAsync?.(pageIndex)) ?? + []; + for (const match of matches) { + const pos = normalizePdfPosition(match, pageIndex + 1); + if (pos) collected.push(pos); + } + } + const best = pickBestMatchPosition(collected, preferredPageIndex); + if (best) return best; + } catch { + // ignore + } + + await Zotero.Promise.delay(120); + } + + return undefined; +} + +async function resolveTextPosition( + reader: any, + text: string, + pageHint?: number, +): Promise { + const view = getPrimaryView(reader); + if (!view) return undefined; + + const candidates = buildQueryCandidates(text); + for (const query of candidates) { + if (!applyFindHighlight(reader, query)) continue; + const position = await waitForFindPosition(view, pageHint); + if (position && positionRectCount(position) > 0) { + ztoolkit.log( + "[ChatPapers:ReaderSync] matched query", + query.slice(0, 48), + "rects=", + positionRectCount(position), + ); + return position; + } + } + + const segments = splitLineSegments(text); + if (segments.length <= 1) return undefined; + + let merged: PdfPosition | undefined; + for (const segment of segments) { + if (!applyFindHighlight(reader, segment)) continue; + const position = await waitForFindPosition(view, pageHint); + if (!position) continue; + merged = merged ? mergePositions(merged, position) : position; + } + + if (merged && positionRectCount(merged) > 0) { + ztoolkit.log( + "[ChatPapers:ReaderSync] merged segment highlight rects=", + positionRectCount(merged), + ); + return merged; + } + + return undefined; +} + export interface PdfHighlightTarget { text: string; page?: number; + position?: PdfPosition; } /** Open PDF (if needed), jump to page, and highlight matching text. */ @@ -207,9 +482,6 @@ export async function highlightTextInPdf( const attachment = findPdfAttachment(item); if (!attachment || !target.text?.trim()) return; - const candidates = buildQueryCandidates(target.text); - if (!candidates.length) return; - try { let reader = findReaderForAttachment(attachment.id); if (!reader) { @@ -219,16 +491,32 @@ export async function highlightTextInPdf( await waitForReaderReady(reader); await navigateReader(reader, target.page); - await Zotero.Promise.delay(350); + await Zotero.Promise.delay(250); - for (const query of candidates) { - if (applyFindHighlight(reader, query)) { - await Zotero.Promise.delay(200); - ztoolkit.log("[ChatPapers:ReaderSync] find applied:", query.slice(0, 40)); - return; - } + const cachedPosition = getCachedReaderSelectionPosition(target.text); + const position = + normalizePdfPosition(target.position, target.page) ?? + normalizePdfPosition(cachedPosition, target.page) ?? + (await resolveTextPosition(reader, target.text, target.page)); + + if (position && applyPositionHighlight(reader, position)) { + applyFindHighlight( + reader, + buildQueryCandidates(target.text)[0] ?? buildPdfSearchQuery(target.text, 96), + ); + return; } - ztoolkit.log("[ChatPapers:ReaderSync] all find attempts failed"); + + const fallbackQuery = buildQueryCandidates(target.text)[0]; + if (fallbackQuery && applyFindHighlight(reader, fallbackQuery)) { + ztoolkit.log( + "[ChatPapers:ReaderSync] find-only fallback:", + fallbackQuery.slice(0, 48), + ); + return; + } + + ztoolkit.log("[ChatPapers:ReaderSync] all highlight attempts failed"); } catch (e) { ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e); } @@ -242,6 +530,7 @@ export async function clearPdfHighlight(item: Zotero.Item): Promise { const reader = findReaderForAttachment(attachment.id); if (!reader) return; clearFindHighlight(reader); + clearPositionHighlight(reader); } catch (e) { ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e); } diff --git a/src/modules/pdf/selection.ts b/src/modules/pdf/selection.ts index c3cd45f..565b468 100644 --- a/src/modules/pdf/selection.ts +++ b/src/modules/pdf/selection.ts @@ -33,11 +33,16 @@ function textFromReaderInternals(reader: any): string { // zotero-plugin-toolkit path: selection popup annotation try { - const popupText = - reader?._internalReader?._lastView?._selectionPopup?.annotation?.text ?? - reader?._internalReader?._primaryView?._selectionPopup?.annotation?.text; + const popup = + reader?._internalReader?._lastView?._selectionPopup ?? + reader?._internalReader?._primaryView?._selectionPopup; + const popupText = popup?.annotation?.text; const t = tryString(popupText); - if (t) return t; + if (t) { + const position = normalizePdfSelectionPosition(popup?.annotation?.position); + if (position) cacheReaderSelection(t, position); + return t; + } } catch { // ignore } @@ -70,7 +75,13 @@ function textFromReaderInternals(reader: any): string { } try { const t = tryString(view._selectionPopup?.annotation?.text); - if (t) return t; + if (t) { + const position = normalizePdfSelectionPosition( + view._selectionPopup?.annotation?.position, + ); + if (position) cacheReaderSelection(t, position); + return t; + } } catch { // ignore } @@ -139,15 +150,83 @@ function listCandidateReaders(): any[] { /** Last non-empty selection cached while user selects in the reader. */ let cachedSelection = ""; +let cachedSelectionPosition: PdfSelectionPosition | undefined; let cachedAt = 0; -export function cacheReaderSelection(text: string) { +export interface PdfSelectionPosition { + pageIndex: number; + rects?: number[][]; + nextPageRects?: number[][]; +} + +export function normalizeHighlightText(text: string): string { + return text + .replace(/[\u0000-\u001f\f]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function normalizePdfSelectionPosition(raw: unknown): PdfSelectionPosition | undefined { + if (!raw || typeof raw !== "object") return undefined; + const pos = raw as Record; + const pageIndex = + typeof pos.pageIndex === "number" + ? pos.pageIndex + : typeof pos.pageNumber === "number" + ? (pos.pageNumber as number) - 1 + : undefined; + if (pageIndex == null || pageIndex < 0) return undefined; + + const rects = Array.isArray(pos.rects) + ? pos.rects.filter( + (r): r is number[] => Array.isArray(r) && r.length === 4, + ) + : undefined; + const nextPageRects = Array.isArray(pos.nextPageRects) + ? pos.nextPageRects.filter( + (r): r is number[] => Array.isArray(r) && r.length === 4, + ) + : undefined; + + if (!rects?.length && !nextPageRects?.length) return undefined; + return { + pageIndex, + rects: rects?.length ? rects : undefined, + nextPageRects: nextPageRects?.length ? nextPageRects : undefined, + }; +} + +export function cacheReaderSelection(text: string, position?: PdfSelectionPosition) { const t = text.trim(); if (!t) return; + const sameText = + normalizeHighlightText(cachedSelection) === normalizeHighlightText(t); cachedSelection = t; + if (position) { + cachedSelectionPosition = position; + } else if (!sameText) { + cachedSelectionPosition = undefined; + } cachedAt = Date.now(); } +export function getCachedReaderSelectionPosition( + text?: string, + maxAgeMs = 120000, +): PdfSelectionPosition | undefined { + if (!cachedSelectionPosition) return undefined; + if (Date.now() - cachedAt > maxAgeMs) return undefined; + if (!text?.trim()) return cachedSelectionPosition; + + const wanted = normalizeHighlightText(text); + const cached = normalizeHighlightText(cachedSelection); + if (!wanted || !cached) return undefined; + if (wanted === cached || cached.includes(wanted) || wanted.includes(cached)) { + return cachedSelectionPosition; + } + return undefined; +} + export function getCachedReaderSelection(maxAgeMs = 120000): string { if (!cachedSelection) return ""; if (Date.now() - cachedAt > maxAgeMs) return ""; @@ -200,7 +279,10 @@ export function registerReaderSelectionHook() { tryString(event?.params?.annotation?.text) || tryString(event?.params?.text) || textFromReaderInternals(event?.reader); - if (text) cacheReaderSelection(text); + const position = normalizePdfSelectionPosition( + event?.params?.annotation?.position, + ); + if (text) cacheReaderSelection(text, position); } catch (e) { ztoolkit.log("selection popup hook failed", e); } diff --git a/src/modules/ui/itemPaneSection.ts b/src/modules/ui/itemPaneSection.ts index 5af18c9..c51219a 100644 --- a/src/modules/ui/itemPaneSection.ts +++ b/src/modules/ui/itemPaneSection.ts @@ -1,5 +1,6 @@ /** Helpers for Zotero ItemPane collapsible sections. */ +import { config } from "../../../package.json"; import { getLocaleID, getString } from "../../utils/locale"; import { getPref, setPref } from "../../utils/prefs"; import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow"; @@ -17,9 +18,14 @@ const FIXED_HEIGHT: Record = { 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"; +/** 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"); diff --git a/src/utils/icons.ts b/src/utils/icons.ts index 8e3af5a..2d7fb85 100644 --- a/src/utils/icons.ts +++ b/src/utils/icons.ts @@ -2,6 +2,26 @@ import type { IconNode } from "lucide"; export type LucideIcon = IconNode; +function serializeLucideNode([tag, attrs]: IconNode[number]): string { + const parts = Object.entries(attrs) + .map(([key, value]) => `${key}="${String(value).replace(/"/g, """)}"`) + .join(" "); + return parts ? `<${tag} ${parts}/>` : `<${tag}/>`; +} + +/** Lucide icon as a 16px SVG data URL for Zotero toolbar list-style-image. */ +export function lucideToolbarIconUrl(icon: LucideIcon): string { + const body = icon.map(serializeLucideNode).join(""); + const svg = [ + '', + body, + "", + ].join(""); + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + export function createLucideIcon( doc: Document, icon: LucideIcon, diff --git a/typings/i10n.d.ts b/typings/i10n.d.ts index 6354e93..977200f 100644 --- a/typings/i10n.d.ts +++ b/typings/i10n.d.ts @@ -49,6 +49,8 @@ export type FluentMessageId = | 'lecture-beat-tts-pending' | 'lecture-empty-desc' | 'lecture-empty-title' + | 'lecture-explain-selection' + | 'lecture-explain-selection-short' | 'lecture-granularity-paragraph' | 'lecture-granularity-sentence' | 'lecture-loading' @@ -76,6 +78,14 @@ export type FluentMessageId = | 'lecture-playing-zh' | 'lecture-reprepare' | 'lecture-save-note' + | 'lecture-selection-context' + | 'lecture-selection-done' + | 'lecture-selection-empty' + | 'lecture-selection-explaining' + | 'lecture-selection-no-item' + | 'lecture-selection-replay' + | 'lecture-selection-syntax' + | 'lecture-selection-title' | 'lecture-sentence-label' | 'lecture-sentence-list-hint' | 'lecture-sentence-list-title'