实现pdf中划句子ai伴读的功能,修复pdf高亮显示的问题
This commit is contained in:
@@ -1038,6 +1038,35 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root {
|
|||||||
overflow-y: auto;
|
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 {
|
.chatpapers-lecture-para-kind {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Lucide: FoldVertical (adaptive off / fixed height) -->
|
||||||
|
<path d="M12 22v-6"/>
|
||||||
|
<path d="M12 8V2"/>
|
||||||
|
<path d="M4 12H2"/>
|
||||||
|
<path d="M10 12H8"/>
|
||||||
|
<path d="M16 12h-2"/>
|
||||||
|
<path d="M22 12h-2"/>
|
||||||
|
<path d="m15 19-3-3-3 3"/>
|
||||||
|
<path d="m15 5-3 3-3-3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Lucide: UnfoldVertical (adaptive on) -->
|
||||||
|
<path d="M12 22v-6"/>
|
||||||
|
<path d="M12 8V2"/>
|
||||||
|
<path d="M4 12H2"/>
|
||||||
|
<path d="M10 12H8"/>
|
||||||
|
<path d="M16 12h-2"/>
|
||||||
|
<path d="M22 12h-2"/>
|
||||||
|
<path d="m15 19-3 3-3-3"/>
|
||||||
|
<path d="m15 5-3-3-3 3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Lucide: AppWindow -->
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||||
|
<path d="M10 4v4"/>
|
||||||
|
<path d="M2 8h20"/>
|
||||||
|
<path d="M6 4v4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 337 B |
@@ -101,6 +101,16 @@ lecture-sentence-list-title = Sentences (original, then explanation)
|
|||||||
lecture-sentence-label = P{ $p }·S{ $s }
|
lecture-sentence-label = P{ $p }·S{ $s }
|
||||||
lecture-sentence-list-hint = Sentences in this paragraph
|
lecture-sentence-list-hint = Sentences in this paragraph
|
||||||
lecture-pdf-sync-hint = The matching passage is highlighted in the PDF reader while playing.
|
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-en = Playing: original
|
||||||
lecture-playing-zh = Playing: translation
|
lecture-playing-zh = Playing: translation
|
||||||
lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced
|
lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ lecture-sentence-list-title = 逐句精读(先读原文,再听讲解)
|
|||||||
lecture-sentence-label = 段{ $p }·句{ $s }
|
lecture-sentence-label = 段{ $p }·句{ $s }
|
||||||
lecture-sentence-list-hint = 本段各句
|
lecture-sentence-list-hint = 本段各句
|
||||||
lecture-pdf-sync-hint = 播放时会在 PDF 阅读器中高亮对应原文。
|
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-en = 正在播放:原文朗读
|
||||||
lecture-playing-zh = 正在播放:中文讲解
|
lecture-playing-zh = 正在播放:中文讲解
|
||||||
lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步
|
lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||||
import type { SelectionExplainResult } from "./explainSelectionLecture";
|
import type { SelectionExplainResult } from "./explainSelectionLecture";
|
||||||
|
|
||||||
|
export interface LecturePlaybackHandle {
|
||||||
|
pause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let playbackGeneration = 0;
|
||||||
|
let explainSessionId = 0;
|
||||||
|
const panePlaybackHandles = new Set<LecturePlaybackHandle>();
|
||||||
|
|
||||||
function waitAudioEnded(audio: HTMLAudioElement): Promise<void> {
|
function waitAudioEnded(audio: HTMLAudioElement): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@@ -25,18 +33,70 @@ function audioPaths(result: SelectionExplainResult): string[] {
|
|||||||
return paths;
|
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(
|
export async function playSelectionExplainAudio(
|
||||||
audio: HTMLAudioElement,
|
audio: HTMLAudioElement,
|
||||||
result: SelectionExplainResult,
|
result: SelectionExplainResult,
|
||||||
|
options?: { generation?: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const paths = audioPaths(result);
|
const paths = audioPaths(result);
|
||||||
if (!paths.length) return;
|
if (!paths.length) return;
|
||||||
|
|
||||||
|
const generation = options?.generation ?? playbackGeneration;
|
||||||
audio.pause();
|
audio.pause();
|
||||||
|
audio.onended = null;
|
||||||
|
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
|
if (generation !== playbackGeneration) return;
|
||||||
audio.src = pathToFileUrl(path);
|
audio.src = pathToFileUrl(path);
|
||||||
audio.load();
|
audio.load();
|
||||||
await audio.play();
|
try {
|
||||||
|
await audio.play();
|
||||||
|
} catch (e) {
|
||||||
|
if (generation !== playbackGeneration) return;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
await waitAudioEnded(audio);
|
await waitAudioEnded(audio);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,20 +106,36 @@ let standaloneAudio: HTMLAudioElement | undefined;
|
|||||||
function getStandaloneAudio(): HTMLAudioElement {
|
function getStandaloneAudio(): HTMLAudioElement {
|
||||||
if (standaloneAudio) return standaloneAudio;
|
if (standaloneAudio) return standaloneAudio;
|
||||||
const win = Zotero.getMainWindow?.() as Window | undefined;
|
const win = Zotero.getMainWindow?.() as Window | undefined;
|
||||||
const doc = win?.document ?? document;
|
const doc = win?.document;
|
||||||
standaloneAudio = doc.createElement("audio");
|
if (!doc) {
|
||||||
standaloneAudio.preload = "none";
|
throw new Error("Cannot resolve main window document for audio playback");
|
||||||
standaloneAudio.style.display = "none";
|
}
|
||||||
doc.body?.appendChild(standaloneAudio);
|
const audio = doc.createElement("audio");
|
||||||
return standaloneAudio;
|
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<void> {
|
||||||
|
stopAllLecturePlayback();
|
||||||
|
const generation = playbackGeneration;
|
||||||
|
await playSelectionExplainAudio(audio ?? getStandaloneAudio(), result, {
|
||||||
|
generation,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playSelectionExplainStandalone(
|
export async function playSelectionExplainStandalone(
|
||||||
result: SelectionExplainResult,
|
result: SelectionExplainResult,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await playSelectionExplainAudio(getStandaloneAudio(), result);
|
await playSelectionExplainExclusive(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSelectionExplainStandalone(): void {
|
export function stopSelectionExplainStandalone(): void {
|
||||||
standaloneAudio?.pause();
|
stopAllLecturePlayback();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,14 @@ import {
|
|||||||
explainSelectionLecture,
|
explainSelectionLecture,
|
||||||
type SelectionExplainResult,
|
type SelectionExplainResult,
|
||||||
} from "../application/explainSelectionLecture";
|
} from "../application/explainSelectionLecture";
|
||||||
import { playSelectionExplainAudio } from "../application/playSelectionExplain";
|
import {
|
||||||
|
beginSelectionExplainSession,
|
||||||
|
isSelectionExplainSessionActive,
|
||||||
|
playSelectionExplainAudio,
|
||||||
|
playSelectionExplainExclusive,
|
||||||
|
registerLecturePlaybackHandle,
|
||||||
|
stopAllLecturePlayback,
|
||||||
|
} from "../application/playSelectionExplain";
|
||||||
import { getString } from "../../../utils/locale";
|
import { getString } from "../../../utils/locale";
|
||||||
import { createLucideIcon } from "../../../utils/icons";
|
import { createLucideIcon } from "../../../utils/icons";
|
||||||
|
|
||||||
@@ -70,6 +77,8 @@ export class LecturePaneView {
|
|||||||
private pdfSyncEnabled = true;
|
private pdfSyncEnabled = true;
|
||||||
private explainingSelection = false;
|
private explainingSelection = false;
|
||||||
private lastSelectionResult?: SelectionExplainResult;
|
private lastSelectionResult?: SelectionExplainResult;
|
||||||
|
private unregisterPlayback?: () => void;
|
||||||
|
private explainSessionId = 0;
|
||||||
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
|
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
|
||||||
|
|
||||||
private statusEl!: HTMLElement;
|
private statusEl!: HTMLElement;
|
||||||
@@ -802,6 +811,139 @@ export class LecturePaneView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async onExplainSelection(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
private async onSaveNote(): Promise<void> {
|
||||||
if (!this.cacheKey) return;
|
if (!this.cacheKey) return;
|
||||||
const data = await loadLectureData(this.cacheKey);
|
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;
|
this.statusEl.dataset.status = status;
|
||||||
|
if (kind) this.statusEl.dataset.kind = kind;
|
||||||
|
else this.statusEl.removeAttribute("data-kind");
|
||||||
this.statusTextEl.textContent =
|
this.statusTextEl.textContent =
|
||||||
message || getString("lecture-status-idle");
|
message || getString("lecture-status-idle");
|
||||||
const busy = [
|
const busy = [
|
||||||
@@ -918,6 +1066,8 @@ export class LecturePaneView {
|
|||||||
"tts_generating",
|
"tts_generating",
|
||||||
].includes(status);
|
].includes(status);
|
||||||
this.actionBtn.disabled = this.preparing || busy;
|
this.actionBtn.disabled = this.preparing || busy;
|
||||||
|
this.explainSelectionBtn.disabled =
|
||||||
|
this.preparing || busy || this.explainingSelection;
|
||||||
this.ttsTestBtn.disabled = this.ttsTesting || this.preparing;
|
this.ttsTestBtn.disabled = this.ttsTesting || this.preparing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { config } from "../../../../package.json";
|
import { config } from "../../../../package.json";
|
||||||
import { getString } from "../../../utils/locale";
|
import { getString } from "../../../utils/locale";
|
||||||
import { cacheReaderSelection } from "../../pdf/selection";
|
import { cacheReaderSelection, getCachedReaderSelectionPosition } from "../../pdf/selection";
|
||||||
import { highlightTextInPdf } from "../../pdf/readerSync";
|
import { highlightTextInPdf } from "../../pdf/readerSync";
|
||||||
import { getRegularItem, resolvePaperAttachment } from "../infrastructure/zotero/adapter";
|
import { getRegularItem, resolvePaperAttachment } from "../infrastructure/zotero/adapter";
|
||||||
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||||
import { explainSelectionLecture } from "../application/explainSelectionLecture";
|
import { explainSelectionLecture } from "../application/explainSelectionLecture";
|
||||||
import { playSelectionExplainStandalone } from "../application/playSelectionExplain";
|
import {
|
||||||
|
beginSelectionExplainSession,
|
||||||
|
isSelectionExplainSessionActive,
|
||||||
|
playSelectionExplainExclusive,
|
||||||
|
} from "../application/playSelectionExplain";
|
||||||
import { lectureEvents } from "../infrastructure/events";
|
import { lectureEvents } from "../infrastructure/events";
|
||||||
|
|
||||||
function tryString(value: unknown): string {
|
function tryString(value: unknown): string {
|
||||||
@@ -47,6 +51,7 @@ export async function runSelectionExplainFromReader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cacheReaderSelection(text);
|
cacheReaderSelection(text);
|
||||||
|
const sessionId = beginSelectionExplainSession();
|
||||||
showHeadline(getString("lecture-selection-explaining"));
|
showHeadline(getString("lecture-selection-explaining"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,9 +62,12 @@ export async function runSelectionExplainFromReader(
|
|||||||
onProgress: (message) => showHeadline(message),
|
onProgress: (message) => showHeadline(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isSelectionExplainSessionActive(sessionId)) return;
|
||||||
|
|
||||||
await highlightTextInPdf(item, {
|
await highlightTextInPdf(item, {
|
||||||
text: result.original,
|
text: result.original,
|
||||||
page: result.page,
|
page: result.page,
|
||||||
|
position: getCachedReaderSelectionPosition(result.original),
|
||||||
});
|
});
|
||||||
|
|
||||||
lectureEvents.emit("lecture:selection_explain", {
|
lectureEvents.emit("lecture:selection_explain", {
|
||||||
@@ -69,7 +77,9 @@ export async function runSelectionExplainFromReader(
|
|||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
|
|
||||||
await playSelectionExplainStandalone(result);
|
if (!isSelectionExplainSessionActive(sessionId)) return;
|
||||||
|
|
||||||
|
await playSelectionExplainExclusive(result);
|
||||||
showHeadline(getString("lecture-selection-done"));
|
showHeadline(getString("lecture-selection-done"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(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";
|
"toolbar-button wide-button chatpapers-selection-explain-btn";
|
||||||
btn.textContent = getString("lecture-explain-selection-short");
|
btn.textContent = getString("lecture-explain-selection-short");
|
||||||
btn.title = getString("lecture-explain-selection");
|
btn.title = getString("lecture-explain-selection");
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev: Event) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
void runSelectionExplainFromReader(reader, text);
|
void runSelectionExplainFromReader(reader, text);
|
||||||
});
|
}, { once: true });
|
||||||
append(btn);
|
append(btn);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e);
|
ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e);
|
||||||
|
|||||||
+331
-42
@@ -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).
|
* Uses internal reader APIs (best-effort; may break across Zotero versions).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { findPdfAttachment } from "./extractor";
|
import { findPdfAttachment } from "./extractor";
|
||||||
|
import {
|
||||||
|
getCachedReaderSelectionPosition,
|
||||||
|
normalizeHighlightText,
|
||||||
|
} from "./selection";
|
||||||
|
|
||||||
|
export interface PdfPosition {
|
||||||
|
pageIndex: number;
|
||||||
|
rects?: number[][];
|
||||||
|
nextPageRects?: number[][];
|
||||||
|
}
|
||||||
|
|
||||||
function getInternalReader(reader: any): any {
|
function getInternalReader(reader: any): any {
|
||||||
if (!reader) return undefined;
|
if (!reader) return undefined;
|
||||||
@@ -53,10 +63,7 @@ function findReaderForAttachment(attachmentId: number): any {
|
|||||||
|
|
||||||
/** Build a short phrase suitable for PDF in-document search. */
|
/** Build a short phrase suitable for PDF in-document search. */
|
||||||
export function buildPdfSearchQuery(text: string, maxLen = 72): string {
|
export function buildPdfSearchQuery(text: string, maxLen = 72): string {
|
||||||
const normalized = text
|
const normalized = normalizeHighlightText(text);
|
||||||
.replace(/[\u0000-\u001f\f]/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
if (!normalized) return "";
|
if (!normalized) return "";
|
||||||
if (normalized.length <= maxLen) return normalized;
|
if (normalized.length <= maxLen) return normalized;
|
||||||
const snippet = normalized.slice(0, maxLen);
|
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;
|
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[] {
|
function buildQueryCandidates(text: string): string[] {
|
||||||
const normalized = text
|
const normalized = normalizeHighlightText(text);
|
||||||
.replace(/[\u0000-\u001f\f]/g, " ")
|
if (!normalized) return [];
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
const words = normalized.split(/\s+/).filter((w) => w.length > 1);
|
|
||||||
const out: string[] = [];
|
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(" "));
|
push(trimQuery(normalized, 480));
|
||||||
if (words.length >= 5) out.push(words.slice(0, 6).join(" "));
|
push(trimQuery(normalized, 320));
|
||||||
if (words.length >= 3) out.push(words.slice(0, 4).join(" "));
|
push(trimQuery(normalized, 220));
|
||||||
|
push(trimQuery(normalized, 140));
|
||||||
|
push(buildPdfSearchQuery(normalized, 96));
|
||||||
|
push(buildPdfSearchQuery(normalized, 72));
|
||||||
|
|
||||||
const q50 = buildPdfSearchQuery(text, 50);
|
const words = normalized.split(/\s+/).filter((w) => w.length > 1);
|
||||||
const q30 = buildPdfSearchQuery(text, 30);
|
if (words.length >= 12) push(words.slice(0, 14).join(" "));
|
||||||
if (q50) out.push(q50);
|
if (words.length >= 8) push(words.slice(0, 10).join(" "));
|
||||||
if (q30 && q30 !== q50) out.push(q30);
|
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<T>(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<void> {
|
async function waitForReaderReady(reader: any): Promise<void> {
|
||||||
@@ -138,12 +244,7 @@ function applyFindHighlight(reader: any, query: string): boolean {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (internal?._updateState) {
|
if (internal?._updateState) {
|
||||||
const win = reader?._iframeWindow;
|
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
|
||||||
const state =
|
|
||||||
win && typeof Components !== "undefined"
|
|
||||||
? Components.utils.cloneInto(payload, win)
|
|
||||||
: payload;
|
|
||||||
internal._updateState(state);
|
|
||||||
try {
|
try {
|
||||||
internal.findNext?.(true);
|
internal.findNext?.(true);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -180,12 +281,7 @@ function clearFindHighlight(reader: any): void {
|
|||||||
try {
|
try {
|
||||||
const internal = getInternalReader(reader);
|
const internal = getInternalReader(reader);
|
||||||
if (internal?._updateState) {
|
if (internal?._updateState) {
|
||||||
const win = reader?._iframeWindow;
|
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
|
||||||
const state =
|
|
||||||
win && typeof Components !== "undefined"
|
|
||||||
? Components.utils.cloneInto(payload, win)
|
|
||||||
: payload;
|
|
||||||
internal._updateState(state);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
getPrimaryView(reader)?.setFindState?.(cleared);
|
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<PdfPosition | undefined> {
|
||||||
|
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<number>();
|
||||||
|
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<PdfPosition | undefined> {
|
||||||
|
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 {
|
export interface PdfHighlightTarget {
|
||||||
text: string;
|
text: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
position?: PdfPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open PDF (if needed), jump to page, and highlight matching text. */
|
/** Open PDF (if needed), jump to page, and highlight matching text. */
|
||||||
@@ -207,9 +482,6 @@ export async function highlightTextInPdf(
|
|||||||
const attachment = findPdfAttachment(item);
|
const attachment = findPdfAttachment(item);
|
||||||
if (!attachment || !target.text?.trim()) return;
|
if (!attachment || !target.text?.trim()) return;
|
||||||
|
|
||||||
const candidates = buildQueryCandidates(target.text);
|
|
||||||
if (!candidates.length) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let reader = findReaderForAttachment(attachment.id);
|
let reader = findReaderForAttachment(attachment.id);
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
@@ -219,16 +491,32 @@ export async function highlightTextInPdf(
|
|||||||
|
|
||||||
await waitForReaderReady(reader);
|
await waitForReaderReady(reader);
|
||||||
await navigateReader(reader, target.page);
|
await navigateReader(reader, target.page);
|
||||||
await Zotero.Promise.delay(350);
|
await Zotero.Promise.delay(250);
|
||||||
|
|
||||||
for (const query of candidates) {
|
const cachedPosition = getCachedReaderSelectionPosition(target.text);
|
||||||
if (applyFindHighlight(reader, query)) {
|
const position =
|
||||||
await Zotero.Promise.delay(200);
|
normalizePdfPosition(target.position, target.page) ??
|
||||||
ztoolkit.log("[ChatPapers:ReaderSync] find applied:", query.slice(0, 40));
|
normalizePdfPosition(cachedPosition, target.page) ??
|
||||||
return;
|
(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) {
|
} catch (e) {
|
||||||
ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e);
|
ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e);
|
||||||
}
|
}
|
||||||
@@ -242,6 +530,7 @@ export async function clearPdfHighlight(item: Zotero.Item): Promise<void> {
|
|||||||
const reader = findReaderForAttachment(attachment.id);
|
const reader = findReaderForAttachment(attachment.id);
|
||||||
if (!reader) return;
|
if (!reader) return;
|
||||||
clearFindHighlight(reader);
|
clearFindHighlight(reader);
|
||||||
|
clearPositionHighlight(reader);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e);
|
ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,16 @@ function textFromReaderInternals(reader: any): string {
|
|||||||
|
|
||||||
// zotero-plugin-toolkit path: selection popup annotation
|
// zotero-plugin-toolkit path: selection popup annotation
|
||||||
try {
|
try {
|
||||||
const popupText =
|
const popup =
|
||||||
reader?._internalReader?._lastView?._selectionPopup?.annotation?.text ??
|
reader?._internalReader?._lastView?._selectionPopup ??
|
||||||
reader?._internalReader?._primaryView?._selectionPopup?.annotation?.text;
|
reader?._internalReader?._primaryView?._selectionPopup;
|
||||||
|
const popupText = popup?.annotation?.text;
|
||||||
const t = tryString(popupText);
|
const t = tryString(popupText);
|
||||||
if (t) return t;
|
if (t) {
|
||||||
|
const position = normalizePdfSelectionPosition(popup?.annotation?.position);
|
||||||
|
if (position) cacheReaderSelection(t, position);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -70,7 +75,13 @@ function textFromReaderInternals(reader: any): string {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const t = tryString(view._selectionPopup?.annotation?.text);
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -139,15 +150,83 @@ function listCandidateReaders(): any[] {
|
|||||||
|
|
||||||
/** Last non-empty selection cached while user selects in the reader. */
|
/** Last non-empty selection cached while user selects in the reader. */
|
||||||
let cachedSelection = "";
|
let cachedSelection = "";
|
||||||
|
let cachedSelectionPosition: PdfSelectionPosition | undefined;
|
||||||
let cachedAt = 0;
|
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<string, unknown>;
|
||||||
|
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();
|
const t = text.trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
const sameText =
|
||||||
|
normalizeHighlightText(cachedSelection) === normalizeHighlightText(t);
|
||||||
cachedSelection = t;
|
cachedSelection = t;
|
||||||
|
if (position) {
|
||||||
|
cachedSelectionPosition = position;
|
||||||
|
} else if (!sameText) {
|
||||||
|
cachedSelectionPosition = undefined;
|
||||||
|
}
|
||||||
cachedAt = Date.now();
|
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 {
|
export function getCachedReaderSelection(maxAgeMs = 120000): string {
|
||||||
if (!cachedSelection) return "";
|
if (!cachedSelection) return "";
|
||||||
if (Date.now() - cachedAt > maxAgeMs) return "";
|
if (Date.now() - cachedAt > maxAgeMs) return "";
|
||||||
@@ -200,7 +279,10 @@ export function registerReaderSelectionHook() {
|
|||||||
tryString(event?.params?.annotation?.text) ||
|
tryString(event?.params?.annotation?.text) ||
|
||||||
tryString(event?.params?.text) ||
|
tryString(event?.params?.text) ||
|
||||||
textFromReaderInternals(event?.reader);
|
textFromReaderInternals(event?.reader);
|
||||||
if (text) cacheReaderSelection(text);
|
const position = normalizePdfSelectionPosition(
|
||||||
|
event?.params?.annotation?.position,
|
||||||
|
);
|
||||||
|
if (text) cacheReaderSelection(text, position);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ztoolkit.log("selection popup hook failed", e);
|
ztoolkit.log("selection popup hook failed", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Helpers for Zotero ItemPane collapsible sections. */
|
/** Helpers for Zotero ItemPane collapsible sections. */
|
||||||
|
|
||||||
|
import { config } from "../../../package.json";
|
||||||
import { getLocaleID, getString } from "../../utils/locale";
|
import { getLocaleID, getString } from "../../utils/locale";
|
||||||
import { getPref, setPref } from "../../utils/prefs";
|
import { getPref, setPref } from "../../utils/prefs";
|
||||||
import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow";
|
import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow";
|
||||||
@@ -17,9 +18,14 @@ const FIXED_HEIGHT: Record<ItemPaneKind, number> = {
|
|||||||
lecture: 420,
|
lecture: 420,
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPEN_WINDOW_ICON = "chrome://zotero/skin/16/universal/open.svg";
|
/** Lucide-derived toolbar icons (see addon/content/icons/pane-*.svg). */
|
||||||
const ADAPTIVE_ICON = "chrome://zotero/skin/16/universal/expand-all.svg";
|
function paneIconURL(file: string): string {
|
||||||
const ADAPTIVE_ICON_OFF = "chrome://zotero/skin/16/universal/collapse-all.svg";
|
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 {
|
export function prepareItemPaneBody(body: HTMLElement): void {
|
||||||
body.classList.add("chatpapers-pane-body");
|
body.classList.add("chatpapers-pane-body");
|
||||||
|
|||||||
@@ -2,6 +2,26 @@ import type { IconNode } from "lucide";
|
|||||||
|
|
||||||
export type LucideIcon = IconNode;
|
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 = [
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"',
|
||||||
|
'fill="none" stroke="context-stroke" stroke-width="2"',
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round">',
|
||||||
|
body,
|
||||||
|
"</svg>",
|
||||||
|
].join("");
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createLucideIcon(
|
export function createLucideIcon(
|
||||||
doc: Document,
|
doc: Document,
|
||||||
icon: LucideIcon,
|
icon: LucideIcon,
|
||||||
|
|||||||
Vendored
+10
@@ -49,6 +49,8 @@ export type FluentMessageId =
|
|||||||
| 'lecture-beat-tts-pending'
|
| 'lecture-beat-tts-pending'
|
||||||
| 'lecture-empty-desc'
|
| 'lecture-empty-desc'
|
||||||
| 'lecture-empty-title'
|
| 'lecture-empty-title'
|
||||||
|
| 'lecture-explain-selection'
|
||||||
|
| 'lecture-explain-selection-short'
|
||||||
| 'lecture-granularity-paragraph'
|
| 'lecture-granularity-paragraph'
|
||||||
| 'lecture-granularity-sentence'
|
| 'lecture-granularity-sentence'
|
||||||
| 'lecture-loading'
|
| 'lecture-loading'
|
||||||
@@ -76,6 +78,14 @@ export type FluentMessageId =
|
|||||||
| 'lecture-playing-zh'
|
| 'lecture-playing-zh'
|
||||||
| 'lecture-reprepare'
|
| 'lecture-reprepare'
|
||||||
| 'lecture-save-note'
|
| '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-label'
|
||||||
| 'lecture-sentence-list-hint'
|
| 'lecture-sentence-list-hint'
|
||||||
| 'lecture-sentence-list-title'
|
| 'lecture-sentence-list-title'
|
||||||
|
|||||||
Reference in New Issue
Block a user