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 = [
+ '",
+ ].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'