From ce79275d41a99435af0b779d563d78bdc671f83d Mon Sep 17 00:00:00 2001 From: yhy Date: Sun, 30 Aug 2026 07:44:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8D=A2=E8=A1=8C=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=92=8C=E6=AE=B5=E8=90=BD=E6=9F=A5=E7=9C=8B=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .tmp_itemPane.scss | 36 --- .tmp_itemPaneCustomSection.scss | 45 ---- addon/content/chatpapers.css | 234 +++++++++++++--- .../icons/pane-adaptive-height-fixed.svg | 16 +- addon/content/icons/pane-adaptive-height.svg | 16 +- addon/content/icons/pane-open-window.svg | 9 +- addon/locale/en-US/addon.ftl | 15 +- addon/locale/zh-CN/addon.ftl | 15 +- src/hooks.ts | 3 +- src/modules/lecture/ui/lecturePane.ts | 249 +++++++++++++----- src/modules/lecture/ui/registerLecturePane.ts | 2 + src/modules/ui/itemPaneSection.ts | 76 ++++-- src/modules/ui/readerPane.ts | 2 + typings/i10n.d.ts | 3 + 14 files changed, 489 insertions(+), 232 deletions(-) delete mode 100644 .tmp_itemPane.scss delete mode 100644 .tmp_itemPaneCustomSection.scss diff --git a/.tmp_itemPane.scss b/.tmp_itemPane.scss deleted file mode 100644 index 2aa4612..0000000 --- a/.tmp_itemPane.scss +++ /dev/null @@ -1,36 +0,0 @@ -item-pane { - flex-grow: 0; - flex-shrink: 1; - min-width: var(--width-available-item-pane, $min-width-item-pane + $width-sidenav); - min-height: $min-width-item-pane + $width-sidenav; - - &[collapsed] { - min-width: $width-sidenav; - min-height: $width-sidenav; - max-width: $width-sidenav; - - visibility: inherit; - - &.stacked { - max-width: unset; - max-height: $width-sidenav; - } - - #zotero-item-pane-content { - visibility: collapse; - } - } - - &.stacked { - flex-direction: column; - height: 0px; - - #zotero-item-pane-content { - width: 100%; - } - } - - #batch-edit-prompt-message { - padding: 3px 8px; - } -} diff --git a/.tmp_itemPaneCustomSection.scss b/.tmp_itemPaneCustomSection.scss deleted file mode 100644 index d6a1857..0000000 --- a/.tmp_itemPaneCustomSection.scss +++ /dev/null @@ -1,45 +0,0 @@ -item-pane-custom-section { - display: flex; - flex-direction: column; - - &[hidden] { - display: none; - } - - .body { - display: flex; - flex-direction: column; - margin: 0; - } - - collapsible-section { - &[custom] > .head { - & .title::before { - content: ''; - width: 16px; - height: 16px; - -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity; - fill: currentColor; - stroke: currentColor; - - @media (prefers-color-scheme: light) { - background: var(--custom-section-icon-light) no-repeat center; - } - @media (prefers-color-scheme: dark) { - background: var(--custom-section-icon-dark) no-repeat center; - } - } - - toolbarbutton.section-custom-button { - fill: currentColor; - -moz-context-properties: fill, fill-opacity; - @media (prefers-color-scheme: light) { - list-style-image: var(--custom-button-icon-light) - } - @media (prefers-color-scheme: dark) { - list-style-image: var(--custom-button-icon-dark) - } - } - } - } -} diff --git a/addon/content/chatpapers.css b/addon/content/chatpapers.css index 489751c..9acdb92 100644 --- a/addon/content/chatpapers.css +++ b/addon/content/chatpapers.css @@ -16,19 +16,20 @@ /* * Item pane section header buttons (open in window, adaptive height). - * Lucide-style icons are stroke-based (context-stroke); Zotero only enables - * fill context on .section-custom-button by default, so strokes stay invisible - * in light and dark themes until stroke is wired to currentColor. + * Icons use fill="context-fill" like native Zotero toolbar SVGs. */ +collapsible-section[custom][data-pane="chatpapers-chat"] > .head toolbarbutton.section-custom-button, +collapsible-section[custom][data-pane="chatpapers-lecture"] > .head toolbarbutton.section-custom-button, collapsible-section.chatpapers-adaptive-height > .head toolbarbutton.section-custom-button, collapsible-section.chatpapers-fixed-height > .head toolbarbutton.section-custom-button { - -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity; + -moz-context-properties: fill, fill-opacity; fill: currentColor; - stroke: currentColor; color: inherit; } @media (prefers-color-scheme: light) { + collapsible-section[custom][data-pane="chatpapers-chat"] > .head toolbarbutton.section-custom-button, + collapsible-section[custom][data-pane="chatpapers-lecture"] > .head toolbarbutton.section-custom-button, collapsible-section.chatpapers-adaptive-height > .head toolbarbutton.section-custom-button, collapsible-section.chatpapers-fixed-height > .head toolbarbutton.section-custom-button { list-style-image: var(--custom-button-icon-light); @@ -36,6 +37,8 @@ collapsible-section.chatpapers-fixed-height > .head toolbarbutton.section-custom } @media (prefers-color-scheme: dark) { + collapsible-section[custom][data-pane="chatpapers-chat"] > .head toolbarbutton.section-custom-button, + collapsible-section[custom][data-pane="chatpapers-lecture"] > .head toolbarbutton.section-custom-button, collapsible-section.chatpapers-adaptive-height > .head toolbarbutton.section-custom-button, collapsible-section.chatpapers-fixed-height > .head toolbarbutton.section-custom-button { list-style-image: var(--custom-button-icon-dark); @@ -75,7 +78,7 @@ collapsible-section[open] > :not(.head) .chatpapers-root { } collapsible-section[open] > :not(.head) .chatpapers-lecture-root { - min-height: 180px; + min-height: 320px; } .chatpapers-header { @@ -824,6 +827,46 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { /* --- Voice lecture pane --- */ .chatpapers-lecture-root { gap: 10px; + max-width: 100%; + min-width: 0; +} + +.chatpapers-lecture-beat-list { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + min-height: 150px; + max-height: min(260px, 34vh); + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.chatpapers-lecture-beat-list.chatpapers-lecture-scroll-y, +.chatpapers-lecture-para-detail.chatpapers-lecture-scroll-y { + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, currentColor 32%, transparent) transparent; + scrollbar-gutter: stable; +} + +.chatpapers-lecture-beat-list.chatpapers-lecture-scroll-y::-webkit-scrollbar, +.chatpapers-lecture-para-detail.chatpapers-lecture-scroll-y::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +.chatpapers-lecture-beat-list.chatpapers-lecture-scroll-y::-webkit-scrollbar-thumb, +.chatpapers-lecture-para-detail.chatpapers-lecture-scroll-y::-webkit-scrollbar-thumb { + background: color-mix(in srgb, currentColor 28%, transparent); + border-radius: 999px; +} + +.chatpapers-lecture-beat-list.chatpapers-lecture-scroll-y::-webkit-scrollbar-track, +.chatpapers-lecture-para-detail.chatpapers-lecture-scroll-y::-webkit-scrollbar-track { + background: transparent; } .chatpapers-lecture-empty { @@ -1015,7 +1058,7 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { outline: 1px solid rgba(31, 122, 140, 0.35); } -.chatpapers-lecture-sentence-item .chatpapers-lecture-beat-title { +.chatpapers-lecture-sentence-item .chatpapers-lecture-beat-preview { font-size: 11px; } @@ -1032,12 +1075,21 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { } .chatpapers-lecture-sentence-chip { + appearance: none; + width: 100%; + box-sizing: border-box; + text-align: left; padding: 4px 8px; border-radius: 6px; border: 1px solid color-mix(in srgb, #1f7a8c 20%, transparent); + background: transparent; cursor: pointer; + font: inherit; font-size: 11px; line-height: 1.35; + color: inherit; + word-break: break-word; + overflow-wrap: anywhere; } .chatpapers-lecture-sentence-chip.is-active { @@ -1062,8 +1114,54 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { border: 1px solid color-mix(in srgb, #1f7a8c 15%, transparent); font-size: 12px; line-height: 1.45; - max-height: 200px; - overflow-y: auto; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.chatpapers-lecture-para-detail:not(.is-empty) { + min-height: 140px; + max-height: min(300px, 38vh); +} + +.chatpapers-lecture-para-detail.is-empty { + min-height: auto; + max-height: none; + overflow: visible; +} + +.chatpapers-lecture-para-detail .chatpapers-lecture-para-original, +.chatpapers-lecture-para-detail .chatpapers-lecture-para-translation, +.chatpapers-lecture-para-detail .chatpapers-lecture-para-context, +.chatpapers-lecture-para-detail .chatpapers-lecture-para-role, +.chatpapers-lecture-para-detail .chatpapers-lecture-para-kind, +.chatpapers-lecture-para-detail .chatpapers-lecture-pdf-sync-hint { + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + overflow-x: hidden; + max-width: 100%; + font-size: 12px; + line-height: 1.55; +} + +.chatpapers-lecture-para-detail .chatpapers-lecture-para-original-label, +.chatpapers-lecture-para-detail .chatpapers-lecture-para-translation-label, +.chatpapers-lecture-para-detail .chatpapers-lecture-para-context-label { + white-space: normal; +} + +.chatpapers-lecture-para-detail-empty { + opacity: 0.72; + font-size: 12px; + line-height: 1.45; + text-align: center; + padding: 8px 4px; +} + +.chatpapers-lecture-para-play-btn { + align-self: flex-start; + margin-bottom: 2px; } .chatpapers-lecture-selection-explain { @@ -1135,6 +1233,10 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { flex-direction: column; gap: 8px; margin-top: 4px; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; } .chatpapers-lecture-player-title { @@ -1143,31 +1245,82 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { opacity: 0.85; } -.chatpapers-lecture-beat-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 220px; - overflow-y: auto; +.chatpapers-lecture-para-list-hint { + font-size: 11px; + opacity: 0.68; + line-height: 1.35; } .chatpapers-lecture-beat-item { display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 8px 10px; + align-items: stretch; + gap: 4px; + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 40px; + box-sizing: border-box; border: 1px solid color-mix(in srgb, #1f7a8c 18%, transparent); border-radius: 8px; background: color-mix(in srgb, #1f7a8c 4%, transparent); + overflow: hidden; +} + +.chatpapers-lecture-beat-main { + flex: 1 1 auto; + min-width: 0; + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border: none; + background: transparent; cursor: pointer; text-align: left; font: inherit; + font-size: 12px; + color: inherit; + box-sizing: border-box; + overflow: hidden; +} + +.chatpapers-lecture-beat-main:hover:not(:disabled) { + background: color-mix(in srgb, #1f7a8c 10%, transparent); +} + +.chatpapers-lecture-beat-main:disabled { + opacity: 0.55; + cursor: default; +} + +.chatpapers-lecture-beat-play { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + align-self: stretch; + border: none; + border-left: 1px solid color-mix(in srgb, #1f7a8c 18%, transparent); + background: color-mix(in srgb, #1f7a8c 6%, transparent); + cursor: pointer; color: inherit; } -.chatpapers-lecture-beat-item:hover:not(:disabled) { - background: color-mix(in srgb, #1f7a8c 10%, transparent); +.chatpapers-lecture-beat-play:hover:not(:disabled) { + background: color-mix(in srgb, #1f7a8c 16%, transparent); +} + +.chatpapers-lecture-beat-play:disabled { + opacity: 0.45; + cursor: default; +} + +.chatpapers-lecture-beat-item.is-selected { + border-color: color-mix(in srgb, #2563eb 45%, transparent); + background: color-mix(in srgb, #2563eb 8%, transparent); } .chatpapers-lecture-beat-item.is-active { @@ -1175,38 +1328,53 @@ collapsible-section[open] > :not(.head) .chatpapers-lecture-root { background: color-mix(in srgb, #16a34a 10%, transparent); } +.chatpapers-lecture-beat-item.is-selected.is-active { + border-color: color-mix(in srgb, #16a34a 50%, transparent); + box-shadow: inset 3px 0 0 color-mix(in srgb, #2563eb 70%, transparent); +} + .chatpapers-lecture-beat-item.is-pending { opacity: 0.55; - cursor: default; } -.chatpapers-lecture-beat-meta { +.chatpapers-lecture-beat-line { display: flex; - flex-direction: column; - gap: 2px; + align-items: center; + gap: 8px; + flex: 1 1 auto; min-width: 0; + overflow: hidden; } -.chatpapers-lecture-beat-type { - font-size: 10px; +.chatpapers-lecture-beat-label { + flex-shrink: 0; + font-size: 11px; font-weight: 600; - opacity: 0.65; - text-transform: uppercase; - letter-spacing: 0.03em; + line-height: 1.3; + opacity: 0.72; + white-space: nowrap; } -.chatpapers-lecture-beat-title { +.chatpapers-lecture-beat-preview { + flex: 1 1 auto; + min-width: 0; font-size: 12px; - line-height: 1.35; + line-height: 1.4; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } .chatpapers-lecture-beat-status { flex-shrink: 0; + align-self: center; font-size: 11px; + line-height: 1; opacity: 0.7; + max-width: 4.5em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .chatpapers-lecture-player-controls { diff --git a/addon/content/icons/pane-adaptive-height-fixed.svg b/addon/content/icons/pane-adaptive-height-fixed.svg index 639aec6..b1ceb13 100644 --- a/addon/content/icons/pane-adaptive-height-fixed.svg +++ b/addon/content/icons/pane-adaptive-height-fixed.svg @@ -1,11 +1,7 @@ - - - - - - - - - - + + + + + + diff --git a/addon/content/icons/pane-adaptive-height.svg b/addon/content/icons/pane-adaptive-height.svg index f9a9f1b..fbd24ae 100644 --- a/addon/content/icons/pane-adaptive-height.svg +++ b/addon/content/icons/pane-adaptive-height.svg @@ -1,11 +1,7 @@ - - - - - - - - - - + + + + + + diff --git a/addon/content/icons/pane-open-window.svg b/addon/content/icons/pane-open-window.svg index 910d625..b407767 100644 --- a/addon/content/icons/pane-open-window.svg +++ b/addon/content/icons/pane-open-window.svg @@ -1,7 +1,4 @@ - - - - - - + + + diff --git a/addon/locale/en-US/addon.ftl b/addon/locale/en-US/addon.ftl index a1c0564..4320cef 100644 --- a/addon/locale/en-US/addon.ftl +++ b/addon/locale/en-US/addon.ftl @@ -4,10 +4,14 @@ prefs-title = ChatPapers item-section-chat-head = ChatPapers item-section-chat-sidenav = Chat with paper -item-pane-open-window = Open in separate window -item-pane-adaptive-height = Adaptive height -item-pane-adaptive-height-on = Adaptive height (on) -item-pane-adaptive-height-off = Adaptive height (off) +item-pane-open-window = + .tooltiptext = Open in separate window +item-pane-adaptive-height = + .tooltiptext = Toggle adaptive height +item-pane-adaptive-height-on = + .tooltiptext = Adaptive height (on) — click to use fixed height +item-pane-adaptive-height-off = + .tooltiptext = Fixed height (on) — click to use adaptive height chat-loading = Loading ChatPapers… chat-placeholder = Ask a question about this paper… @@ -91,6 +95,9 @@ 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-list-hint = Click a paragraph for details; click ▶ to play +lecture-para-detail-empty = Select a paragraph above to view original, translation, and context +lecture-para-play = Play this part lecture-para-label = Para { $n } lecture-para-original = Original lecture-para-translation = Translation diff --git a/addon/locale/zh-CN/addon.ftl b/addon/locale/zh-CN/addon.ftl index 649f7c9..08c5b9b 100644 --- a/addon/locale/zh-CN/addon.ftl +++ b/addon/locale/zh-CN/addon.ftl @@ -4,10 +4,14 @@ prefs-title = ChatPapers item-section-chat-head = ChatPapers item-section-chat-sidenav = 论文对话 -item-pane-open-window = 在独立窗口中打开 -item-pane-adaptive-height = 自适应高度 -item-pane-adaptive-height-on = 自适应高度(已开启) -item-pane-adaptive-height-off = 自适应高度(已关闭) +item-pane-open-window = + .tooltiptext = 在独立窗口中打开 +item-pane-adaptive-height = + .tooltiptext = 切换自适应高度 +item-pane-adaptive-height-on = + .tooltiptext = 自适应高度(已开启)— 点击切换为固定高度 +item-pane-adaptive-height-off = + .tooltiptext = 固定高度(已开启)— 点击切换为自适应高度 chat-loading = 正在加载 ChatPapers… chat-placeholder = 就这篇论文提问… @@ -91,6 +95,9 @@ lecture-note-saved = 伴读笔记已写入 Zotero 子笔记 lecture-mode-overview = 整体讲解 lecture-mode-paragraph = 逐段精读 lecture-paragraph-list-title = 论文段落(先读原文,再听译文) +lecture-para-list-hint = 点击段落查看详情,点击 ▶ 开始朗读 +lecture-para-detail-empty = 点击上方段落查看原文、译文与上下文 +lecture-para-play = 朗读此段 lecture-para-label = 段落 { $n } lecture-para-original = 原文 lecture-para-translation = 译文 / 讲解 diff --git a/src/hooks.ts b/src/hooks.ts index 322fced..911dee7 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -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 { addon.data.ztoolkit = createZToolkit(); + ensureChatPapersStyles(win.document); win.MozXULElement.insertFTLIfNeeded( `${addon.data.config.addonRef}-mainWindow.ftl`, ); diff --git a/src/modules/lecture/ui/lecturePane.ts b/src/modules/lecture/ui/lecturePane.ts index 5045d3f..3b70b73 100644 --- a/src/modules/lecture/ui/lecturePane.ts +++ b/src/modules/lecture/ui/lecturePane.ts @@ -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); } diff --git a/src/modules/lecture/ui/registerLecturePane.ts b/src/modules/lecture/ui/registerLecturePane.ts index dab6892..d366cd4 100644 --- a/src/modules/lecture/ui/registerLecturePane.ts +++ b/src/modules/lecture/ui/registerLecturePane.ts @@ -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 }); }, }); diff --git a/src/modules/ui/itemPaneSection.ts b/src/modules/ui/itemPaneSection.ts index 92c2adb..8295d75 100644 --- a/src/modules/ui/itemPaneSection.ts +++ b/src/modules/ui/itemPaneSection.ts @@ -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( diff --git a/src/modules/ui/readerPane.ts b/src/modules/ui/readerPane.ts index b064cb8..d7a0f79 100644 --- a/src/modules/ui/readerPane.ts +++ b/src/modules/ui/readerPane.ts @@ -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 }); }, }); diff --git a/typings/i10n.d.ts b/typings/i10n.d.ts index 977200f..b94753d 100644 --- a/typings/i10n.d.ts +++ b/typings/i10n.d.ts @@ -61,8 +61,11 @@ export type FluentMessageId = | 'lecture-no-pdf' | 'lecture-note-saved' | 'lecture-para-context' + | 'lecture-para-detail-empty' | 'lecture-para-label' + | 'lecture-para-list-hint' | 'lecture-para-original' + | 'lecture-para-play' | 'lecture-para-translation' | 'lecture-paragraph-list-title' | 'lecture-pdf-sync-hint'