增加阅读是pdf中高亮显示

This commit is contained in:
yhy
2026-08-29 22:46:28 +08:00
parent 3a2438f566
commit 1475f8b3c5
17 changed files with 2125 additions and 95 deletions
+200 -4
View File
@@ -3,13 +3,32 @@
flex-direction: column;
gap: 8px;
padding: 8px;
min-height: 320px;
min-height: 0;
box-sizing: border-box;
font-size: 13px;
-moz-user-select: text;
user-select: text;
}
.chatpapers-pane-body {
min-height: 0 !important;
}
collapsible-section:not([open]) > :not(.head) .chatpapers-root,
collapsible-section:not([open]) > :not(.head) .chatpapers-lecture-root {
min-height: 0 !important;
padding-block: 0;
overflow: hidden;
}
collapsible-section[open] > :not(.head) .chatpapers-root {
min-height: 240px;
}
collapsible-section[open] > :not(.head) .chatpapers-lecture-root {
min-height: 180px;
}
.chatpapers-header {
display: flex;
flex-direction: column;
@@ -216,9 +235,9 @@
}
.chatpapers-messages {
flex: 1;
min-height: 180px;
max-height: 420px;
flex: 1 1 auto;
min-height: 0;
max-height: min(420px, 45vh);
overflow: auto;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 8px;
@@ -856,6 +875,183 @@
line-height: 1.4;
}
.chatpapers-lecture-mode-tabs {
display: flex;
gap: 0;
padding: 3px;
border-radius: 10px;
background: #e8eef0;
border: 1px solid #c5d3d8;
}
.chatpapers-lecture-mode-tab {
flex: 1;
-moz-appearance: none;
appearance: none;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 12px;
font-weight: 700;
cursor: pointer;
color: #4a5568;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.chatpapers-lecture-mode-tab:hover:not(.is-active) {
background: rgba(255, 255, 255, 0.55);
color: #2d3748;
}
.chatpapers-lecture-mode-tab.is-active,
.chatpapers-lecture-mode-tab[aria-selected="true"] {
color: #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.chatpapers-lecture-mode-tabs.mode-paragraph
.chatpapers-lecture-mode-tab[data-mode="paragraph"].is-active,
.chatpapers-lecture-mode-tabs.mode-paragraph
.chatpapers-lecture-mode-tab[data-mode="paragraph"][aria-selected="true"] {
background: #1f7a8c;
}
.chatpapers-lecture-mode-tabs.mode-overview
.chatpapers-lecture-mode-tab[data-mode="overview"].is-active,
.chatpapers-lecture-mode-tabs.mode-overview
.chatpapers-lecture-mode-tab[data-mode="overview"][aria-selected="true"] {
background: #2563eb;
}
.chatpapers-lecture-granularity-tabs {
display: none;
gap: 0;
padding: 2px;
margin-top: 2px;
border-radius: 8px;
background: #f3f6f8;
border: 1px solid #d8e2e8;
}
.chatpapers-lecture-granularity-tabs.is-visible {
display: flex;
}
.chatpapers-lecture-granularity-tab {
flex: 1;
-moz-appearance: none;
appearance: none;
padding: 6px 8px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 11px;
font-weight: 700;
cursor: pointer;
color: #64748b;
transition: background 0.15s ease, color 0.15s ease;
}
.chatpapers-lecture-granularity-tab:hover:not(.is-active) {
background: rgba(255, 255, 255, 0.7);
color: #334155;
}
.chatpapers-lecture-granularity-tab.is-active,
.chatpapers-lecture-granularity-tab[aria-selected="true"] {
background: #ffffff;
color: #1f7a8c;
box-shadow: 0 1px 3px rgba(31, 122, 140, 0.2);
outline: 1px solid rgba(31, 122, 140, 0.35);
}
.chatpapers-lecture-sentence-item .chatpapers-lecture-beat-title {
font-size: 11px;
}
.chatpapers-lecture-para-original.is-active-sentence {
border-left: 3px solid #1f7a8c;
padding-left: 8px;
background: color-mix(in srgb, #1f7a8c 8%, transparent);
}
.chatpapers-lecture-sentence-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.chatpapers-lecture-sentence-chip {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, #1f7a8c 20%, transparent);
cursor: pointer;
font-size: 11px;
line-height: 1.35;
}
.chatpapers-lecture-sentence-chip.is-active {
background: color-mix(in srgb, #1f7a8c 12%, transparent);
border-color: color-mix(in srgb, #1f7a8c 40%, transparent);
}
.chatpapers-lecture-pdf-sync-hint {
margin-top: 4px;
font-size: 10px;
opacity: 0.72;
font-style: italic;
}
.chatpapers-lecture-para-detail {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border-radius: 8px;
background: color-mix(in srgb, #1f7a8c 5%, transparent);
border: 1px solid color-mix(in srgb, #1f7a8c 15%, transparent);
font-size: 12px;
line-height: 1.45;
max-height: 200px;
overflow-y: auto;
}
.chatpapers-lecture-para-kind {
font-size: 11px;
font-weight: 600;
opacity: 0.75;
}
.chatpapers-lecture-para-role {
font-size: 11px;
opacity: 0.85;
font-style: italic;
}
.chatpapers-lecture-para-original-label,
.chatpapers-lecture-para-translation-label,
.chatpapers-lecture-para-context-label {
font-size: 10px;
font-weight: 600;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-top: 4px;
}
.chatpapers-lecture-para-original {
opacity: 0.9;
}
.chatpapers-lecture-para-translation {
font-weight: 500;
}
.chatpapers-lecture-para-context {
opacity: 0.85;
}
.chatpapers-lecture-player {
display: flex;
flex-direction: column;
+46 -10
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
<!-- ChatPapers voice lecture: paper + headphones -->
<!-- ChatPapers 语音伴读:论文 + 声波 + 耳机(与对话气泡图标区分) -->
<path
fill="context-fill"
fill-opacity="0.92"
@@ -10,19 +10,55 @@
fill-opacity="0.35"
d="M10.6 2.85v2.35c0 .45.37.82.82.82h2.35L10.6 2.85z"
/>
<!-- 声波(替代对话图标的横线文本) -->
<rect x="4.4" y="8.35" width="1.15" height="2.8" rx="0.4" fill="context-fill" fill-opacity="0.5" />
<rect x="6.05" y="7.25" width="1.15" height="4.2" rx="0.4" fill="context-fill" fill-opacity="0.72" />
<rect x="7.7" y="8.65" width="1.15" height="2.2" rx="0.4" fill="context-fill" fill-opacity="0.5" />
<rect x="9.35" y="7.75" width="1.15" height="3.5" rx="0.4" fill="context-fill" fill-opacity="0.62" />
<!-- 耳机头梁 -->
<path
stroke="context-fill"
stroke-opacity="0.92"
stroke-width="1.15"
stroke-linecap="round"
fill="none"
d="M12.85 9.15c0-1.55 1.15-2.8 2.55-2.8s2.55 1.25 2.55 2.8"
/>
<!-- 左耳罩 -->
<rect
x="11.55"
y="9.05"
width="2.15"
height="3.55"
rx="0.85"
fill="context-fill"
fill-opacity="0.92"
/>
<!-- 右耳罩 -->
<rect
x="14.55"
y="9.05"
width="2.15"
height="3.55"
rx="0.85"
fill="context-fill"
fill-opacity="0.92"
/>
<!-- 向外声波弧线 -->
<path
stroke="context-stroke"
stroke-opacity="0.55"
stroke-width="0.9"
stroke-opacity="0.45"
stroke-width="0.85"
stroke-linecap="round"
d="M4.35 7.1h5.1M4.35 9.15h4.2"
fill="none"
d="M17.15 10.1c.55.45.9 1.05.9 1.75s-.35 1.3-.9 1.75"
/>
<path
fill="context-fill"
d="M12.1 8.35c1.45 0 2.65 1.15 2.65 2.55v2.1c0 .35-.28.62-.62.62h-.75v1.05c0 .55-.45 1-1 1h-.55c-.55 0-1-.45-1-1v-1.05h-.55c-.35 0-.62-.28-.62-.62v-2.1c0-1.4 1.2-2.55 2.65-2.55z"
/>
<path
fill="context-fill"
d="M16.55 9.05c1.05 0 1.9.82 1.9 1.85v1.35c0 1-.85 1.85-1.9 1.85-.35 0-.62-.28-.62-.62v-3.8c0-.35.28-.62.62-.62z"
stroke="context-stroke"
stroke-opacity="0.3"
stroke-width="0.85"
stroke-linecap="round"
fill="none"
d="M17.85 10.1c.85.7 1.35 1.65 1.35 2.75s-.5 2.05-1.35 2.75"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 1.9 KiB

+18
View File
@@ -82,6 +82,24 @@ lecture-play-complete = Playback finished
lecture-beat-tts-pending = Synthesizing
lecture-beat-tts-failed = Failed
lecture-reprepare = Re-prepare
lecture-save-note = Save note
lecture-note-saved = Lecture note saved to Zotero
lecture-mode-overview = Overview
lecture-mode-paragraph = Paragraph reading
lecture-paragraph-list-title = Paper paragraphs (original, then translation)
lecture-para-label = Para { $n }
lecture-para-original = Original
lecture-para-translation = Translation
lecture-para-context = Context
lecture-granularity-paragraph = By paragraph
lecture-granularity-sentence = By sentence
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-playing-en = Playing: original
lecture-playing-zh = Playing: translation
lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced
lecture-no-pdf = No PDF attachment on this item
lecture-attach-error = Cannot read PDF attachment
lecture-mount-error = Failed to load voice lecture pane
+18
View File
@@ -82,6 +82,24 @@ lecture-play-complete = 本轮讲解播放完毕
lecture-beat-tts-pending = 合成中
lecture-beat-tts-failed = 失败
lecture-reprepare = 重新备课
lecture-save-note = 保存笔记
lecture-note-saved = 伴读笔记已写入 Zotero 子笔记
lecture-mode-overview = 整体讲解
lecture-mode-paragraph = 逐段精读
lecture-paragraph-list-title = 论文段落(先读原文,再听译文)
lecture-para-label = 段落 { $n }
lecture-para-original = 原文
lecture-para-translation = 译文 / 讲解
lecture-para-context = 上下文衔接
lecture-granularity-paragraph = 按段
lecture-granularity-sentence = 按句
lecture-sentence-list-title = 逐句精读(先读原文,再听讲解)
lecture-sentence-label = 段{ $p }·句{ $s }
lecture-sentence-list-hint = 本段各句
lecture-pdf-sync-hint = 播放时会在 PDF 阅读器中高亮对应原文。
lecture-playing-en = 正在播放:原文朗读
lecture-playing-zh = 正在播放:中文讲解
lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步
lecture-no-pdf = 当前条目没有可用的 PDF 附件
lecture-attach-error = 无法读取 PDF 附件
lecture-mount-error = 语音伴读面板加载失败
@@ -0,0 +1,109 @@
import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm";
import type { LectureData } from "../infrastructure/storage/lectureStore";
import { createChildNote } from "../../zotero/notes";
import { markdownToNoteHtml } from "../../../utils/markdown";
const NOTE_TITLE_PREFIX = "ChatPapers 伴读笔记:";
export function buildLectureNoteMarkdown(
data: LectureData,
paperTitle: string,
): string {
const lines: string[] = [
`> 由 ChatPapers 语音伴读自动生成。`,
"",
];
if (data.summary) {
lines.push("## 全文摘要", "", formatSummaryForDisplay(data.summary), "");
}
if (data.paragraphs?.length) {
lines.push("## 逐段精读(原文 + 译文 + 上下文)", "");
for (const p of data.paragraphs) {
if (p.genStatus !== "ready") continue;
lines.push(
`### 段落 ${p.orderIndex + 1}`,
"",
`**在全文中的作用**${p.roleInPaper || "—"}`,
"",
"**原文**",
"",
"```",
p.originalText,
"```",
"",
"**译文 / 讲解**",
"",
p.translation,
"",
`**上下文衔接**${p.contextLink || "—"}`,
"",
"---",
"",
);
}
}
if (data.beats?.length) {
lines.push("## 整体讲解(beats", "");
for (const b of data.beats) {
lines.push(`### ${b.title || b.id}`, "", b.script, "");
}
}
lines.push(
"",
`---`,
`*论文:${paperTitle} · 更新于 ${new Date(data.updatedAt).toLocaleString()}*`,
);
return lines.join("\n");
}
function findExistingLectureNote(
parentItem: Zotero.Item,
noteTitle: string,
): Zotero.Item | undefined {
try {
const noteIds = parentItem.getNotes();
for (const id of noteIds) {
const note = Zotero.Items.get(id) as Zotero.Item | false;
if (!note) continue;
const html = String(note.getNote() || "");
if (
html.includes(NOTE_TITLE_PREFIX) ||
html.includes(noteTitle) ||
html.includes("ChatPapers 伴读")
) {
return note;
}
}
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] findExistingLectureNote failed", e);
}
return undefined;
}
export async function upsertLectureNote(options: {
parentItem: Zotero.Item;
data: LectureData;
paperTitle: string;
}): Promise<Zotero.Item> {
const noteTitle = `${NOTE_TITLE_PREFIX} ${options.paperTitle}`;
const bodyMarkdown = buildLectureNoteMarkdown(options.data, options.paperTitle);
const html = markdownToNoteHtml(noteTitle, bodyMarkdown);
const existing = findExistingLectureNote(options.parentItem, noteTitle);
if (existing) {
existing.setNote(html);
await existing.saveTx();
return existing;
}
return createChildNote({
parentItem: options.parentItem,
title: noteTitle,
bodyMarkdown,
});
}
@@ -0,0 +1,306 @@
import type {
StoredParagraph,
StoredSentence,
} from "../infrastructure/storage/lectureStore";
import { pathToFileUrl } from "../infrastructure/platform/paths";
export type ReadGranularity = "paragraph" | "sentence";
interface AudioSegment {
paragraphId: string;
sentenceId?: string;
kind: "en" | "zh";
audioPath: string;
highlightText: string;
page?: number;
}
export interface PlayParagraphHandle {
play: () => Promise<void>;
pause: () => void;
next: () => Promise<void>;
prev: () => Promise<void>;
jumpToParagraphId: (paragraphId: string, kind?: "en" | "zh") => Promise<void>;
jumpToSentenceId: (sentenceId: string, kind?: "en" | "zh") => Promise<void>;
getCurrentParagraphId: () => string | undefined;
getCurrentSentenceId: () => string | undefined;
getCurrentKind: () => "en" | "zh" | undefined;
getCurrentHighlight: () => { text: string; page?: number } | undefined;
isPlaying: () => boolean;
destroy: () => void;
setParagraphs: (paragraphs: StoredParagraph[]) => void;
setGranularity: (granularity: ReadGranularity) => void;
}
function sentenceReady(s: StoredSentence, kind: "en" | "zh"): boolean {
if (kind === "en") {
return s.ttsEnStatus === "ready" && !!s.audioPathEn;
}
return s.ttsZhStatus === "ready" && !!s.audioPathZh && !!s.translation;
}
function paragraphReady(p: StoredParagraph, kind: "en" | "zh"): boolean {
if (kind === "en") {
return p.ttsEnStatus === "ready" && !!p.audioPathEn && p.genStatus === "ready";
}
return p.ttsZhStatus === "ready" && !!p.audioPathZh && p.genStatus === "ready";
}
function pushSentenceSegments(
segments: AudioSegment[],
paragraphId: string,
sentences: StoredSentence[],
): void {
for (const s of sentences) {
if (sentenceReady(s, "en")) {
segments.push({
paragraphId,
sentenceId: s.id,
kind: "en",
audioPath: s.audioPathEn!,
highlightText: s.text,
page: s.page,
});
}
if (sentenceReady(s, "zh")) {
segments.push({
paragraphId,
sentenceId: s.id,
kind: "zh",
audioPath: s.audioPathZh!,
highlightText: s.text,
page: s.page,
});
}
}
}
function buildSegments(
paragraphs: StoredParagraph[],
granularity: ReadGranularity,
): AudioSegment[] {
const segments: AudioSegment[] = [];
for (const p of paragraphs) {
if (p.genStatus !== "ready") continue;
const sentences = (p.sentences ?? []).filter(
(s) => sentenceReady(s, "en") || sentenceReady(s, "zh"),
);
const useSentences =
granularity === "sentence" &&
sentences.some((s) => sentenceReady(s, "en") || sentenceReady(s, "zh"));
if (useSentences) {
pushSentenceSegments(segments, p.id, sentences);
continue;
}
if (paragraphReady(p, "en")) {
segments.push({
paragraphId: p.id,
kind: "en",
audioPath: p.audioPathEn!,
highlightText: p.originalText,
page: p.page,
});
}
if (paragraphReady(p, "zh")) {
segments.push({
paragraphId: p.id,
kind: "zh",
audioPath: p.audioPathZh!,
highlightText: p.originalText,
page: p.page,
});
}
}
return segments;
}
export function createParagraphPlayer(options: {
audio: HTMLAudioElement;
paragraphs: StoredParagraph[];
granularity?: ReadGranularity;
onSegmentChange?: (info: {
paragraphId: string | undefined;
sentenceId: string | undefined;
kind: "en" | "zh" | undefined;
highlightText?: string;
page?: number;
}) => void;
onPlayingChange?: (playing: boolean) => void;
onComplete?: () => void;
}): PlayParagraphHandle {
let granularity = options.granularity ?? "paragraph";
let segments = buildSegments(options.paragraphs, granularity);
let index = 0;
let playing = false;
const notify = () => {
const seg = segments[index];
options.onSegmentChange?.({
paragraphId: seg?.paragraphId,
sentenceId: seg?.sentenceId,
kind: seg?.kind,
highlightText: seg?.highlightText,
page: seg?.page,
});
};
const loadIndex = (i: number): boolean => {
if (i < 0 || i >= segments.length) return false;
index = i;
const seg = segments[i];
options.audio.src = pathToFileUrl(seg.audioPath);
options.audio.load();
notify();
return true;
};
options.audio.onended = () => {
void (async () => {
if (index + 1 < segments.length) {
index += 1;
if (loadIndex(index)) {
try {
await options.audio.play();
} catch {
playing = false;
options.onPlayingChange?.(false);
}
}
} else {
playing = false;
options.onPlayingChange?.(false);
options.onComplete?.();
}
})();
};
return {
setGranularity(next) {
granularity = next;
const current = segments[index];
segments = buildSegments(options.paragraphs, granularity);
if (current) {
const i = segments.findIndex(
(s) =>
s.paragraphId === current.paragraphId &&
s.sentenceId === current.sentenceId &&
s.kind === current.kind,
);
index = i >= 0 ? i : 0;
} else {
index = 0;
}
notify();
},
setParagraphs(next) {
options.paragraphs = next;
const current = segments[index];
segments = buildSegments(next, granularity);
if (current) {
const i = segments.findIndex(
(s) =>
s.paragraphId === current.paragraphId &&
s.sentenceId === current.sentenceId &&
s.kind === current.kind,
);
index = i >= 0 ? i : Math.min(index, Math.max(0, segments.length - 1));
} else if (index >= segments.length) {
index = Math.max(0, segments.length - 1);
}
notify();
},
async play() {
if (!segments.length) return;
if (!options.audio.src) {
loadIndex(index);
} else {
notify();
}
try {
await options.audio.play();
playing = true;
options.onPlayingChange?.(true);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] paragraph play failed", e);
playing = false;
options.onPlayingChange?.(false);
}
},
pause() {
options.audio.pause();
playing = false;
options.onPlayingChange?.(false);
},
async next() {
if (index + 1 >= segments.length) return;
index += 1;
if (loadIndex(index)) await this.play();
},
async prev() {
if (index <= 0) return;
index -= 1;
if (loadIndex(index)) await this.play();
},
async jumpToParagraphId(paragraphId: string, kind: "en" | "zh" = "en") {
const i = segments.findIndex(
(s) =>
s.paragraphId === paragraphId &&
!s.sentenceId &&
(kind === "en" ? s.kind === "en" : s.kind === "zh"),
);
const fallback = segments.findIndex((s) => s.paragraphId === paragraphId);
const target = i >= 0 ? i : fallback;
if (target < 0) return;
index = target;
if (loadIndex(index)) await this.play();
},
async jumpToSentenceId(sentenceId: string, kind: "en" | "zh" = "en") {
const i = segments.findIndex(
(s) =>
s.sentenceId === sentenceId &&
(kind === "en" ? s.kind === "en" : s.kind === "zh"),
);
if (i < 0) return;
index = i;
if (loadIndex(index)) await this.play();
},
getCurrentParagraphId() {
return segments[index]?.paragraphId;
},
getCurrentSentenceId() {
return segments[index]?.sentenceId;
},
getCurrentKind() {
return segments[index]?.kind;
},
getCurrentHighlight() {
const seg = segments[index];
if (!seg) return undefined;
return { text: seg.highlightText, page: seg.page };
},
isPlaying() {
return playing && !options.audio.paused;
},
destroy() {
options.audio.onended = null;
options.audio.pause();
playing = false;
},
};
}
@@ -1,24 +1,31 @@
import type { LectureStatus, PaperCache } from "../domain/types";
import { upsertLectureNote } from "./generateLectureNote";
import { generateBeats } from "../infrastructure/llm/beatLlm";
import {
formatSummaryForDisplay,
generatePaperSummary,
} from "../infrastructure/llm/lectureLlm";
import {
buildRollingContext,
generateParagraphExplanation,
isPrimarilyEnglish,
toStoredParagraph,
} from "../infrastructure/llm/paragraphLlm";
import {
chunkPaperText,
formatChunkOutline,
} from "../infrastructure/pdf/chunker";
import { splitSentences } from "../infrastructure/pdf/sentenceSplit";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import {
ensureAudioDir,
} from "../infrastructure/storage/audioStorage";
import { lectureEvents } from "../infrastructure/events";
import {
isStep1Ready,
isLectureComplete,
loadLectureData,
mergeLectureData,
type LectureData,
type StoredBeat,
type StoredParagraph,
} from "../infrastructure/storage/lectureStore";
import { audioFilePath } from "../infrastructure/storage/paths";
import { paperRepository } from "../infrastructure/storage/repository";
@@ -33,6 +40,11 @@ import {
} from "../../pdf/extractor";
import { LlmError } from "../../llm/types";
/** Max paragraphs for Step 2 (long papers: lazy gen later). */
const MAX_PARAGRAPH_UNITS = 25;
const TTS_EN_MAX_CHARS = 600;
const TTS_SENTENCE_ZH_MAX_CHARS = 280;
export class PrepareLectureUseCase {
private inflight = new Map<string, Promise<PaperCache>>();
@@ -75,7 +87,7 @@ export class PrepareLectureUseCase {
}
const data = await loadLectureData(ctx.cacheKey);
if (paper.lectureStatus === "ready" && isStep1Ready(data)) {
if (paper.lectureStatus === "ready" && isLectureComplete(data)) {
return paper;
}
@@ -250,21 +262,293 @@ export class PrepareLectureUseCase {
data.phase = 2;
await mergeLectureData(cacheKey, paper.id, data);
// --- Paragraph explanations (Step 2) ---
const targetChunks = (data.chunks ?? []).slice(0, MAX_PARAGRAPH_UNITS);
let paragraphs: StoredParagraph[] = data.paragraphs ?? [];
const needsParaGen = targetChunks.some(
(c) =>
!paragraphs.find((p) => p.id === c.id && p.genStatus === "ready"),
);
if (needsParaGen && targetChunks.length) {
await emitStatus(
"paragraphs_generating",
`正在生成逐段译文(0/${targetChunks.length})…`,
70,
);
const rolling: Array<{ translation: string; contextLink: string }> = [];
let done = 0;
for (const chunk of targetChunks) {
const existing = paragraphs.find((p) => p.id === chunk.id);
if (existing?.genStatus === "ready") {
rolling.push({
translation: existing.translation,
contextLink: existing.contextLink,
});
if (rolling.length > 3) rolling.shift();
continue;
}
try {
const priorContext = buildRollingContext(rolling);
const llm = await generateParagraphExplanation({
paperTitle: title,
summary: data.summary!,
chunk,
priorContext,
});
const para = toStoredParagraph(chunk, llm);
paragraphs = paragraphs.filter((p) => p.id !== chunk.id);
paragraphs.push(para);
rolling.push({
translation: para.translation,
contextLink: para.contextLink,
});
if (rolling.length > 3) rolling.shift();
} catch (e) {
if (e instanceof LlmError) throw e;
ztoolkit.log("[ChatPapers:Lecture] paragraph gen failed", chunk.id, e);
paragraphs = paragraphs.filter((p) => p.id !== chunk.id);
paragraphs.push({
id: chunk.id,
orderIndex: chunk.orderIndex,
originalText: chunk.text,
translation: chunk.text.slice(0, 300),
contextLink: "",
roleInPaper: "",
ttsEnStatus: isPrimarilyEnglish(chunk.text) ? "pending" : "skipped",
ttsZhStatus: "pending",
genStatus: "failed",
});
}
done += 1;
data.paragraphs = paragraphs.sort(
(a, b) => a.orderIndex - b.orderIndex,
);
await mergeLectureData(cacheKey, paper.id, data);
lectureEvents.emit("lecture:paragraph_ready", {
paperId: paper.id,
paragraphId: chunk.id,
});
await emitStatus(
"paragraphs_generating",
`正在生成逐段译文(${done}/${targetChunks.length})…`,
70 + Math.floor((done / targetChunks.length) * 10),
);
}
}
// --- Backfill sentences for cached paragraphs (re-prepare upgrade) ---
paragraphs = (data.paragraphs ?? []).sort(
(a, b) => a.orderIndex - b.orderIndex,
);
let backfilled = false;
for (const para of paragraphs) {
if (para.genStatus === "ready" && !(para.sentences?.length ?? 0)) {
const chunk = targetChunks.find((c) => c.id === para.id);
para.sentences = splitSentences(para.originalText).map(
(text, orderIndex) => ({
id: `${para.id}-s${orderIndex}`,
orderIndex,
text,
translation:
orderIndex === 0 ? para.translation.slice(0, 120) : "",
page: chunk?.page ?? para.page,
searchText: text.slice(0, 80),
ttsEnStatus: isPrimarilyEnglish(text)
? ("pending" as const)
: ("skipped" as const),
ttsZhStatus: "pending" as const,
}),
);
backfilled = true;
}
}
if (backfilled) {
data.paragraphs = paragraphs;
await mergeLectureData(cacheKey, paper.id, data);
}
// --- TTS for paragraphs (EN then ZH per unit; then per-sentence) ---
const paraTtsPending = paragraphs.filter(
(p) =>
(p.ttsEnStatus === "pending" && isPrimarilyEnglish(p.originalText)) ||
p.ttsZhStatus !== "ready" ||
(p.sentences ?? []).some(
(s) =>
(s.ttsEnStatus === "pending" &&
isPrimarilyEnglish(s.text)) ||
s.ttsZhStatus === "pending",
),
);
if (paraTtsPending.length) {
await ensureAudioDir(cacheKey);
let ttsDone = 0;
const ttsTotal = paraTtsPending.length;
for (const para of paragraphs) {
let updated = false;
if (
para.ttsEnStatus === "pending" &&
isPrimarilyEnglish(para.originalText)
) {
try {
const enText = para.originalText.slice(0, TTS_EN_MAX_CHARS);
const outPath = audioFilePath(cacheKey, `${para.id}-en`, "wav");
const result = await synthesizeSpeech({
text: enText,
outputPath: outPath,
lang: "en",
});
para.audioPathEn = result.audioPath;
para.ttsEnStatus = "ready";
updated = true;
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] EN TTS failed", para.id, e);
para.ttsEnStatus = "failed";
updated = true;
}
}
if (para.ttsZhStatus !== "ready" && para.translation) {
try {
const outPath = audioFilePath(cacheKey, `${para.id}-zh`, "wav");
const result = await synthesizeSpeech({
text: para.translation.slice(0, 1200),
outputPath: outPath,
lang: "zh",
});
para.audioPathZh = result.audioPath;
para.ttsZhStatus = "ready";
updated = true;
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] ZH TTS failed", para.id, e);
para.ttsZhStatus = "failed";
updated = true;
}
}
for (const sentence of para.sentences ?? []) {
if (
sentence.ttsEnStatus === "pending" &&
isPrimarilyEnglish(sentence.text)
) {
try {
const outPath = audioFilePath(
cacheKey,
`${sentence.id}-en`,
"wav",
);
const result = await synthesizeSpeech({
text: sentence.text.slice(0, TTS_EN_MAX_CHARS),
outputPath: outPath,
lang: "en",
});
sentence.audioPathEn = result.audioPath;
sentence.ttsEnStatus = "ready";
updated = true;
} catch (e) {
ztoolkit.log(
"[ChatPapers:Lecture] sentence EN TTS failed",
sentence.id,
e,
);
sentence.ttsEnStatus = "failed";
updated = true;
}
}
if (sentence.ttsZhStatus === "pending" && sentence.translation) {
try {
const outPath = audioFilePath(
cacheKey,
`${sentence.id}-zh`,
"wav",
);
const result = await synthesizeSpeech({
text: sentence.translation.slice(0, TTS_SENTENCE_ZH_MAX_CHARS),
outputPath: outPath,
lang: "zh",
});
sentence.audioPathZh = result.audioPath;
sentence.ttsZhStatus = "ready";
updated = true;
} catch (e) {
ztoolkit.log(
"[ChatPapers:Lecture] sentence ZH TTS failed",
sentence.id,
e,
);
sentence.ttsZhStatus = "failed";
updated = true;
}
}
}
if (updated) {
ttsDone += 1;
data.paragraphs = paragraphs;
await mergeLectureData(cacheKey, paper.id, data);
if (para.audioPathZh) {
lectureEvents.emit("tts:unit_ready", {
paperId: paper.id,
unitId: para.id,
audioPath: para.audioPathZh,
});
}
await emitStatus(
"tts_generating",
`正在合成逐段/逐句语音(${ttsDone}/${ttsTotal})…`,
82 + Math.floor((ttsDone / ttsTotal) * 15),
);
}
}
}
if (!paragraphs.some((p) => p.ttsZhStatus === "ready")) {
throw new Error("逐段中文语音均合成失败,请检查 TTS 设置。");
}
// --- Zotero note ---
try {
const note = await upsertLectureNote({
parentItem: parent,
data: { ...data, paragraphs, summary: data.summary! },
paperTitle: title,
});
data.noteItemId = String(note.id);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] upsert note failed", e);
}
data.phase = 3;
await mergeLectureData(cacheKey, paper.id, data);
paper.lectureStatus = "ready";
paper.updatedAt = Date.now();
await paperRepository.upsert(cacheKey, paper);
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
const preview = formatSummaryForDisplay(data.summary!);
const readyBeats = beats.filter((b) => b.ttsStatus === "ready").length;
const readyParas = paragraphs.filter((p) => p.ttsZhStatus === "ready").length;
lectureEvents.emit("lecture:status", {
paperId: paper.id,
lectureStatus: "ready",
message: `备课完成:${readyCount}讲解可播放\n${preview}`,
message: `备课完成:${readyParas}逐段精读 + ${readyBeats} 段整体讲解\n笔记已写入 Zotero 子笔记`,
});
lectureEvents.emit("parse:progress", {
paperId: paper.id,
percent: 100,
message: "整体讲解已就绪",
message: "逐段精读与笔记已就绪",
});
} catch (e) {
paper.parseStatus = "failed";
@@ -0,0 +1,177 @@
import { parseJsonLoose } from "../../../llm/jsonParse";
import { LlmError } from "../../../llm/types";
import type { SummaryJson } from "../../domain/types";
import type { TextChunk } from "../pdf/chunker";
import { splitSentences } from "../pdf/sentenceSplit";
import type { StoredParagraph, StoredSentence } from "../storage/lectureStore";
import { requestStructuredOutput } from "./structuredOutput";
const PARA_SYSTEM = `你是 ChatPapers 论文伴读助手。用户会按论文物理顺序逐段听课。
必须只输出一个 JSON 对象,不要 markdown 代码块。
格式:{"translation":"...","contextLink":"...","roleInPaper":"...","sentences":[{"original":"...","translation":"..."}]}
要求:
- translation:连贯的中文。英文段落需先准确翻译,再用简短口语解释难词/公式(不要念 LaTeX);中文段落则做白话梳理。
- contextLink:1–2 句,说明本段与上一段及全文摘要的衔接(代词指代、逻辑转折等)。
- roleInPaper:本段在论文结构中的作用(如「方法开篇」「实验设置」)。
- sentences:将本段拆成 2–8 个朗读单元(按句号/问号/叹号分句),每项含 original(原文句)与 translation(该句的中文讲解,3080 字)。
- 术语与专有名词翻译前后一致;指代前文概念时用相同译法。`;
export interface ParagraphLlmResult {
translation: string;
contextLink: string;
roleInPaper: string;
sentences: Array<{ original: string; translation: string }>;
}
function parseSentences(
raw: unknown,
chunkText: string,
): Array<{ original: string; translation: string }> {
if (!Array.isArray(raw)) return [];
const parsed = raw
.map((item) => {
if (!item || typeof item !== "object") return null;
const o = item as Record<string, unknown>;
const original = String(o.original || o.text || o.en || "").trim();
const translation = String(o.translation || o.zh || "").trim();
if (!original) return null;
return { original, translation: translation || original };
})
.filter(Boolean) as Array<{ original: string; translation: string }>;
if (parsed.length) return parsed.slice(0, 12);
return splitSentences(chunkText).map((s) => ({
original: s,
translation: "",
}));
}
function parseParagraphJson(raw: string, chunkText: string): ParagraphLlmResult {
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
const translation = String(parsed.translation || parsed.zh || "").trim();
if (!translation) {
throw new LlmError("provider", "段落讲解 JSON 缺少 translation 字段。");
}
const sentences = parseSentences(parsed.sentences, chunkText);
return {
translation,
contextLink: String(parsed.contextLink || parsed.context || "").trim(),
roleInPaper: String(parsed.roleInPaper || parsed.role || "").trim(),
sentences,
};
}
function fallbackParagraph(chunk: TextChunk): ParagraphLlmResult {
const sentences = splitSentences(chunk.text).map((s) => ({
original: s,
translation: s.slice(0, 120),
}));
return {
translation: chunk.text.slice(0, 500),
contextLink: "(模型未能生成衔接说明,请重试备课或更换模型。)",
roleInPaper: "(待补充)",
sentences,
};
}
export async function generateParagraphExplanation(options: {
paperTitle: string;
summary: SummaryJson;
chunk: TextChunk;
priorContext: string;
}): Promise<ParagraphLlmResult> {
const summaryBlock = [
`问题:${options.summary.problem}`,
`方法:${options.summary.method}`,
`结果:${options.summary.result}`,
`局限:${options.summary.limitation}`,
].join("\n");
const user = `【论文标题】${options.paperTitle}
【全文摘要】
${summaryBlock}
【前文衔接(最近几段要点,可能为空)】
${options.priorContext || "(这是论文开头第一段)"}
【当前段落 ${options.chunk.id}
${options.chunk.text}
请输出 JSON。`;
return requestStructuredOutput({
system: PARA_SYSTEM,
user,
parse: (raw) => parseParagraphJson(raw, options.chunk.text),
jsonMode: true,
fallback: () => fallbackParagraph(options.chunk),
});
}
/** Detect if chunk is primarily English for TTS lang selection. */
export function isPrimarilyEnglish(text: string): boolean {
const letters = text.match(/[a-zA-Z]/g)?.length ?? 0;
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
return letters > cjk * 2 && letters > 40;
}
export function buildRollingContext(
recent: Array<{ translation: string; contextLink: string }>,
maxChars = 1200,
): string {
const parts = recent.map(
(p, i) =>
`[${i + 1}] ${p.contextLink ? `${p.contextLink} ` : ""}${p.translation.slice(0, 200)}`,
);
let out = parts.join("\n");
if (out.length > maxChars) out = out.slice(-maxChars);
return out;
}
function buildStoredSentences(
chunk: TextChunk,
llmSentences: Array<{ original: string; translation: string }>,
paragraphTranslation: string,
): StoredSentence[] {
const en = isPrimarilyEnglish(chunk.text);
return llmSentences.map((s, orderIndex) => {
const zh =
s.translation.trim() ||
(orderIndex === 0 ? paragraphTranslation.slice(0, 120) : "");
return {
id: `${chunk.id}-s${orderIndex}`,
orderIndex,
text: s.original,
translation: zh,
page: chunk.page,
searchText: s.original.slice(0, 80),
ttsEnStatus: en ? ("pending" as const) : ("skipped" as const),
ttsZhStatus: zh ? ("pending" as const) : ("failed" as const),
};
});
}
export function toStoredParagraph(
chunk: TextChunk,
llm: ParagraphLlmResult,
): StoredParagraph {
const en = isPrimarilyEnglish(chunk.text);
const sentences = buildStoredSentences(chunk, llm.sentences, llm.translation);
return {
id: chunk.id,
orderIndex: chunk.orderIndex,
originalText: chunk.text,
translation: llm.translation,
contextLink: llm.contextLink,
roleInPaper: llm.roleInPaper,
page: chunk.page,
searchText: chunk.searchText ?? chunk.text.slice(0, 80),
sentences,
ttsEnStatus: en ? "pending" : "skipped",
ttsZhStatus: "pending",
genStatus: "ready",
};
}
@@ -4,44 +4,85 @@ export interface TextChunk {
id: string;
orderIndex: number;
text: string;
/** 1-based page where this chunk starts (from PDF form-feed breaks). */
page?: number;
/** Short snippet for PDF in-document search. */
searchText?: string;
}
const MIN_CHUNK_CHARS = 200;
const MAX_CHUNK_CHARS = 900;
const MAX_CHUNKS = 40;
export function chunkPaperText(text: string): TextChunk[] {
const paragraphs = text
.split(/\n\s*\n+/)
.map((s) => s.replace(/\s+/g, " ").trim())
.filter((s) => s.length > 40);
interface RawPara {
text: string;
page: number;
}
const merged: string[] = [];
let buffer = "";
function normalizePara(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
for (const para of paragraphs) {
if (
buffer &&
(buffer.length + para.length > MAX_CHUNK_CHARS ||
(buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1))
) {
merged.push(buffer);
buffer = para;
} else {
buffer = buffer ? `${buffer} ${para}` : para;
function extractParagraphsWithPages(text: string): RawPara[] {
const hasPages = text.includes("\f");
const pageBlocks = hasPages ? text.split("\f") : [text];
const out: RawPara[] = [];
for (let pageIndex = 0; pageIndex < pageBlocks.length; pageIndex++) {
const pageNum = pageIndex + 1;
const paras = pageBlocks[pageIndex]
.split(/\n\s*\n+/)
.map(normalizePara)
.filter((s) => s.length > 40);
for (const para of paras) {
out.push({ text: para, page: pageNum });
}
}
if (buffer) merged.push(buffer);
if (!merged.length && text.trim()) {
merged.push(text.trim().slice(0, MAX_CHUNK_CHARS));
if (!out.length && text.trim()) {
out.push({ text: normalizePara(text).slice(0, MAX_CHUNK_CHARS), page: 1 });
}
return merged.slice(0, MAX_CHUNKS).map((chunkText, orderIndex) => ({
id: `p-${orderIndex}`,
orderIndex,
text: chunkText.slice(0, 2000),
}));
return out;
}
function mergeParagraphs(paras: RawPara[]): Array<{ text: string; page: number }> {
const merged: Array<{ text: string; page: number }> = [];
let buffer = "";
let bufferPage = 1;
for (const para of paras) {
if (
buffer &&
(buffer.length + para.text.length > MAX_CHUNK_CHARS ||
(buffer.length >= MIN_CHUNK_CHARS && merged.length >= MAX_CHUNKS - 1))
) {
merged.push({ text: buffer, page: bufferPage });
buffer = para.text;
bufferPage = para.page;
} else {
if (!buffer) bufferPage = para.page;
buffer = buffer ? `${buffer} ${para.text}` : para.text;
}
}
if (buffer) merged.push({ text: buffer, page: bufferPage });
return merged;
}
export function chunkPaperText(text: string): TextChunk[] {
const paras = extractParagraphsWithPages(text);
const merged = mergeParagraphs(paras);
return merged.slice(0, MAX_CHUNKS).map((chunk, orderIndex) => {
const chunkText = chunk.text.slice(0, 2000);
return {
id: `p-${orderIndex}`,
orderIndex,
text: chunkText,
page: chunk.page,
searchText: chunkText.slice(0, 80),
};
});
}
/** Compact outline for LLM beats generation. */
@@ -49,7 +90,15 @@ export function formatChunkOutline(chunks: TextChunk[]): string {
return chunks
.map(
(c) =>
`[${c.id}] ${c.text.slice(0, 100)}${c.text.length > 100 ? "…" : ""}`,
`[${c.id}${c.page ? ` p${c.page}` : ""}] ${c.text.slice(0, 100)}${c.text.length > 100 ? "…" : ""}`,
)
.join("\n");
}
/** Lookup chunk metadata by paragraph id. */
export function chunkById(
chunks: TextChunk[] | undefined,
id: string,
): TextChunk | undefined {
return chunks?.find((c) => c.id === id);
}
@@ -0,0 +1,22 @@
/** Split paragraph text into sentences (EN + CJK punctuation). */
const MIN_SENTENCE_CHARS = 12;
const MAX_SENTENCES = 12;
export function splitSentences(text: string): string[] {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return [];
const parts =
normalized.match(/[^.!?。!?]+[.!?。!?]+|[^.!?。!?]+$/g) ?? [normalized];
const sentences = parts
.map((s) => s.trim())
.filter((s) => s.length >= MIN_SENTENCE_CHARS);
if (!sentences.length && normalized.length >= MIN_SENTENCE_CHARS) {
return [normalized];
}
return sentences.slice(0, MAX_SENTENCES);
}
@@ -14,14 +14,46 @@ export interface StoredBeat {
ttsStatus: "pending" | "ready" | "failed";
}
export interface StoredSentence {
id: string;
orderIndex: number;
text: string;
translation: string;
page?: number;
searchText?: string;
audioPathEn?: string;
audioPathZh?: string;
ttsEnStatus: "pending" | "ready" | "failed" | "skipped";
ttsZhStatus: "pending" | "ready" | "failed";
}
export interface StoredParagraph {
id: string;
orderIndex: number;
originalText: string;
translation: string;
contextLink: string;
roleInPaper: string;
page?: number;
searchText?: string;
sentences?: StoredSentence[];
audioPathEn?: string;
audioPathZh?: string;
ttsEnStatus: "pending" | "ready" | "failed" | "skipped";
ttsZhStatus: "pending" | "ready" | "failed";
genStatus: "pending" | "ready" | "failed";
}
export interface LectureData {
paperId: string;
cacheKey: string;
/** 1 = summary only, 2 = beats + TTS (Step 1) */
phase?: 1 | 2;
/** 1 = summary, 2 = beats+TTS, 3 = paragraphs+note */
phase?: 1 | 2 | 3;
summary?: SummaryJson;
chunks?: TextChunk[];
beats?: StoredBeat[];
paragraphs?: StoredParagraph[];
noteItemId?: string;
extractChars?: number;
extractSource?: string;
updatedAt: number;
@@ -89,7 +121,27 @@ export function isStep1Ready(data: LectureData | undefined): boolean {
return data.beats.some((b) => b.ttsStatus === "ready" && b.audioPath);
}
export function isStep2Ready(data: LectureData | undefined): boolean {
if (!data?.paragraphs?.length) return false;
return data.paragraphs.some(
(p) => p.genStatus === "ready" && p.ttsZhStatus === "ready" && p.audioPathZh,
);
}
export function isLectureComplete(data: LectureData | undefined): boolean {
return isStep1Ready(data) && isStep2Ready(data);
}
export function playableBeats(data: LectureData | undefined): StoredBeat[] {
if (!data?.beats) return [];
return data.beats.filter((b) => b.ttsStatus === "ready" && b.audioPath);
}
export function playableParagraphs(
data: LectureData | undefined,
): StoredParagraph[] {
if (!data?.paragraphs) return [];
return data.paragraphs.filter(
(p) => p.genStatus === "ready" && p.ttsZhStatus === "ready" && p.audioPathZh,
);
}
+472 -45
View File
@@ -5,6 +5,12 @@ import {
createPlayLecture,
type PlayLectureHandle,
} from "../application/playLecture";
import {
createParagraphPlayer,
type PlayParagraphHandle,
type ReadGranularity,
} from "../application/playParagraphLecture";
import { upsertLectureNote } from "../application/generateLectureNote";
import { lectureEvents } from "../infrastructure/events";
import { BEAT_TYPE_LABELS } from "../infrastructure/llm/beatLlm";
import { pathToFileUrl } from "../infrastructure/platform/paths";
@@ -15,8 +21,11 @@ import {
} from "../infrastructure/storage/paths";
import {
isStep1Ready,
isStep2Ready,
loadLectureData,
type StoredBeat,
type StoredParagraph,
type StoredSentence,
} from "../infrastructure/storage/lectureStore";
import { formatSummaryForDisplay } from "../infrastructure/llm/lectureLlm";
import { paperRepository } from "../infrastructure/storage/repository";
@@ -31,6 +40,8 @@ import {
resolvePaperAttachment,
} from "../infrastructure/zotero/adapter";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import { chunkById } from "../infrastructure/pdf/chunker";
import { highlightTextInPdf } from "../../pdf/readerSync";
import { getString } from "../../../utils/locale";
import { createLucideIcon } from "../../../utils/icons";
@@ -44,16 +55,28 @@ export class LecturePaneView {
private ttsTesting = false;
private audioEl?: HTMLAudioElement;
private player?: PlayLectureHandle;
private paragraphPlayer?: PlayParagraphHandle;
private playMode: "overview" | "paragraph" = "paragraph";
private readGranularity: ReadGranularity = "paragraph";
private activeBeatId?: string;
private activeParagraphId?: string;
private activeSentenceId?: string;
private pdfSyncEnabled = true;
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
private statusEl!: HTMLElement;
private statusTextEl!: HTMLElement;
private ttsMetaEl!: HTMLElement;
private actionBtn!: HTMLButtonElement;
private ttsTestBtn!: HTMLButtonElement;
private noteBtn!: HTMLButtonElement;
private hintEl!: HTMLElement;
private playerSection!: HTMLElement;
private modeTabsEl!: HTMLElement;
private granularityTabsEl!: HTMLElement;
private beatListEl!: HTMLElement;
private paragraphDetailEl!: HTMLElement;
private playerTitleEl!: HTMLElement;
private playBtn!: HTMLButtonElement;
private pauseBtn!: HTMLButtonElement;
private prevBtn!: HTMLButtonElement;
@@ -125,34 +148,63 @@ export class LecturePaneView {
);
this.ttsTestBtn.addEventListener("click", () => void this.onTtsTest());
this.noteBtn = this.doc.createElement("button");
this.noteBtn.className = "chatpapers-lecture-secondary";
this.noteBtn.type = "button";
this.noteBtn.textContent = getString("lecture-save-note");
this.noteBtn.addEventListener("click", () => void this.onSaveNote());
this.playerSection = this.el("div", "chatpapers-lecture-player");
this.playerSection.hidden = true;
const playerTitle = this.el(
"div",
"chatpapers-lecture-player-title",
getString("lecture-beat-list-title"),
this.modeTabsEl = this.el("div", "chatpapers-lecture-mode-tabs");
this.modeTabsEl.setAttribute("role", "tablist");
this.modeTabsEl.append(
this.makeModeTab("paragraph", getString("lecture-mode-paragraph")),
this.makeModeTab("overview", getString("lecture-mode-overview")),
);
this.granularityTabsEl = this.el("div", "chatpapers-lecture-granularity-tabs");
this.granularityTabsEl.setAttribute("role", "tablist");
this.granularityTabsEl.append(
this.makeGranularityTab("paragraph", getString("lecture-granularity-paragraph")),
this.makeGranularityTab("sentence", getString("lecture-granularity-sentence")),
);
const playerTitle = this.el("div", "chatpapers-lecture-player-title");
this.playerTitleEl = playerTitle;
this.beatListEl = this.el("div", "chatpapers-lecture-beat-list");
this.paragraphDetailEl = this.el("div", "chatpapers-lecture-para-detail");
const playerControls = this.el("div", "chatpapers-lecture-player-controls");
this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () =>
void this.player?.prev(),
);
this.playBtn = this.makeIconBtn("lecture-play-start", Play, () =>
void this.player?.play(),
);
this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () =>
this.player?.pause(),
);
this.nextBtn = this.makeIconBtn("lecture-play-next", SkipForward, () =>
void this.player?.next(),
);
this.prevBtn = this.makeIconBtn("lecture-play-prev", SkipBack, () => {
if (this.playMode === "paragraph") void this.paragraphPlayer?.prev();
else void this.player?.prev();
});
this.playBtn = this.makeIconBtn("lecture-play-start", Play, () => {
if (this.playMode === "paragraph") void this.paragraphPlayer?.play();
else void this.player?.play();
});
this.pauseBtn = this.makeIconBtn("lecture-play-pause", Pause, () => {
if (this.playMode === "paragraph") this.paragraphPlayer?.pause();
else this.player?.pause();
});
this.nextBtn = this.makeIconBtn("lecture-play-next", SkipForward, () => {
if (this.playMode === "paragraph") void this.paragraphPlayer?.next();
else void this.player?.next();
});
this.pauseBtn.hidden = true;
playerControls.append(this.prevBtn, this.playBtn, this.pauseBtn, this.nextBtn);
this.playerSection.append(playerTitle, this.beatListEl, playerControls);
this.playerSection.append(
this.modeTabsEl,
this.granularityTabsEl,
playerTitle,
this.beatListEl,
this.paragraphDetailEl,
playerControls,
);
this.audioEl = this.doc.createElement("audio");
this.audioEl.className = "chatpapers-lecture-audio";
@@ -163,7 +215,7 @@ export class LecturePaneView {
this.updatePlatformHint();
const actions = this.el("div", "chatpapers-lecture-actions");
actions.append(this.actionBtn, this.ttsTestBtn);
actions.append(this.actionBtn, this.noteBtn, this.ttsTestBtn);
this.body.append(
header,
@@ -186,6 +238,8 @@ export class LecturePaneView {
this.unsubscribers = [];
this.player?.destroy();
this.player = undefined;
this.paragraphPlayer?.destroy();
this.paragraphPlayer = undefined;
this.audioEl?.pause();
this.body.replaceChildren();
this.body.classList.remove("chatpapers-root", "chatpapers-lecture-root");
@@ -219,9 +273,96 @@ export class LecturePaneView {
if (!this.shouldHandleEvent(d.paperId)) return;
void this.refreshPlayer();
}),
lectureEvents.on("lecture:paragraph_ready", (d) => {
if (!this.shouldHandleEvent(d.paperId)) return;
void this.refreshPlayer();
}),
);
}
private makeModeTab(
mode: "overview" | "paragraph",
label: string,
): HTMLButtonElement {
const btn = this.doc.createElement("button");
btn.type = "button";
btn.className = "chatpapers-lecture-mode-tab";
btn.dataset.mode = mode;
btn.setAttribute("role", "tab");
btn.setAttribute("aria-selected", "false");
btn.textContent = label;
btn.addEventListener("click", () => {
this.playMode = mode;
this.updateModeTabs();
void this.refreshPlayer();
});
return btn;
}
private makeGranularityTab(
granularity: ReadGranularity,
label: string,
): HTMLButtonElement {
const btn = this.doc.createElement("button");
btn.type = "button";
btn.className = "chatpapers-lecture-granularity-tab";
btn.dataset.granularity = granularity;
btn.setAttribute("role", "tab");
btn.setAttribute("aria-selected", "false");
btn.textContent = label;
btn.addEventListener("click", () => {
this.readGranularity = granularity;
this.updateGranularityTabs();
this.paragraphPlayer?.setGranularity(granularity);
void this.refreshPlayer();
});
return btn;
}
private updateGranularityTabs(): void {
for (const node of this.granularityTabsEl.querySelectorAll(
".chatpapers-lecture-granularity-tab",
)) {
const el = node as HTMLElement;
const active = el.dataset.granularity === this.readGranularity;
el.classList.toggle("is-active", active);
el.setAttribute("aria-selected", active ? "true" : "false");
}
this.granularityTabsEl.classList.toggle(
"is-visible",
this.playMode === "paragraph",
);
}
private async syncPdfHighlight(
text?: string,
page?: number,
paragraphId?: string,
): Promise<void> {
if (!this.pdfSyncEnabled || !text?.trim()) return;
const chunk = paragraphId
? chunkById(this.lectureChunks, paragraphId)
: undefined;
await highlightTextInPdf(this.item, {
text,
page: page ?? chunk?.page,
});
}
private updateModeTabs(): void {
for (const node of this.modeTabsEl.querySelectorAll(
".chatpapers-lecture-mode-tab",
)) {
const el = node as HTMLElement;
const active = el.dataset.mode === this.playMode;
el.classList.toggle("is-active", active);
el.setAttribute("aria-selected", active ? "true" : "false");
}
this.modeTabsEl.classList.toggle("mode-paragraph", this.playMode === "paragraph");
this.modeTabsEl.classList.toggle("mode-overview", this.playMode === "overview");
this.updateGranularityTabs();
}
private shouldHandleEvent(paperId: string): boolean {
if (this.preparing) return this.paper?.id === paperId || !this.paper;
return this.paper?.id === paperId;
@@ -298,46 +439,326 @@ export class LecturePaneView {
if (!this.cacheKey || !this.audioEl) return;
const data = await loadLectureData(this.cacheKey);
const beats = data?.beats ?? [];
const canPlay = isStep1Ready(data);
const paragraphs = data?.paragraphs ?? [];
const hasParagraphs = isStep2Ready(data) || paragraphs.length > 0;
const hasBeats = isStep1Ready(data);
this.playerSection.hidden = !beats.length;
this.renderBeatList(beats);
if (hasParagraphs && !hasBeats) this.playMode = "paragraph";
if (!hasParagraphs && hasBeats) this.playMode = "overview";
if (!this.player) {
this.player = createPlayLecture({
audio: this.audioEl,
beats,
onBeatChange: (id) => {
this.activeBeatId = id;
this.highlightBeat(id);
},
onPlayingChange: (playing) => {
this.playBtn.hidden = playing;
this.pauseBtn.hidden = !playing;
},
onComplete: () => {
this.setStatus("ready", getString("lecture-play-complete"));
},
});
} else {
this.player.setBeats(beats);
this.playerSection.hidden = !(hasBeats || hasParagraphs);
this.updateModeTabs();
this.noteBtn.disabled = !data?.summary;
this.lectureChunks = data?.chunks;
if (this.playMode === "paragraph" && hasParagraphs) {
this.playerTitleEl.textContent =
this.readGranularity === "sentence"
? getString("lecture-sentence-list-title")
: getString("lecture-paragraph-list-title");
this.renderParagraphList(paragraphs);
this.renderParagraphDetail(
paragraphs,
this.activeParagraphId,
this.paragraphPlayer?.getCurrentKind(),
this.activeSentenceId,
);
if (!this.paragraphPlayer) {
this.paragraphPlayer = createParagraphPlayer({
audio: this.audioEl,
paragraphs,
granularity: this.readGranularity,
onSegmentChange: (info) => {
this.activeParagraphId = info.paragraphId;
this.activeSentenceId = info.sentenceId;
this.renderParagraphList(paragraphs);
this.renderParagraphDetail(
paragraphs,
info.paragraphId,
info.kind,
info.sentenceId,
);
void this.syncPdfHighlight(
info.highlightText,
info.page,
info.paragraphId,
);
},
onPlayingChange: (playing) => {
this.playBtn.hidden = playing;
this.pauseBtn.hidden = !playing;
},
onComplete: () => {
this.setStatus("ready", getString("lecture-play-complete"));
},
});
} else {
this.paragraphPlayer.setParagraphs(paragraphs);
this.paragraphPlayer.setGranularity(this.readGranularity);
}
this.beatListEl.hidden = false;
this.granularityTabsEl.classList.add("is-visible");
} else if (hasBeats) {
this.granularityTabsEl.classList.remove("is-visible");
this.playerTitleEl.textContent = getString("lecture-beat-list-title");
this.beatListEl.hidden = false;
this.paragraphDetailEl.hidden = true;
this.renderBeatList(beats);
if (!this.player) {
this.player = createPlayLecture({
audio: this.audioEl,
beats,
onBeatChange: (id) => {
this.activeBeatId = id;
this.highlightBeat(id);
},
onPlayingChange: (playing) => {
this.playBtn.hidden = playing;
this.pauseBtn.hidden = !playing;
},
onComplete: () => {
this.setStatus("ready", getString("lecture-play-complete"));
},
});
} else {
this.player.setBeats(beats);
}
}
this.updateActionButton();
if (canPlay && this.paper?.lectureStatus === "ready") {
const readyCount = beats.filter((b) => b.ttsStatus === "ready").length;
if (!this.preparing) {
if (this.paper?.lectureStatus === "ready" && !this.preparing) {
if (isStep2Ready(data)) {
const count = paragraphs.filter((p) => p.ttsZhStatus === "ready").length;
this.setStatus(
"ready",
getString("lecture-status-ready-step1", {
args: { count: readyCount },
}),
getString("lecture-status-ready-step2", { args: { count } }),
);
} else if (isStep1Ready(data)) {
const count = beats.filter((b) => b.ttsStatus === "ready").length;
this.setStatus(
"ready",
getString("lecture-status-ready-step1", { args: { count } }),
);
}
}
}
private renderParagraphList(paragraphs: StoredParagraph[]): void {
this.beatListEl.replaceChildren();
if (this.readGranularity === "sentence") {
for (const para of paragraphs) {
for (const sentence of para.sentences ?? []) {
this.beatListEl.append(this.makeSentenceRow(para, sentence));
}
}
return;
}
for (const para of paragraphs) {
this.beatListEl.append(this.makeParagraphRow(para));
}
}
private makeParagraphRow(para: StoredParagraph): HTMLButtonElement {
const row = this.doc.createElement("button") as HTMLButtonElement;
row.type = "button";
row.className = "chatpapers-lecture-beat-item";
row.dataset.beatId = para.id;
if (para.id === this.activeParagraphId && !this.activeSentenceId) {
row.classList.add("is-active");
}
if (para.ttsZhStatus !== "ready") {
row.classList.add("is-pending");
row.disabled = para.genStatus !== "ready";
}
const meta = this.el("div", "chatpapers-lecture-beat-meta");
const preview = para.originalText.slice(0, 60);
meta.append(
this.el(
"span",
"chatpapers-lecture-beat-type",
getString("lecture-para-label", { args: { n: para.orderIndex + 1 } }),
),
this.el(
"span",
"chatpapers-lecture-beat-title",
`${preview}${para.originalText.length > 60 ? "…" : ""}`,
),
);
const status = this.el("span", "chatpapers-lecture-beat-status");
if (para.ttsZhStatus === "ready") status.textContent = "✓";
else if (para.genStatus === "ready") {
status.textContent = getString("lecture-beat-tts-pending");
} else {
status.textContent = "…";
}
row.append(meta, status);
row.addEventListener("click", () => {
void this.paragraphPlayer?.jumpToParagraphId(para.id, "en");
void this.syncPdfHighlight(
para.originalText,
para.page,
para.id,
);
});
return row;
}
private makeSentenceRow(
para: StoredParagraph,
sentence: StoredSentence,
): HTMLButtonElement {
const row = this.doc.createElement("button") as HTMLButtonElement;
row.type = "button";
row.className =
"chatpapers-lecture-beat-item chatpapers-lecture-sentence-item";
row.dataset.sentenceId = sentence.id;
if (sentence.id === this.activeSentenceId) row.classList.add("is-active");
if (sentence.ttsZhStatus !== "ready" && sentence.ttsEnStatus !== "ready") {
row.classList.add("is-pending");
row.disabled = para.genStatus !== "ready";
}
const meta = this.el("div", "chatpapers-lecture-beat-meta");
const preview = sentence.text.slice(0, 72);
meta.append(
this.el(
"span",
"chatpapers-lecture-beat-type",
getString("lecture-sentence-label", {
args: { p: para.orderIndex + 1, s: sentence.orderIndex + 1 },
}),
),
this.el(
"span",
"chatpapers-lecture-beat-title",
`${preview}${sentence.text.length > 72 ? "…" : ""}`,
),
);
const status = this.el("span", "chatpapers-lecture-beat-status");
if (sentence.ttsZhStatus === "ready" || sentence.ttsEnStatus === "ready") {
status.textContent = "✓";
} else {
status.textContent = getString("lecture-beat-tts-pending");
}
row.append(meta, status);
row.addEventListener("click", () => {
void this.paragraphPlayer?.jumpToSentenceId(sentence.id, "en");
void this.syncPdfHighlight(
sentence.text,
sentence.page ?? para.page,
para.id,
);
});
return row;
}
private renderParagraphDetail(
paragraphs: StoredParagraph[],
paragraphId?: string,
kind?: "en" | "zh",
sentenceId?: string,
): void {
const para = paragraphs.find((p) => p.id === paragraphId);
this.paragraphDetailEl.replaceChildren();
if (!para) {
this.paragraphDetailEl.hidden = true;
return;
}
this.paragraphDetailEl.hidden = false;
const sentence = sentenceId
? para.sentences?.find((s) => s.id === sentenceId)
: undefined;
const kindLabel =
kind === "en"
? getString("lecture-playing-en")
: kind === "zh"
? getString("lecture-playing-zh")
: "";
if (kindLabel) {
this.paragraphDetailEl.append(
this.el("div", "chatpapers-lecture-para-kind", kindLabel),
);
}
if (para.roleInPaper && !sentence) {
this.paragraphDetailEl.append(
this.el("div", "chatpapers-lecture-para-role", para.roleInPaper),
);
}
const originalText = sentence?.text ?? para.originalText;
const translationText = sentence?.translation ?? para.translation;
this.paragraphDetailEl.append(
this.el("div", "chatpapers-lecture-para-original-label", getString("lecture-para-original")),
this.el(
"div",
`chatpapers-lecture-para-original${sentence ? " is-active-sentence" : ""}`,
originalText,
),
this.el("div", "chatpapers-lecture-para-translation-label", getString("lecture-para-translation")),
this.el("div", "chatpapers-lecture-para-translation", translationText),
);
if (sentence && (para.sentences?.length ?? 0) > 1) {
const sentenceList = this.el("div", "chatpapers-lecture-sentence-list");
for (const s of para.sentences ?? []) {
const item = this.el(
"div",
`chatpapers-lecture-sentence-chip${s.id === sentence.id ? " is-active" : ""}`,
s.text,
);
item.addEventListener("click", () => {
void this.paragraphPlayer?.jumpToSentenceId(s.id, "en");
});
sentenceList.append(item);
}
this.paragraphDetailEl.append(
this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-sentence-list-hint")),
sentenceList,
);
}
if (para.contextLink && !sentence) {
this.paragraphDetailEl.append(
this.el("div", "chatpapers-lecture-para-context-label", getString("lecture-para-context")),
this.el("div", "chatpapers-lecture-para-context", para.contextLink),
);
}
this.paragraphDetailEl.append(
this.el("div", "chatpapers-lecture-pdf-sync-hint", getString("lecture-pdf-sync-hint")),
);
}
private async onSaveNote(): Promise<void> {
if (!this.cacheKey) return;
const data = await loadLectureData(this.cacheKey);
if (!data?.summary) return;
try {
const parent = getRegularItem(this.item);
const title = String(parent.getField("title") || "");
await upsertLectureNote({ parentItem: parent, data, paperTitle: title });
this.setStatus("ready", getString("lecture-note-saved"));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
this.setStatus("failed", msg);
}
}
private renderBeatList(beats: StoredBeat[]): void {
this.beatListEl.replaceChildren();
for (const beat of beats) {
@@ -402,6 +823,12 @@ export class LecturePaneView {
case "ready": {
if (cacheKey) {
const data = await loadLectureData(cacheKey);
if (isStep2Ready(data)) {
const count = data!.paragraphs!.filter(
(p) => p.ttsZhStatus === "ready",
).length;
return getString("lecture-status-ready-step2", { args: { count } });
}
if (isStep1Ready(data)) {
const count = data!.beats!.filter((b) => b.ttsStatus === "ready").length;
return getString("lecture-status-ready-step1", { args: { count } });
@@ -2,6 +2,10 @@ import { config } from "../../../../package.json";
import { getLocaleID, getString } from "../../../utils/locale";
import { findPdfAttachment } from "../../pdf/extractor";
import { ensureChatPapersStyles } from "../../ui/readerPane";
import {
onItemPaneSectionToggle,
prepareItemPaneBody,
} from "../../ui/itemPaneSection";
import { LecturePaneView } from "./lecturePane";
const views = new WeakMap<HTMLElement, LecturePaneView>();
@@ -25,6 +29,7 @@ export function registerLecturePane() {
onInit: ({ body }) => {
const doc = body.ownerDocument;
if (doc) ensureChatPapersStyles(doc);
prepareItemPaneBody(body);
},
onDestroy: ({ body }) => {
views.get(body)?.destroy();
@@ -60,6 +65,13 @@ export function registerLecturePane() {
err.textContent = getString("lecture-mount-error");
body.append(err);
}
const section = body.closest("collapsible-section");
if (section && !section.hasAttribute("open")) {
onItemPaneSectionToggle({ body });
}
},
onToggle: ({ body, event }) => {
onItemPaneSectionToggle({ body, event });
},
});
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Sync lecture playback to Zotero PDF reader: navigate + in-document find/highlight.
* Uses internal reader APIs (best-effort; may break across Zotero versions).
*/
import { findPdfAttachment } from "./extractor";
function getInternalReader(reader: any): any {
if (!reader) return undefined;
try {
if (reader._internalReader) return reader._internalReader;
const wrapped =
reader._iframeWindow?.wrappedJSObject?._reader ||
reader._iframeWindow?.wrappedJSObject?.reader;
if (wrapped) return wrapped;
} catch {
// ignore cross-origin
}
return undefined;
}
function getPrimaryView(reader: any): any {
const internal = getInternalReader(reader);
return (
internal?._activePrimaryView ??
internal?._primaryView ??
internal?._lastView
);
}
async function openReaderForAttachment(attachment: Zotero.Item): Promise<any> {
try {
return await (Zotero.Reader as any).open(attachment.id);
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] open failed", e);
return undefined;
}
}
function findReaderForAttachment(attachmentId: number): any {
try {
const readers =
(Zotero.Reader as any).getAll?.() ||
(Zotero.Reader as any)._readers ||
[];
return readers.find(
(r: any) => Number(r.itemID ?? r._itemID) === Number(attachmentId),
);
} catch {
return undefined;
}
}
/** 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();
if (!normalized) return "";
if (normalized.length <= maxLen) return normalized;
const snippet = normalized.slice(0, maxLen);
const lastSpace = snippet.lastIndexOf(" ");
return lastSpace > 32 ? snippet.slice(0, lastSpace) : snippet;
}
/** Multiple progressively shorter queries — PDF text may differ from extraction. */
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 out: string[] = [];
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(" "));
const q50 = buildPdfSearchQuery(text, 50);
const q30 = buildPdfSearchQuery(text, 30);
if (q50) out.push(q50);
if (q30 && q30 !== q50) out.push(q30);
return [...new Set(out.filter((q) => q.length >= 8))];
}
async function waitForReaderReady(reader: any): Promise<void> {
if (reader?._initPromise) {
try {
await reader._initPromise;
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] reader init failed", e);
}
}
for (let i = 0; i < 40; i++) {
const internal = getInternalReader(reader);
if (internal?._updateState || getPrimaryView(reader)?.setFindState) return;
await Zotero.Promise.delay(100);
}
}
async function navigateReader(reader: any, page?: number): Promise<void> {
if (!page || page < 1) return;
const location = { pageIndex: page - 1, pageNumber: page };
try {
if (typeof reader?.navigate === "function") {
await reader.navigate(location);
return;
}
const internal = getInternalReader(reader);
if (typeof internal?.navigate === "function") {
await internal.navigate(location);
}
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] navigate failed", e);
}
}
function makeFindState(query: string) {
return {
popupOpen: false,
active: true,
query,
highlightAll: true,
caseSensitive: false,
entireWord: false,
index: 0,
result: null,
};
}
function applyFindHighlight(reader: any, query: string): boolean {
const internal = getInternalReader(reader);
const findState = makeFindState(query);
const payload = { primaryViewFindState: findState };
try {
if (internal?._updateState) {
const win = reader?._iframeWindow;
const state =
win && typeof Components !== "undefined"
? Components.utils.cloneInto(payload, win)
: payload;
internal._updateState(state);
try {
internal.findNext?.(true);
} catch {
// ignore
}
return true;
}
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] _updateState find failed", e);
}
try {
const view = getPrimaryView(reader);
if (view?.setFindState) {
view.setFindState(findState);
try {
view.findNext?.();
} catch {
// ignore
}
return true;
}
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] view setFindState failed", e);
}
return false;
}
function clearFindHighlight(reader: any): void {
const cleared = makeFindState("");
cleared.active = false;
const payload = { primaryViewFindState: cleared };
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);
return;
}
getPrimaryView(reader)?.setFindState?.(cleared);
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] clear find failed", e);
}
}
export interface PdfHighlightTarget {
text: string;
page?: number;
}
/** Open PDF (if needed), jump to page, and highlight matching text. */
export async function highlightTextInPdf(
item: Zotero.Item,
target: PdfHighlightTarget,
): Promise<void> {
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) {
reader = await openReaderForAttachment(attachment);
}
if (!reader) return;
await waitForReaderReady(reader);
await navigateReader(reader, target.page);
await Zotero.Promise.delay(350);
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;
}
}
ztoolkit.log("[ChatPapers:ReaderSync] all find attempts failed");
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e);
}
}
/** Remove in-document search highlight from the open reader. */
export async function clearPdfHighlight(item: Zotero.Item): Promise<void> {
const attachment = findPdfAttachment(item);
if (!attachment) return;
try {
const reader = findReaderForAttachment(attachment.id);
if (!reader) return;
clearFindHighlight(reader);
} catch (e) {
ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e);
}
}
+49
View File
@@ -0,0 +1,49 @@
/** Helpers for Zotero ItemPane collapsible sections. */
export function prepareItemPaneBody(body: HTMLElement): void {
body.classList.add("chatpapers-pane-body");
body.style.minHeight = "0";
}
export function onItemPaneSectionToggle(options: {
body: HTMLElement;
event?: Event;
}): void {
const section = resolveCollapsibleSection(options.body, options.event);
if (!section) return;
const open = section.hasAttribute("open");
if (open) {
options.body.style.removeProperty("height");
options.body.style.removeProperty("overflow");
options.body.style.minHeight = "0";
const raf = options.body.ownerDocument?.defaultView?.requestAnimationFrame;
const updateHeight = () => {
const h = options.body.scrollHeight;
if (h > 0) {
section.style.setProperty("--open-height", `${h}px`);
}
};
if (raf) raf(updateHeight);
else updateHeight();
return;
}
options.body.style.minHeight = "0";
options.body.style.height = "0";
options.body.style.overflow = "hidden";
section.style.setProperty("--open-height", "0px");
}
function resolveCollapsibleSection(
body: HTMLElement,
event?: Event,
): HTMLElement | null {
const fromEvent = (event?.currentTarget ?? event?.target) as
| HTMLElement
| undefined;
return (
(fromEvent?.closest?.("collapsible-section") as HTMLElement | null) ??
(body.closest("collapsible-section") as HTMLElement | null)
);
}
+9
View File
@@ -1,6 +1,7 @@
import { config } from "../../../package.json";
import { getLocaleID, getString } from "../../utils/locale";
import { ChatView } from "./chatView";
import { onItemPaneSectionToggle, prepareItemPaneBody } from "./itemPaneSection";
const views = new WeakMap<HTMLElement, ChatView>();
@@ -23,6 +24,7 @@ export function registerChatPane() {
onInit: ({ body }) => {
const doc = body.ownerDocument;
if (doc) ensureStyles(doc);
prepareItemPaneBody(body);
},
onDestroy: ({ body }) => {
views.get(body)?.destroy();
@@ -51,6 +53,13 @@ export function registerChatPane() {
const view = new ChatView(doc, body, item);
views.set(body, view);
await view.mount();
const section = body.closest("collapsible-section");
if (section && !section.hasAttribute("open")) {
onItemPaneSectionToggle({ body });
}
},
onToggle: ({ body, event }) => {
onItemPaneSectionToggle({ body, event });
},
});
}
+18
View File
@@ -45,10 +45,21 @@ export type FluentMessageId =
| 'lecture-beat-tts-pending'
| 'lecture-empty-desc'
| 'lecture-empty-title'
| 'lecture-granularity-paragraph'
| 'lecture-granularity-sentence'
| 'lecture-loading'
| 'lecture-mode-label'
| 'lecture-mode-overview'
| 'lecture-mode-paragraph'
| 'lecture-mount-error'
| 'lecture-no-pdf'
| 'lecture-note-saved'
| 'lecture-para-context'
| 'lecture-para-label'
| 'lecture-para-original'
| 'lecture-para-translation'
| 'lecture-paragraph-list-title'
| 'lecture-pdf-sync-hint'
| 'lecture-platform-hint-mac'
| 'lecture-platform-hint-other'
| 'lecture-platform-hint-win'
@@ -57,7 +68,13 @@ export type FluentMessageId =
| 'lecture-play-pause'
| 'lecture-play-prev'
| 'lecture-play-start'
| 'lecture-playing-en'
| 'lecture-playing-zh'
| 'lecture-reprepare'
| 'lecture-save-note'
| 'lecture-sentence-label'
| 'lecture-sentence-list-hint'
| 'lecture-sentence-list-title'
| 'lecture-start'
| 'lecture-status-failed'
| 'lecture-status-idle'
@@ -65,6 +82,7 @@ export type FluentMessageId =
| 'lecture-status-ready'
| 'lecture-status-ready-phase1'
| 'lecture-status-ready-step1'
| 'lecture-status-ready-step2'
| 'lecture-tts-provider'
| 'lecture-tts-test'
| 'lecture-tts-test-done'