实现pdf中划句子ai伴读的功能,修复pdf高亮显示的问题
This commit is contained in:
@@ -1038,6 +1038,35 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chatpapers-lecture-selection-explain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, #2563eb 6%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #2563eb 18%, transparent);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chatpapers-lecture-selection-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.chatpapers-lecture-selection-replay {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chatpapers-selection-explain-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatpapers-lecture-para-kind {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -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-list-hint = Sentences in this paragraph
|
||||
lecture-pdf-sync-hint = The matching passage is highlighted in the PDF reader while playing.
|
||||
lecture-explain-selection = Explain & read selection
|
||||
lecture-explain-selection-short = Explain
|
||||
lecture-selection-empty = Select a sentence in the PDF first
|
||||
lecture-selection-explaining = Generating explanation for selection…
|
||||
lecture-selection-done = Selection explanation ready
|
||||
lecture-selection-no-item = Cannot resolve the current PDF item
|
||||
lecture-selection-title = Selection explain
|
||||
lecture-selection-syntax = Syntax / terms
|
||||
lecture-selection-context = Context role
|
||||
lecture-selection-replay = Replay explanation
|
||||
lecture-playing-en = Playing: original
|
||||
lecture-playing-zh = Playing: translation
|
||||
lecture-status-ready-step2 = Ready: { $count } paragraphs + note synced
|
||||
|
||||
@@ -101,6 +101,16 @@ lecture-sentence-list-title = 逐句精读(先读原文,再听讲解)
|
||||
lecture-sentence-label = 段{ $p }·句{ $s }
|
||||
lecture-sentence-list-hint = 本段各句
|
||||
lecture-pdf-sync-hint = 播放时会在 PDF 阅读器中高亮对应原文。
|
||||
lecture-explain-selection = 解释朗读选区
|
||||
lecture-explain-selection-short = 解释朗读
|
||||
lecture-selection-empty = 请先在 PDF 中选中要解释的句子
|
||||
lecture-selection-explaining = 正在生成选区讲解…
|
||||
lecture-selection-done = 选区讲解完成
|
||||
lecture-selection-no-item = 无法识别当前 PDF 条目
|
||||
lecture-selection-title = 选区讲解
|
||||
lecture-selection-syntax = 句法/术语
|
||||
lecture-selection-context = 上下文作用
|
||||
lecture-selection-replay = 重播讲解
|
||||
lecture-playing-en = 正在播放:原文朗读
|
||||
lecture-playing-zh = 正在播放:中文讲解
|
||||
lecture-status-ready-step2 = 备课完成:{ $count } 段逐段精读可播放,笔记已同步
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { pathToFileUrl } from "../infrastructure/platform/paths";
|
||||
import type { SelectionExplainResult } from "./explainSelectionLecture";
|
||||
|
||||
export interface LecturePlaybackHandle {
|
||||
pause: () => void;
|
||||
}
|
||||
|
||||
let playbackGeneration = 0;
|
||||
let explainSessionId = 0;
|
||||
const panePlaybackHandles = new Set<LecturePlaybackHandle>();
|
||||
|
||||
function waitAudioEnded(audio: HTMLAudioElement): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
@@ -25,18 +33,70 @@ function audioPaths(result: SelectionExplainResult): string[] {
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Stop standalone + all registered lecture pane players. */
|
||||
export function stopAllLecturePlayback(): void {
|
||||
playbackGeneration += 1;
|
||||
standaloneAudio?.pause();
|
||||
if (standaloneAudio) {
|
||||
standaloneAudio.onended = null;
|
||||
standaloneAudio.removeAttribute("src");
|
||||
}
|
||||
for (const handle of panePlaybackHandles) {
|
||||
try {
|
||||
handle.pause();
|
||||
} catch (e) {
|
||||
ztoolkit.log("[ChatPapers:Lecture] pause registered player failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLecturePlaybackHandle(
|
||||
handle: LecturePlaybackHandle,
|
||||
): () => void {
|
||||
panePlaybackHandles.add(handle);
|
||||
return () => {
|
||||
panePlaybackHandles.delete(handle);
|
||||
};
|
||||
}
|
||||
|
||||
/** Begin a new explain flow; cancels any in-flight playback and explain session. */
|
||||
export function beginSelectionExplainSession(): number {
|
||||
explainSessionId += 1;
|
||||
stopAllLecturePlayback();
|
||||
return explainSessionId;
|
||||
}
|
||||
|
||||
export function isSelectionExplainSessionActive(sessionId: number): boolean {
|
||||
return sessionId === explainSessionId;
|
||||
}
|
||||
|
||||
export function cancelSelectionExplainSessions(): void {
|
||||
explainSessionId += 1;
|
||||
stopAllLecturePlayback();
|
||||
}
|
||||
|
||||
export async function playSelectionExplainAudio(
|
||||
audio: HTMLAudioElement,
|
||||
result: SelectionExplainResult,
|
||||
options?: { generation?: number },
|
||||
): Promise<void> {
|
||||
const paths = audioPaths(result);
|
||||
if (!paths.length) return;
|
||||
|
||||
const generation = options?.generation ?? playbackGeneration;
|
||||
audio.pause();
|
||||
audio.onended = null;
|
||||
|
||||
for (const path of paths) {
|
||||
if (generation !== playbackGeneration) return;
|
||||
audio.src = pathToFileUrl(path);
|
||||
audio.load();
|
||||
await audio.play();
|
||||
try {
|
||||
await audio.play();
|
||||
} catch (e) {
|
||||
if (generation !== playbackGeneration) return;
|
||||
throw e;
|
||||
}
|
||||
await waitAudioEnded(audio);
|
||||
}
|
||||
}
|
||||
@@ -46,20 +106,36 @@ let standaloneAudio: HTMLAudioElement | undefined;
|
||||
function getStandaloneAudio(): HTMLAudioElement {
|
||||
if (standaloneAudio) return standaloneAudio;
|
||||
const win = Zotero.getMainWindow?.() as Window | undefined;
|
||||
const doc = win?.document ?? document;
|
||||
standaloneAudio = doc.createElement("audio");
|
||||
standaloneAudio.preload = "none";
|
||||
standaloneAudio.style.display = "none";
|
||||
doc.body?.appendChild(standaloneAudio);
|
||||
return standaloneAudio;
|
||||
const doc = win?.document;
|
||||
if (!doc) {
|
||||
throw new Error("Cannot resolve main window document for audio playback");
|
||||
}
|
||||
const audio = doc.createElement("audio");
|
||||
audio.preload = "none";
|
||||
audio.style.display = "none";
|
||||
doc.body?.appendChild(audio);
|
||||
standaloneAudio = audio;
|
||||
return audio;
|
||||
}
|
||||
|
||||
/** Play selection explain on one audio element, stopping every other lecture audio first. */
|
||||
export async function playSelectionExplainExclusive(
|
||||
result: SelectionExplainResult,
|
||||
audio?: HTMLAudioElement,
|
||||
): Promise<void> {
|
||||
stopAllLecturePlayback();
|
||||
const generation = playbackGeneration;
|
||||
await playSelectionExplainAudio(audio ?? getStandaloneAudio(), result, {
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
export async function playSelectionExplainStandalone(
|
||||
result: SelectionExplainResult,
|
||||
): Promise<void> {
|
||||
await playSelectionExplainAudio(getStandaloneAudio(), result);
|
||||
await playSelectionExplainExclusive(result);
|
||||
}
|
||||
|
||||
export function stopSelectionExplainStandalone(): void {
|
||||
standaloneAudio?.pause();
|
||||
stopAllLecturePlayback();
|
||||
}
|
||||
|
||||
@@ -47,7 +47,14 @@ import {
|
||||
explainSelectionLecture,
|
||||
type SelectionExplainResult,
|
||||
} from "../application/explainSelectionLecture";
|
||||
import { playSelectionExplainAudio } from "../application/playSelectionExplain";
|
||||
import {
|
||||
beginSelectionExplainSession,
|
||||
isSelectionExplainSessionActive,
|
||||
playSelectionExplainAudio,
|
||||
playSelectionExplainExclusive,
|
||||
registerLecturePlaybackHandle,
|
||||
stopAllLecturePlayback,
|
||||
} from "../application/playSelectionExplain";
|
||||
import { getString } from "../../../utils/locale";
|
||||
import { createLucideIcon } from "../../../utils/icons";
|
||||
|
||||
@@ -70,6 +77,8 @@ export class LecturePaneView {
|
||||
private pdfSyncEnabled = true;
|
||||
private explainingSelection = false;
|
||||
private lastSelectionResult?: SelectionExplainResult;
|
||||
private unregisterPlayback?: () => void;
|
||||
private explainSessionId = 0;
|
||||
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
|
||||
|
||||
private statusEl!: HTMLElement;
|
||||
@@ -802,6 +811,139 @@ export class LecturePaneView {
|
||||
);
|
||||
}
|
||||
|
||||
private async onExplainSelection(): Promise<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> {
|
||||
if (!this.cacheKey) return;
|
||||
const data = await loadLectureData(this.cacheKey);
|
||||
@@ -906,8 +1048,14 @@ export class LecturePaneView {
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: LectureStatus | "idle", message?: string): void {
|
||||
private setStatus(
|
||||
status: LectureStatus | "idle",
|
||||
message?: string,
|
||||
kind?: "warn" | "error" | "info",
|
||||
): void {
|
||||
this.statusEl.dataset.status = status;
|
||||
if (kind) this.statusEl.dataset.kind = kind;
|
||||
else this.statusEl.removeAttribute("data-kind");
|
||||
this.statusTextEl.textContent =
|
||||
message || getString("lecture-status-idle");
|
||||
const busy = [
|
||||
@@ -918,6 +1066,8 @@ export class LecturePaneView {
|
||||
"tts_generating",
|
||||
].includes(status);
|
||||
this.actionBtn.disabled = this.preparing || busy;
|
||||
this.explainSelectionBtn.disabled =
|
||||
this.preparing || busy || this.explainingSelection;
|
||||
this.ttsTestBtn.disabled = this.ttsTesting || this.preparing;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { config } from "../../../../package.json";
|
||||
import { getString } from "../../../utils/locale";
|
||||
import { cacheReaderSelection } from "../../pdf/selection";
|
||||
import { cacheReaderSelection, getCachedReaderSelectionPosition } from "../../pdf/selection";
|
||||
import { highlightTextInPdf } from "../../pdf/readerSync";
|
||||
import { getRegularItem, resolvePaperAttachment } from "../infrastructure/zotero/adapter";
|
||||
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
|
||||
import { explainSelectionLecture } from "../application/explainSelectionLecture";
|
||||
import { playSelectionExplainStandalone } from "../application/playSelectionExplain";
|
||||
import {
|
||||
beginSelectionExplainSession,
|
||||
isSelectionExplainSessionActive,
|
||||
playSelectionExplainExclusive,
|
||||
} from "../application/playSelectionExplain";
|
||||
import { lectureEvents } from "../infrastructure/events";
|
||||
|
||||
function tryString(value: unknown): string {
|
||||
@@ -47,6 +51,7 @@ export async function runSelectionExplainFromReader(
|
||||
}
|
||||
|
||||
cacheReaderSelection(text);
|
||||
const sessionId = beginSelectionExplainSession();
|
||||
showHeadline(getString("lecture-selection-explaining"));
|
||||
|
||||
try {
|
||||
@@ -57,9 +62,12 @@ export async function runSelectionExplainFromReader(
|
||||
onProgress: (message) => showHeadline(message),
|
||||
});
|
||||
|
||||
if (!isSelectionExplainSessionActive(sessionId)) return;
|
||||
|
||||
await highlightTextInPdf(item, {
|
||||
text: result.original,
|
||||
page: result.page,
|
||||
position: getCachedReaderSelectionPosition(result.original),
|
||||
});
|
||||
|
||||
lectureEvents.emit("lecture:selection_explain", {
|
||||
@@ -69,7 +77,9 @@ export async function runSelectionExplainFromReader(
|
||||
result,
|
||||
});
|
||||
|
||||
await playSelectionExplainStandalone(result);
|
||||
if (!isSelectionExplainSessionActive(sessionId)) return;
|
||||
|
||||
await playSelectionExplainExclusive(result);
|
||||
showHeadline(getString("lecture-selection-done"));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -96,11 +106,11 @@ export function registerSelectionExplainPopup(): void {
|
||||
"toolbar-button wide-button chatpapers-selection-explain-btn";
|
||||
btn.textContent = getString("lecture-explain-selection-short");
|
||||
btn.title = getString("lecture-explain-selection");
|
||||
btn.addEventListener("click", (ev) => {
|
||||
btn.addEventListener("click", (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void runSelectionExplainFromReader(reader, text);
|
||||
});
|
||||
}, { once: true });
|
||||
append(btn);
|
||||
} catch (e) {
|
||||
ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e);
|
||||
|
||||
+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).
|
||||
*/
|
||||
|
||||
import { findPdfAttachment } from "./extractor";
|
||||
import {
|
||||
getCachedReaderSelectionPosition,
|
||||
normalizeHighlightText,
|
||||
} from "./selection";
|
||||
|
||||
export interface PdfPosition {
|
||||
pageIndex: number;
|
||||
rects?: number[][];
|
||||
nextPageRects?: number[][];
|
||||
}
|
||||
|
||||
function getInternalReader(reader: any): any {
|
||||
if (!reader) return undefined;
|
||||
@@ -53,10 +63,7 @@ function findReaderForAttachment(attachmentId: number): any {
|
||||
|
||||
/** Build a short phrase suitable for PDF in-document search. */
|
||||
export function buildPdfSearchQuery(text: string, maxLen = 72): string {
|
||||
const normalized = text
|
||||
.replace(/[\u0000-\u001f\f]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const normalized = normalizeHighlightText(text);
|
||||
if (!normalized) return "";
|
||||
if (normalized.length <= maxLen) return normalized;
|
||||
const snippet = normalized.slice(0, maxLen);
|
||||
@@ -64,25 +71,124 @@ export function buildPdfSearchQuery(text: string, maxLen = 72): string {
|
||||
return lastSpace > 32 ? snippet.slice(0, lastSpace) : snippet;
|
||||
}
|
||||
|
||||
/** Multiple progressively shorter queries — PDF text may differ from extraction. */
|
||||
function trimQuery(text: string, maxLen: number): string {
|
||||
const normalized = normalizeHighlightText(text);
|
||||
if (!normalized) return "";
|
||||
if (normalized.length <= maxLen) return normalized;
|
||||
const snippet = normalized.slice(0, maxLen);
|
||||
const lastSpace = snippet.lastIndexOf(" ");
|
||||
return lastSpace > 24 ? snippet.slice(0, lastSpace) : snippet;
|
||||
}
|
||||
|
||||
/** Prefer full text first — PDF page content joins line breaks as spaces. */
|
||||
function buildQueryCandidates(text: string): string[] {
|
||||
const normalized = text
|
||||
.replace(/[\u0000-\u001f\f]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const words = normalized.split(/\s+/).filter((w) => w.length > 1);
|
||||
const normalized = normalizeHighlightText(text);
|
||||
if (!normalized) return [];
|
||||
|
||||
const out: string[] = [];
|
||||
const push = (q: string) => {
|
||||
const t = q.trim();
|
||||
if (t.length >= 6) out.push(t);
|
||||
};
|
||||
|
||||
if (words.length >= 8) out.push(words.slice(0, 10).join(" "));
|
||||
if (words.length >= 5) out.push(words.slice(0, 6).join(" "));
|
||||
if (words.length >= 3) out.push(words.slice(0, 4).join(" "));
|
||||
push(trimQuery(normalized, 480));
|
||||
push(trimQuery(normalized, 320));
|
||||
push(trimQuery(normalized, 220));
|
||||
push(trimQuery(normalized, 140));
|
||||
push(buildPdfSearchQuery(normalized, 96));
|
||||
push(buildPdfSearchQuery(normalized, 72));
|
||||
|
||||
const q50 = buildPdfSearchQuery(text, 50);
|
||||
const q30 = buildPdfSearchQuery(text, 30);
|
||||
if (q50) out.push(q50);
|
||||
if (q30 && q30 !== q50) out.push(q30);
|
||||
const words = normalized.split(/\s+/).filter((w) => w.length > 1);
|
||||
if (words.length >= 12) push(words.slice(0, 14).join(" "));
|
||||
if (words.length >= 8) push(words.slice(0, 10).join(" "));
|
||||
if (words.length >= 5) push(words.slice(0, 6).join(" "));
|
||||
|
||||
return [...new Set(out.filter((q) => q.length >= 8))];
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/** Split wrapped sentences into line-sized chunks for fallback per-line find. */
|
||||
function splitLineSegments(text: string): string[] {
|
||||
const normalized = normalizeHighlightText(text);
|
||||
if (!normalized) return [];
|
||||
|
||||
const explicitLines = text
|
||||
.split(/\n+/)
|
||||
.map((line) => normalizeHighlightText(line))
|
||||
.filter((line) => line.length >= 6);
|
||||
|
||||
const source = explicitLines.length > 1 ? explicitLines : [normalized];
|
||||
const segments: string[] = [];
|
||||
|
||||
for (const line of source) {
|
||||
if (line.length <= 88) {
|
||||
segments.push(line);
|
||||
continue;
|
||||
}
|
||||
const words = line.split(/\s+/).filter(Boolean);
|
||||
let buf: string[] = [];
|
||||
let len = 0;
|
||||
for (const word of words) {
|
||||
const nextLen = len ? len + 1 + word.length : word.length;
|
||||
if (nextLen > 72 && buf.length) {
|
||||
segments.push(buf.join(" "));
|
||||
buf = [word];
|
||||
len = word.length;
|
||||
} else {
|
||||
buf.push(word);
|
||||
len = nextLen;
|
||||
}
|
||||
}
|
||||
if (buf.length) segments.push(buf.join(" "));
|
||||
}
|
||||
|
||||
return [...new Set(segments.filter((s) => s.length >= 6))];
|
||||
}
|
||||
|
||||
function cloneIntoFrame<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> {
|
||||
@@ -138,12 +244,7 @@ function applyFindHighlight(reader: any, query: string): boolean {
|
||||
|
||||
try {
|
||||
if (internal?._updateState) {
|
||||
const win = reader?._iframeWindow;
|
||||
const state =
|
||||
win && typeof Components !== "undefined"
|
||||
? Components.utils.cloneInto(payload, win)
|
||||
: payload;
|
||||
internal._updateState(state);
|
||||
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
|
||||
try {
|
||||
internal.findNext?.(true);
|
||||
} catch {
|
||||
@@ -180,12 +281,7 @@ function clearFindHighlight(reader: any): void {
|
||||
try {
|
||||
const internal = getInternalReader(reader);
|
||||
if (internal?._updateState) {
|
||||
const win = reader?._iframeWindow;
|
||||
const state =
|
||||
win && typeof Components !== "undefined"
|
||||
? Components.utils.cloneInto(payload, win)
|
||||
: payload;
|
||||
internal._updateState(state);
|
||||
internal._updateState(cloneIntoFrame(payload, reader?._iframeWindow));
|
||||
return;
|
||||
}
|
||||
getPrimaryView(reader)?.setFindState?.(cleared);
|
||||
@@ -194,9 +290,188 @@ function clearFindHighlight(reader: any): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clearPositionHighlight(reader: any): void {
|
||||
const view = getPrimaryView(reader);
|
||||
if (!view) return;
|
||||
try {
|
||||
view._readAloudHighlightedPosition = null;
|
||||
view._readAloudSentenceHighlightedPosition = null;
|
||||
view._highlightedPosition = null;
|
||||
view._render?.();
|
||||
} catch (e) {
|
||||
ztoolkit.log("[ChatPapers:ReaderSync] clear position highlight failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPositionHighlight(reader: any, position: PdfPosition): boolean {
|
||||
const view = getPrimaryView(reader);
|
||||
if (!view || !positionRectCount(position)) return false;
|
||||
|
||||
try {
|
||||
if (typeof view.navigateToPosition === "function") {
|
||||
void view.navigateToPosition(position, {
|
||||
ifNeeded: true,
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
|
||||
view._readAloudHighlightedPosition = position;
|
||||
view._readAloudSentenceHighlightedPosition = null;
|
||||
view._highlightedPosition = null;
|
||||
view._render?.();
|
||||
return true;
|
||||
} catch (e) {
|
||||
ztoolkit.log("[ChatPapers:ReaderSync] applyPositionHighlight failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePdfPosition(raw: any, pageHint?: number): PdfPosition | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const pageIndex =
|
||||
typeof raw.pageIndex === "number"
|
||||
? raw.pageIndex
|
||||
: pageHint && pageHint > 0
|
||||
? pageHint - 1
|
||||
: undefined;
|
||||
if (pageIndex == null || pageIndex < 0) return undefined;
|
||||
|
||||
const rects = Array.isArray(raw.rects)
|
||||
? raw.rects.filter((r: unknown) => Array.isArray(r) && r.length === 4)
|
||||
: undefined;
|
||||
const nextPageRects = Array.isArray(raw.nextPageRects)
|
||||
? raw.nextPageRects.filter((r: unknown) => Array.isArray(r) && r.length === 4)
|
||||
: undefined;
|
||||
|
||||
if (!rects?.length && !nextPageRects?.length) return undefined;
|
||||
return {
|
||||
pageIndex,
|
||||
rects: rects?.length ? rects : undefined,
|
||||
nextPageRects: nextPageRects?.length ? nextPageRects : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function pickBestMatchPosition(
|
||||
positions: PdfPosition[],
|
||||
preferredPageIndex?: number,
|
||||
): PdfPosition | undefined {
|
||||
if (!positions.length) return undefined;
|
||||
|
||||
let best = positions[0];
|
||||
let bestScore = -1;
|
||||
for (const pos of positions) {
|
||||
let score = positionRectCount(pos) * 10;
|
||||
if (
|
||||
preferredPageIndex != null &&
|
||||
pos.pageIndex === preferredPageIndex - 1
|
||||
) {
|
||||
score += 100;
|
||||
}
|
||||
if (pos.nextPageRects?.length) score += 5;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = pos;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
async function waitForFindPosition(
|
||||
view: any,
|
||||
pageHint?: number,
|
||||
): Promise<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 {
|
||||
text: string;
|
||||
page?: number;
|
||||
position?: PdfPosition;
|
||||
}
|
||||
|
||||
/** Open PDF (if needed), jump to page, and highlight matching text. */
|
||||
@@ -207,9 +482,6 @@ export async function highlightTextInPdf(
|
||||
const attachment = findPdfAttachment(item);
|
||||
if (!attachment || !target.text?.trim()) return;
|
||||
|
||||
const candidates = buildQueryCandidates(target.text);
|
||||
if (!candidates.length) return;
|
||||
|
||||
try {
|
||||
let reader = findReaderForAttachment(attachment.id);
|
||||
if (!reader) {
|
||||
@@ -219,16 +491,32 @@ export async function highlightTextInPdf(
|
||||
|
||||
await waitForReaderReady(reader);
|
||||
await navigateReader(reader, target.page);
|
||||
await Zotero.Promise.delay(350);
|
||||
await Zotero.Promise.delay(250);
|
||||
|
||||
for (const query of candidates) {
|
||||
if (applyFindHighlight(reader, query)) {
|
||||
await Zotero.Promise.delay(200);
|
||||
ztoolkit.log("[ChatPapers:ReaderSync] find applied:", query.slice(0, 40));
|
||||
return;
|
||||
}
|
||||
const cachedPosition = getCachedReaderSelectionPosition(target.text);
|
||||
const position =
|
||||
normalizePdfPosition(target.position, target.page) ??
|
||||
normalizePdfPosition(cachedPosition, target.page) ??
|
||||
(await resolveTextPosition(reader, target.text, target.page));
|
||||
|
||||
if (position && applyPositionHighlight(reader, position)) {
|
||||
applyFindHighlight(
|
||||
reader,
|
||||
buildQueryCandidates(target.text)[0] ?? buildPdfSearchQuery(target.text, 96),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ztoolkit.log("[ChatPapers:ReaderSync] all find attempts failed");
|
||||
|
||||
const fallbackQuery = buildQueryCandidates(target.text)[0];
|
||||
if (fallbackQuery && applyFindHighlight(reader, fallbackQuery)) {
|
||||
ztoolkit.log(
|
||||
"[ChatPapers:ReaderSync] find-only fallback:",
|
||||
fallbackQuery.slice(0, 48),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ztoolkit.log("[ChatPapers:ReaderSync] all highlight attempts failed");
|
||||
} catch (e) {
|
||||
ztoolkit.log("[ChatPapers:ReaderSync] highlightTextInPdf failed", e);
|
||||
}
|
||||
@@ -242,6 +530,7 @@ export async function clearPdfHighlight(item: Zotero.Item): Promise<void> {
|
||||
const reader = findReaderForAttachment(attachment.id);
|
||||
if (!reader) return;
|
||||
clearFindHighlight(reader);
|
||||
clearPositionHighlight(reader);
|
||||
} catch (e) {
|
||||
ztoolkit.log("[ChatPapers:ReaderSync] clearPdfHighlight failed", e);
|
||||
}
|
||||
|
||||
@@ -33,11 +33,16 @@ function textFromReaderInternals(reader: any): string {
|
||||
|
||||
// zotero-plugin-toolkit path: selection popup annotation
|
||||
try {
|
||||
const popupText =
|
||||
reader?._internalReader?._lastView?._selectionPopup?.annotation?.text ??
|
||||
reader?._internalReader?._primaryView?._selectionPopup?.annotation?.text;
|
||||
const popup =
|
||||
reader?._internalReader?._lastView?._selectionPopup ??
|
||||
reader?._internalReader?._primaryView?._selectionPopup;
|
||||
const popupText = popup?.annotation?.text;
|
||||
const t = tryString(popupText);
|
||||
if (t) return t;
|
||||
if (t) {
|
||||
const position = normalizePdfSelectionPosition(popup?.annotation?.position);
|
||||
if (position) cacheReaderSelection(t, position);
|
||||
return t;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -70,7 +75,13 @@ function textFromReaderInternals(reader: any): string {
|
||||
}
|
||||
try {
|
||||
const t = tryString(view._selectionPopup?.annotation?.text);
|
||||
if (t) return t;
|
||||
if (t) {
|
||||
const position = normalizePdfSelectionPosition(
|
||||
view._selectionPopup?.annotation?.position,
|
||||
);
|
||||
if (position) cacheReaderSelection(t, position);
|
||||
return t;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -139,15 +150,83 @@ function listCandidateReaders(): any[] {
|
||||
|
||||
/** Last non-empty selection cached while user selects in the reader. */
|
||||
let cachedSelection = "";
|
||||
let cachedSelectionPosition: PdfSelectionPosition | undefined;
|
||||
let cachedAt = 0;
|
||||
|
||||
export function cacheReaderSelection(text: string) {
|
||||
export interface PdfSelectionPosition {
|
||||
pageIndex: number;
|
||||
rects?: number[][];
|
||||
nextPageRects?: number[][];
|
||||
}
|
||||
|
||||
export function normalizeHighlightText(text: string): string {
|
||||
return text
|
||||
.replace(/[\u0000-\u001f\f]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizePdfSelectionPosition(raw: unknown): PdfSelectionPosition | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const pos = raw as Record<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();
|
||||
if (!t) return;
|
||||
const sameText =
|
||||
normalizeHighlightText(cachedSelection) === normalizeHighlightText(t);
|
||||
cachedSelection = t;
|
||||
if (position) {
|
||||
cachedSelectionPosition = position;
|
||||
} else if (!sameText) {
|
||||
cachedSelectionPosition = undefined;
|
||||
}
|
||||
cachedAt = Date.now();
|
||||
}
|
||||
|
||||
export function getCachedReaderSelectionPosition(
|
||||
text?: string,
|
||||
maxAgeMs = 120000,
|
||||
): PdfSelectionPosition | undefined {
|
||||
if (!cachedSelectionPosition) return undefined;
|
||||
if (Date.now() - cachedAt > maxAgeMs) return undefined;
|
||||
if (!text?.trim()) return cachedSelectionPosition;
|
||||
|
||||
const wanted = normalizeHighlightText(text);
|
||||
const cached = normalizeHighlightText(cachedSelection);
|
||||
if (!wanted || !cached) return undefined;
|
||||
if (wanted === cached || cached.includes(wanted) || wanted.includes(cached)) {
|
||||
return cachedSelectionPosition;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCachedReaderSelection(maxAgeMs = 120000): string {
|
||||
if (!cachedSelection) return "";
|
||||
if (Date.now() - cachedAt > maxAgeMs) return "";
|
||||
@@ -200,7 +279,10 @@ export function registerReaderSelectionHook() {
|
||||
tryString(event?.params?.annotation?.text) ||
|
||||
tryString(event?.params?.text) ||
|
||||
textFromReaderInternals(event?.reader);
|
||||
if (text) cacheReaderSelection(text);
|
||||
const position = normalizePdfSelectionPosition(
|
||||
event?.params?.annotation?.position,
|
||||
);
|
||||
if (text) cacheReaderSelection(text, position);
|
||||
} catch (e) {
|
||||
ztoolkit.log("selection popup hook failed", e);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Helpers for Zotero ItemPane collapsible sections. */
|
||||
|
||||
import { config } from "../../../package.json";
|
||||
import { getLocaleID, getString } from "../../utils/locale";
|
||||
import { getPref, setPref } from "../../utils/prefs";
|
||||
import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow";
|
||||
@@ -17,9 +18,14 @@ const FIXED_HEIGHT: Record<ItemPaneKind, number> = {
|
||||
lecture: 420,
|
||||
};
|
||||
|
||||
const OPEN_WINDOW_ICON = "chrome://zotero/skin/16/universal/open.svg";
|
||||
const ADAPTIVE_ICON = "chrome://zotero/skin/16/universal/expand-all.svg";
|
||||
const ADAPTIVE_ICON_OFF = "chrome://zotero/skin/16/universal/collapse-all.svg";
|
||||
/** Lucide-derived toolbar icons (see addon/content/icons/pane-*.svg). */
|
||||
function paneIconURL(file: string): string {
|
||||
return `chrome://${config.addonRef}/content/icons/${file}`;
|
||||
}
|
||||
|
||||
const OPEN_WINDOW_ICON = paneIconURL("pane-open-window.svg");
|
||||
const ADAPTIVE_ICON = paneIconURL("pane-adaptive-height.svg");
|
||||
const ADAPTIVE_ICON_OFF = paneIconURL("pane-adaptive-height-fixed.svg");
|
||||
|
||||
export function prepareItemPaneBody(body: HTMLElement): void {
|
||||
body.classList.add("chatpapers-pane-body");
|
||||
|
||||
@@ -2,6 +2,26 @@ import type { IconNode } from "lucide";
|
||||
|
||||
export type LucideIcon = IconNode;
|
||||
|
||||
function serializeLucideNode([tag, attrs]: IconNode[number]): string {
|
||||
const parts = Object.entries(attrs)
|
||||
.map(([key, value]) => `${key}="${String(value).replace(/"/g, """)}"`)
|
||||
.join(" ");
|
||||
return parts ? `<${tag} ${parts}/>` : `<${tag}/>`;
|
||||
}
|
||||
|
||||
/** Lucide icon as a 16px SVG data URL for Zotero toolbar list-style-image. */
|
||||
export function lucideToolbarIconUrl(icon: LucideIcon): string {
|
||||
const body = icon.map(serializeLucideNode).join("");
|
||||
const svg = [
|
||||
'<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(
|
||||
doc: Document,
|
||||
icon: LucideIcon,
|
||||
|
||||
Vendored
+10
@@ -49,6 +49,8 @@ export type FluentMessageId =
|
||||
| 'lecture-beat-tts-pending'
|
||||
| 'lecture-empty-desc'
|
||||
| 'lecture-empty-title'
|
||||
| 'lecture-explain-selection'
|
||||
| 'lecture-explain-selection-short'
|
||||
| 'lecture-granularity-paragraph'
|
||||
| 'lecture-granularity-sentence'
|
||||
| 'lecture-loading'
|
||||
@@ -76,6 +78,14 @@ export type FluentMessageId =
|
||||
| 'lecture-playing-zh'
|
||||
| 'lecture-reprepare'
|
||||
| 'lecture-save-note'
|
||||
| 'lecture-selection-context'
|
||||
| 'lecture-selection-done'
|
||||
| 'lecture-selection-empty'
|
||||
| 'lecture-selection-explaining'
|
||||
| 'lecture-selection-no-item'
|
||||
| 'lecture-selection-replay'
|
||||
| 'lecture-selection-syntax'
|
||||
| 'lecture-selection-title'
|
||||
| 'lecture-sentence-label'
|
||||
| 'lecture-sentence-list-hint'
|
||||
| 'lecture-sentence-list-title'
|
||||
|
||||
Reference in New Issue
Block a user