修改换行逻辑和段落查看方式

This commit is contained in:
yhy
2026-08-30 07:44:59 +08:00
parent 54f004a56c
commit ce79275d41
14 changed files with 489 additions and 232 deletions
+2 -1
View File
@@ -2,7 +2,7 @@ import { initLocale } from "./utils/locale";
import { createZToolkit } from "./utils/ztoolkit";
import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane";
import { registerSelectionExplainPopup } from "./modules/lecture/ui/registerSelectionExplainPopup";
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
import { registerChatPane, registerPrefs, ensureChatPapersStyles } from "./modules/ui/readerPane";
import { registerPrefsScripts } from "./modules/ui/prefs";
import { registerItemMenus } from "./modules/ui/itemMenu";
import { registerReaderSelectionHook } from "./modules/pdf/selection";
@@ -30,6 +30,7 @@ async function onStartup() {
async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
addon.data.ztoolkit = createZToolkit();
ensureChatPapersStyles(win.document);
win.MozXULElement.insertFTLIfNeeded(
`${addon.data.config.addonRef}-mainWindow.ftl`,
);
+182 -67
View File
@@ -74,6 +74,8 @@ export class LecturePaneView {
private activeBeatId?: string;
private activeParagraphId?: string;
private activeSentenceId?: string;
private selectedParagraphId?: string;
private selectedSentenceId?: string;
private pdfSyncEnabled = true;
private explainingSelection = false;
private lastSelectionResult?: SelectionExplainResult;
@@ -93,6 +95,7 @@ export class LecturePaneView {
private modeTabsEl!: HTMLElement;
private granularityTabsEl!: HTMLElement;
private beatListEl!: HTMLElement;
private paraListHintEl!: HTMLElement;
private paragraphDetailEl!: HTMLElement;
private selectionExplainEl!: HTMLElement;
private playerTitleEl!: HTMLElement;
@@ -199,8 +202,17 @@ export class LecturePaneView {
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");
this.paraListHintEl = this.el(
"div",
"chatpapers-lecture-para-list-hint",
getString("lecture-para-list-hint"),
);
this.paraListHintEl.hidden = true;
this.beatListEl = this.el("div", "chatpapers-lecture-beat-list chatpapers-lecture-scroll-y");
this.paragraphDetailEl = this.el(
"div",
"chatpapers-lecture-para-detail chatpapers-lecture-scroll-y",
);
this.selectionExplainEl = this.el("div", "chatpapers-lecture-selection-explain");
this.selectionExplainEl.hidden = true;
@@ -229,6 +241,7 @@ export class LecturePaneView {
this.modeTabsEl,
this.granularityTabsEl,
playerTitle,
this.paraListHintEl,
this.beatListEl,
this.paragraphDetailEl,
this.selectionExplainEl,
@@ -537,12 +550,13 @@ export class LecturePaneView {
this.readGranularity === "sentence"
? getString("lecture-sentence-list-title")
: getString("lecture-paragraph-list-title");
this.paraListHintEl.hidden = false;
this.renderParagraphList(paragraphs);
this.renderParagraphDetail(
paragraphs,
this.activeParagraphId,
this.selectedParagraphId ?? this.activeParagraphId,
this.paragraphPlayer?.getCurrentKind(),
this.activeSentenceId,
this.selectedSentenceId ?? this.activeSentenceId,
);
if (!this.paragraphPlayer) {
@@ -553,6 +567,8 @@ export class LecturePaneView {
onSegmentChange: (info) => {
this.activeParagraphId = info.paragraphId;
this.activeSentenceId = info.sentenceId;
this.selectedParagraphId = info.paragraphId;
this.selectedSentenceId = info.sentenceId;
this.renderParagraphList(paragraphs);
this.renderParagraphDetail(
paragraphs,
@@ -582,6 +598,7 @@ export class LecturePaneView {
this.granularityTabsEl.classList.add("is-visible");
} else if (hasBeats) {
this.granularityTabsEl.classList.remove("is-visible");
this.paraListHintEl.hidden = true;
this.playerTitleEl.textContent = getString("lecture-beat-list-title");
this.beatListEl.hidden = false;
this.paragraphDetailEl.hidden = true;
@@ -633,43 +650,116 @@ export class LecturePaneView {
if (this.readGranularity === "sentence") {
for (const para of paragraphs) {
for (const sentence of para.sentences ?? []) {
this.beatListEl.append(this.makeSentenceRow(para, sentence));
this.beatListEl.append(this.makeSentenceRow(paragraphs, para, sentence));
}
}
return;
}
for (const para of paragraphs) {
this.beatListEl.append(this.makeParagraphRow(para));
this.beatListEl.append(this.makeParagraphRow(paragraphs, 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";
}
private isParagraphSelected(paraId: string, sentenceId?: string): boolean {
if (this.selectedParagraphId !== paraId) return false;
if (sentenceId) return this.selectedSentenceId === sentenceId;
return !this.selectedSentenceId;
}
const meta = this.el("div", "chatpapers-lecture-beat-meta");
const preview = para.originalText.slice(0, 60);
meta.append(
private isParagraphPlaying(paraId: string, sentenceId?: string): boolean {
if (this.activeParagraphId !== paraId) return false;
if (sentenceId) return this.activeSentenceId === sentenceId;
return !this.activeSentenceId;
}
private selectParagraph(
paragraphs: StoredParagraph[],
paragraphId: string,
sentenceId?: string,
): void {
this.selectedParagraphId = paragraphId;
this.selectedSentenceId = sentenceId;
this.renderParagraphList(paragraphs);
this.renderParagraphDetail(paragraphs, paragraphId, undefined, sentenceId);
const para = paragraphs.find((p) => p.id === paragraphId);
if (!para) return;
const sentence = sentenceId
? para.sentences?.find((s) => s.id === sentenceId)
: undefined;
const text = sentence?.text ?? para.originalText;
void this.syncPdfHighlight(
text,
sentence?.page ?? para.page,
para.id,
);
}
private playParagraph(para: StoredParagraph, sentence?: StoredSentence): void {
if (sentence) {
void this.paragraphPlayer?.jumpToSentenceId(sentence.id, "en");
void this.syncPdfHighlight(
sentence.text,
sentence.page ?? para.page,
para.id,
);
return;
}
void this.paragraphPlayer?.jumpToParagraphId(para.id, "en");
void this.syncPdfHighlight(para.originalText, para.page, para.id);
}
private makeParagraphPlayButton(
para: StoredParagraph,
sentence?: StoredSentence,
): HTMLButtonElement {
const playBtn = this.doc.createElement("button");
playBtn.type = "button";
playBtn.className = "chatpapers-lecture-beat-play";
playBtn.title = getString("lecture-para-play");
playBtn.setAttribute("aria-label", getString("lecture-para-play"));
playBtn.append(
createLucideIcon(this.doc, Play, {
size: 14,
className: "chatpapers-lecture-btn-icon",
}),
);
const canPlay = sentence
? sentence.ttsZhStatus === "ready" ||
sentence.ttsEnStatus === "ready" ||
para.genStatus === "ready"
: para.ttsZhStatus === "ready" || para.genStatus === "ready";
playBtn.disabled = !canPlay;
playBtn.addEventListener("click", (event) => {
event.stopPropagation();
this.playParagraph(para, sentence);
});
return playBtn;
}
private makeParagraphRow(
paragraphs: StoredParagraph[],
para: StoredParagraph,
): HTMLElement {
const row = this.el("div", "chatpapers-lecture-beat-item");
row.dataset.beatId = para.id;
if (this.isParagraphSelected(para.id)) row.classList.add("is-selected");
if (this.isParagraphPlaying(para.id)) row.classList.add("is-active");
if (para.ttsZhStatus !== "ready") row.classList.add("is-pending");
const main = this.doc.createElement("button");
main.type = "button";
main.className = "chatpapers-lecture-beat-main";
const line = this.el("div", "chatpapers-lecture-beat-line");
line.append(
this.el(
"span",
"chatpapers-lecture-beat-type",
"chatpapers-lecture-beat-label",
getString("lecture-para-label", { args: { n: para.orderIndex + 1 } }),
),
this.el(
"span",
"chatpapers-lecture-beat-title",
`${preview}${para.originalText.length > 60 ? "…" : ""}`,
),
this.el("span", "chatpapers-lecture-beat-preview", para.originalText),
);
const status = this.el("span", "chatpapers-lecture-beat-status");
@@ -680,48 +770,47 @@ export class LecturePaneView {
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,
);
main.append(line, status);
main.title = para.originalText;
main.addEventListener("click", () => {
this.selectParagraph(paragraphs, para.id);
});
row.append(main, this.makeParagraphPlayButton(para));
return row;
}
private makeSentenceRow(
paragraphs: StoredParagraph[],
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";
): HTMLElement {
const row = this.el("div", "chatpapers-lecture-beat-item chatpapers-lecture-sentence-item");
row.dataset.sentenceId = sentence.id;
if (sentence.id === this.activeSentenceId) row.classList.add("is-active");
if (this.isParagraphSelected(para.id, sentence.id)) {
row.classList.add("is-selected");
}
if (this.isParagraphPlaying(para.id, sentence.id)) {
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(
const main = this.doc.createElement("button");
main.type = "button";
main.className = "chatpapers-lecture-beat-main";
const line = this.el("div", "chatpapers-lecture-beat-line");
line.append(
this.el(
"span",
"chatpapers-lecture-beat-type",
"chatpapers-lecture-beat-label",
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 ? "…" : ""}`,
),
this.el("span", "chatpapers-lecture-beat-preview", sentence.text),
);
const status = this.el("span", "chatpapers-lecture-beat-status");
@@ -731,15 +820,13 @@ export class LecturePaneView {
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,
);
main.append(line, status);
main.title = sentence.text;
main.addEventListener("click", () => {
this.selectParagraph(paragraphs, para.id, sentence.id);
});
row.append(main, this.makeParagraphPlayButton(para, sentence));
return row;
}
@@ -749,18 +836,47 @@ export class LecturePaneView {
kind?: "en" | "zh",
sentenceId?: string,
): void {
const para = paragraphs.find((p) => p.id === paragraphId);
const para = paragraphId
? paragraphs.find((p) => p.id === paragraphId)
: undefined;
this.paragraphDetailEl.replaceChildren();
if (!para) {
this.paragraphDetailEl.hidden = true;
this.paragraphDetailEl.hidden = false;
this.paragraphDetailEl.classList.add("is-empty");
this.paragraphDetailEl.classList.remove("chatpapers-lecture-scroll-y");
this.paragraphDetailEl.append(
this.el(
"div",
"chatpapers-lecture-para-detail-empty",
getString("lecture-para-detail-empty"),
),
);
return;
}
this.paragraphDetailEl.hidden = false;
this.paragraphDetailEl.classList.remove("is-empty");
this.paragraphDetailEl.classList.add("chatpapers-lecture-scroll-y");
const sentence = sentenceId
? para.sentences?.find((s) => s.id === sentenceId)
: undefined;
const playBtn = this.doc.createElement("button");
playBtn.type = "button";
playBtn.className =
"chatpapers-lecture-secondary chatpapers-lecture-para-play-btn";
playBtn.append(
createLucideIcon(this.doc, Play, {
size: 14,
className: "chatpapers-lecture-btn-icon",
}),
this.doc.createTextNode(getString("lecture-para-play")),
);
playBtn.addEventListener("click", () => {
this.playParagraph(para, sentence);
});
this.paragraphDetailEl.append(playBtn);
const kindLabel =
kind === "en"
? getString("lecture-playing-en")
@@ -796,13 +912,12 @@ export class LecturePaneView {
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,
);
const item = this.doc.createElement("button");
item.type = "button";
item.className = `chatpapers-lecture-sentence-chip${s.id === sentence.id ? " is-active" : ""}`;
item.textContent = s.text;
item.addEventListener("click", () => {
void this.paragraphPlayer?.jumpToSentenceId(s.id, "en");
this.selectParagraph(paragraphs, para.id, s.id);
});
sentenceList.append(item);
}
@@ -49,6 +49,7 @@ export function registerLecturePane() {
body.replaceChildren();
const doc = body.ownerDocument;
if (!doc) return;
applyItemPaneSectionButtonIcons(body);
const loading = doc.createElement("div");
loading.className = "chatpapers-loading";
loading.textContent = getString("lecture-loading");
@@ -81,6 +82,7 @@ export function registerLecturePane() {
}
},
onToggle: ({ body, event }) => {
applyItemPaneSectionButtonIcons(body);
onItemPaneSectionToggle({ body, event });
},
});
+60 -16
View File
@@ -2,6 +2,7 @@
import { config } from "../../../package.json";
import { getLocaleID, getString } from "../../utils/locale";
import type { FluentMessageId } from "../../../typings/i10n";
import { getPref, setPref } from "../../utils/prefs";
import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow";
@@ -37,36 +38,71 @@ export function isAdaptiveHeight(kind: ItemPaneKind): boolean {
return val !== false;
}
function setSectionButtonIcons(btn: HTMLElement, icon: string): void {
function sectionButtonTooltip(messageId: FluentMessageId): string {
const fromBranch = getString(messageId, { branch: "tooltiptext" });
const l10nId = getLocaleID(messageId);
if (fromBranch && fromBranch !== l10nId) return fromBranch;
return getString(messageId);
}
function applySectionButtonTooltip(
btn: HTMLElement,
messageId: FluentMessageId,
): void {
const l10nId = getLocaleID(messageId);
const tooltip = sectionButtonTooltip(messageId);
btn.setAttribute("data-l10n-id", l10nId);
btn.setAttribute("title", tooltip);
btn.setAttribute("tooltiptext", tooltip);
try {
btn.ownerDocument?.l10n?.setAttributes?.(btn, l10nId);
} catch {
// ignore if Fluent is not ready on this document yet
}
}
function styleSectionButtonIcon(btn: HTMLElement, icon: string): void {
const url = `url('${icon}')`;
btn.style.setProperty("--custom-button-icon-light", url);
btn.style.setProperty("--custom-button-icon-dark", url);
btn.style.setProperty("list-style-image", url);
btn.style.setProperty("-moz-context-properties", "fill, fill-opacity");
btn.style.setProperty("fill", "currentColor");
}
function applyOpenInWindowButtonIcon(body: HTMLElement): void {
function findSectionButton(
section: HTMLElement | null,
type: string,
): HTMLElement | null {
if (!section) return null;
return (section.querySelector(`.${type}.section-custom-button`) ??
section.querySelector(`.${type}`)) as HTMLElement | null;
}
function applyOpenInWindowButtonIcon(body: HTMLElement): boolean {
const section = resolveCollapsibleSection(body);
const btn = section?.querySelector(
".open-in-window.section-custom-button",
) as HTMLElement | null;
if (!btn) return;
setSectionButtonIcons(btn, OPEN_WINDOW_ICON);
const btn = findSectionButton(section, "open-in-window");
if (!btn) return false;
styleSectionButtonIcon(btn, OPEN_WINDOW_ICON);
applySectionButtonTooltip(btn, "item-pane-open-window");
return true;
}
export function updateAdaptiveButtonState(
body: HTMLElement,
adaptive: boolean,
): void {
): boolean {
const section = resolveCollapsibleSection(body);
const btn = section?.querySelector(
".adaptive-height.section-custom-button",
) as HTMLElement | null;
if (!btn) return;
const btn = findSectionButton(section, "adaptive-height");
if (!btn) return false;
btn.setAttribute("aria-pressed", adaptive ? "true" : "false");
btn.classList.toggle("chatpapers-adaptive-active", adaptive);
setSectionButtonIcons(btn, adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF);
btn.title = getString(
styleSectionButtonIcon(btn, adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF);
applySectionButtonTooltip(
btn,
adaptive ? "item-pane-adaptive-height-on" : "item-pane-adaptive-height-off",
);
return true;
}
export function applyItemPaneSectionButtonIcons(
@@ -74,9 +110,17 @@ export function applyItemPaneSectionButtonIcons(
adaptive = isAdaptiveHeight(
(body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind,
),
attempt = 0,
): void {
applyOpenInWindowButtonIcon(body);
updateAdaptiveButtonState(body, adaptive);
const openReady = applyOpenInWindowButtonIcon(body);
const adaptiveReady = updateAdaptiveButtonState(body, adaptive);
if ((openReady && adaptiveReady) || attempt >= 8) return;
const win = body.ownerDocument?.defaultView;
if (!win) return;
win.requestAnimationFrame(() => {
applyItemPaneSectionButtonIcons(body, adaptive, attempt + 1);
});
}
export function applyAdaptiveHeight(
+2
View File
@@ -49,6 +49,7 @@ export function registerChatPane() {
body.replaceChildren();
const doc = body.ownerDocument;
if (!doc) return;
applyItemPaneSectionButtonIcons(body);
const loading = doc.createElement("div");
loading.className = "chatpapers-loading";
loading.textContent = getString("chat-loading");
@@ -72,6 +73,7 @@ export function registerChatPane() {
}
},
onToggle: ({ body, event }) => {
applyItemPaneSectionButtonIcons(body);
onItemPaneSectionToggle({ body, event });
},
});