增加pdf中划句或段ai伴读功能

This commit is contained in:
yhy
2026-08-29 23:07:19 +08:00
parent 1475f8b3c5
commit 0ed8f42029
22 changed files with 2045 additions and 15 deletions
+36
View File
@@ -0,0 +1,36 @@
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;
}
}
+45
View File
@@ -0,0 +1,45 @@
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)
}
}
}
}
}
+873
View File
@@ -0,0 +1,873 @@
general-sentence-separator = { " " }
general-key-control = Control
general-key-shift = Shift
general-key-alt = Alt
general-key-option = Option
general-key-command = Command
option-or-alt =
{ PLATFORM() ->
[macos] { general-key-option }
*[other] { general-key-alt }
}
command-or-control =
{ PLATFORM() ->
[macos] { general-key-command }
*[other] { general-key-control }
}
return-or-enter =
{ PLATFORM() ->
[macos] Return
*[other] Enter
}
delete-or-backspace =
{ PLATFORM() ->
[macos] Delete
*[other] Backspace
}
-os-name =
{ PLATFORM() ->
[macos] macOS
[windows] Windows
*[other] Linux
}
general-print = Print
general-remove = Remove
general-add = Add
general-remind-me-later = Remind Me Later
general-dont-ask-again = Dont Ask Again
general-choose-file = Choose File…
general-open-settings = Open Settings
general-settings = Settings…
general-help = Help
general-tag = Tag
general-got-it = Got It
general-done = Done
general-view-troubleshooting-instructions = View Troubleshooting Instructions
general-go-back = Go Back
general-accept = Accept
general-cancel = Cancel
cancel-button =
.label = { general-cancel }
general-show-in-library = Show in Library
general-restartApp = Restart { -app-name }
general-restartInTroubleshootingMode = Restart in Troubleshooting Mode
general-save = Save
general-clear = Clear
clear-button =
.label = { general-clear }
general-update = Update
general-reset-to-default = Reset to Default
general-back = Back
general-edit = Edit
general-cut = Cut
general-copy = Copy
general-paste = Paste
general-find = Find
general-delete = Delete
general-insert = Insert
general-and = and
general-et-al = et al.
general-previous = Previous
general-next = Next
general-learn-more = Learn More
general-more-information = More Information
general-warning = Warning
general-type-to-continue = Type “{ $text }” to continue.
general-continue = Continue
general-allow = Allow
general-always-allow = Always Allow
general-deny = Deny
general-red = Red
general-orange = Orange
general-yellow = Yellow
general-green = Green
general-teal = Teal
general-blue = Blue
general-purple = Purple
general-magenta = Magenta
general-violet = Violet
general-maroon = Maroon
general-gray = Gray
general-black = Black
general-loading = Loading…
db-checking-integrity = Checking database integrity…
db-repairing = Repairing database…
citation-style-label = Citation Style:
language-label = Language:
menu-custom-group-submenu =
.label = More Options…
menu-file-show-in-finder =
.label = Show in Finder
menu-file-show-file =
.label = Show File
menu-file-show-files =
.label = Show Files
menu-print =
.label = { general-print }
menu-density =
.label = Density
add-attachment = Add Attachment
new-note = New Note
menu-add-by-identifier =
.label = Add by Identifier…
menu-add-attachment =
.label = { add-attachment }
menu-add-standalone-file-attachment =
.label = Add File…
menu-add-standalone-linked-file-attachment =
.label = Add Link to File…
menu-add-child-file-attachment =
.label = Attach File…
menu-add-child-linked-file-attachment =
.label = Attach Link to File…
menu-add-child-linked-url-attachment =
.label = Attach Web Link…
menu-new-note =
.label = { new-note }
menu-new-standalone-note =
.label = New Standalone Note
menu-new-item-note =
.label = New Item Note
menu-restoreToLibrary =
.label = Restore to Library
menu-deletePermanently =
.label = Delete Permanently…
menu-tools-plugins =
.label = Plugins
menu-view-columns-move-left =
.label = Move Column Left
menu-view-columns-move-right =
.label = Move Column Right
menu-view-hide-context-annotation-rows =
.label = Hide Non-Matching Annotations
menu-view-note-font-size =
.label = Note Font Size
menu-view-note-tab-font-size =
.label = Note Tab Font Size
menu-show-tabs-menu =
.label = Show Tabs Menu
menu-edit-copy-annotation =
.label = { $count ->
[one] Copy Annotation
*[other] Copy { $count } Annotations
}
main-window-command =
.label = Library
main-window-key =
.key = L
zotero-toolbar-tabs-menu =
.tooltiptext = List all tabs
filter-collections = Filter Collections
zotero-collections-search =
.placeholder = { filter-collections }
zotero-collections-search-btn =
.tooltiptext = { filter-collections }
zotero-tabs-menu-filter =
.placeholder = Search Tabs
zotero-tabs-menu-close-button =
.title = Close Tab
zotero-toolbar-tabs-scroll-forwards =
.title = Scroll forwards
zotero-toolbar-tabs-scroll-backwards =
.title = Scroll backwards
toolbar-add-attachment =
.tooltiptext = { add-attachment }
recently-read = Recently Read
collections-menu-show-recently-read =
.label = Show { recently-read }
item-menu-remove-from-recently-read =
.label = Remove from { recently-read }…
collections-menu-clear-all-last-read =
.label = Clear All Last Read Dates…
recently-read-clear-all-confirm = All Last Read dates in this library will be erased.
# Item list section headers for a multiple-row collection-tree selection within one library
items-section-collections-selected =
{ $count ->
[one] { $count } collection selected
*[other] { $count } collections selected
}
items-section-searches-selected =
{ $count ->
[one] { $count } saved search selected
*[other] { $count } saved searches selected
}
items-section-sources-selected =
{ $count ->
[one] { $count } source selected
*[other] { $count } sources selected
}
# Item list section headers for a selection spanning multiple libraries, one per library
items-section-library-collections =
{ $count ->
[one] { $library } ({ $count } collection selected)
*[other] { $library } ({ $count } collections selected)
}
items-section-library-searches =
{ $count ->
[one] { $library } ({ $count } saved search selected)
*[other] { $library } ({ $count } saved searches selected)
}
items-section-library-sources =
{ $count ->
[one] { $library } ({ $count } source selected)
*[other] { $library } ({ $count } sources selected)
}
items-section-library-recently-read = { $library } ({ recently-read })
items-section-library = { $library }
collections-menu-rename =
.label = Rename
edit-saved-search = Edit Saved Search
collections-menu-edit-search =
.label = Edit Search
collections-menu-duplicate-search =
.label = Duplicate Search
collections-menu-move-collection =
.label = Move To
collections-menu-copy-collection =
.label = Copy To
collections-menu-export =
.label = Export…
collections-menu-generate-report =
.label = Generate Report…
collections-menu-create-bibliography =
.label = Create Bibliography…
collections-menu-unsubscribe =
.label = Unsubscribe…
collections-menu-delete =
.label = { $count ->
[one] Delete Collection…
*[other] Delete Collections…
}
collections-menu-delete-with-items =
.label = { $count ->
[one] Delete Collection and Items…
*[other] Delete Collections and Items…
}
collections-menu-delete-search =
.label = { $count ->
[one] Delete Search…
*[other] Delete Searches…
}
collections-delete-title =
{ $count ->
[one] Delete Collection
*[other] Delete Collections
}
collections-delete-message =
{ $count ->
[one] Are you sure you want to delete this collection?
*[other] Are you sure you want to delete { $count } collections?
}
collections-delete-keep-items =
{ $count ->
[one] Items within this collection will not be deleted.
*[other] Items within these collections will not be deleted.
}
collections-delete-with-items-title =
{ $count ->
[one] Delete Collection and Items
*[other] Delete Collections and Items
}
collections-delete-with-items-message =
{ $count ->
[one] Are you sure you want to delete this collection and move all items within it to the Trash?
*[other] Are you sure you want to delete { $count } collections and move all items within them to the Trash?
}
collections-delete-search-title =
{ $count ->
[one] Delete Search
*[other] Delete Searches
}
collections-delete-search-message =
{ $count ->
[one] Are you sure you want to delete this search?
*[other] Are you sure you want to delete { $count } searches?
}
item-creator-moveDown =
.label = Move Down
item-creator-moveToTop =
.label = Move to Top
item-creator-moveUp =
.label = Move Up
item-menu-viewAttachment =
.label = Open { $numAttachments ->
[one] { $attachmentType ->
[pdf] PDF
[epub] EPUB
[snapshot] Snapshot
[note] Note
*[other] Attachment
}
*[other] { $attachmentType ->
[pdf] PDFs
[epub] EPUBs
[snapshot] Snapshots
[note] Notes
*[other] Attachments
}
} {
$openIn ->
[tab] in New Tab
[window] in New Window
*[other] { "" }
}
item-menu-add-file =
.label = File
item-menu-add-linked-file =
.label = Linked File
item-menu-add-url =
.label = Web Link
item-menu-change-parent-item =
.label = Change Parent Item…
item-menu-relate-items =
.label = Relate Items
view-online = View Online
item-menu-option-view-online =
.label = {view-online}
item-button-view-online =
.tooltiptext = {view-online}
file-renaming-file-renamed-to = File renamed to { $filename }
itembox-button-options =
.tooltiptext = Open context menu
itembox-button-merge =
.aria-label = Select version of { $field } field
create-parent-intro = Enter a DOI, ISBN, PMID, arXiv ID, or ADS Bibcode to identify this file:
reader-use-dark-mode-for-content =
.label = Use Dark Mode for Content
update-updates-found-intro-minor = An update for { -app-name } is available:
update-updates-found-desc = It is recommended that you apply this update as soon as possible.
import-window =
.title = Import
import-where-from = Where do you want to import from?
import-online-intro-title = Introduction
import-source-file =
.label = A file (BibTeX, RIS, Zotero RDF, etc.)
import-source-folder =
.label = A folder of PDFs or other files
import-source-online =
.label = { $targetApp } online import
import-options = Options
import-importing = Importing…
import-create-collection =
.label = Place imported collections and items into new collection
import-recreate-structure =
.label = Recreate folder structure as collections
import-fileTypes-header = File Types to Import:
import-fileTypes-pdf =
.label = PDFs
import-fileTypes-other =
.placeholder = Other files by pattern, comma-separated (e.g., *.jpg,*.png)
import-file-handling = File Handling
import-file-handling-store =
.label = Copy files to the { -app-name } storage folder
import-file-handling-link =
.label = Link to files in original location
import-fileHandling-description = Linked files cannot be synced by { -app-name }.
import-online-new =
.label = Download new items only; dont update previously imported items
import-mendeley-username = Username
import-mendeley-password = Password
general-error = Error
file-interface-import-error = An error occurred while trying to import the selected file. Please ensure that the file is valid and try again.
file-interface-import-complete = Import Complete
file-interface-items-were-imported = { $numItems ->
[0] No items were imported
[one] One item was imported
*[other] { $numItems } items were imported
}
file-interface-items-were-relinked = { $numRelinked ->
[0] No items were relinked
[one] One item was relinked
*[other] { $numRelinked } items were relinked
}
import-mendeley-cannot-decrypt = The selected Mendeley database could not be decrypted. This can happen if the database file has been renamed. See <a data-l10n-name="mendeley-import-kb">How do I import a Mendeley library into Zotero?</a> for more information.
import-mendeley-unsupported = The selected file does not appear to be a Mendeley database. See <a data-l10n-name="mendeley-import-kb">How do I import a Mendeley library into Zotero?</a> for more information.
import-mendeley-db-in-use = The selected Mendeley database is in use. Please quit Mendeley Desktop and try again.
file-interface-import-error-translator = An error occurred importing the selected file with “{ $translator }”. Please ensure that the file is valid and try again.
import-online-intro=In the next step you will be asked to log in to { $targetAppOnline } and grant { -app-name } access. This is necessary to import your { $targetApp } library into { -app-name }.
import-online-intro2={ -app-name } will never see or store your { $targetApp } password.
import-online-form-intro = Please enter your credentials to log in to { $targetAppOnline }. This is necessary to import your { $targetApp } library into { -app-name }.
import-online-wrong-credentials = Login to { $targetApp } failed. Please re-enter credentials and try again.
import-online-blocked-by-plugin = The import cannot continue with { $plugin } installed. Please disable this plugin and try again.
import-online-relink-only =
.label = Relink Mendeley Desktop citations
import-online-relink-kb = { general-more-information }
import-online-connection-error = { -app-name } could not connect to { $targetApp }. Please check your internet connection and try again.
tab-title-multiple-collections = Multiple
items-table-cell-notes =
.aria-label = { $count ->
[one] { $count } Note
*[other] { $count } Notes
}
items-column-added-by = Added By
items-column-modified-by = Modified By
items-column-last-read = Last Read
report-error =
.label = Report Error…
rtfScan-wizard =
.title = RTF Scan
rtfScan-introPage-description = { -app-name } can automatically extract and reformat citations and insert a bibliography into RTF files. It currently supports citations in variations of the following formats:
rtfScan-introPage-description2 = To get started, select an RTF input file and an output file below:
rtfScan-input-file = Input File:
rtfScan-output-file = Output File:
rtfScan-no-file-selected = No file selected
rtfScan-choose-input-file =
.label = { general-choose-file }
.aria-label = Choose Input File
rtfScan-choose-output-file =
.label = { general-choose-file }
.aria-label = Choose Output File
rtfScan-intro-page = Introduction
rtfScan-scan-page = Scanning for Citations
rtfScan-scanPage-description = { -app-name } is scanning your document for citations. Please be patient.
rtfScan-citations-page = Verify Cited Items
rtfScan-citations-page-description = Please review the list of recognized citations below to ensure that { -app-name } has selected the corresponding items correctly. Any unmapped or ambiguous citations must be resolved before proceeding to the next step.
rtfScan-style-page = Document Formatting
rtfScan-format-page = Formatting Citations
rtfScan-format-page-description = { -app-name } is processing and formatting your RTF file. Please be patient.
rtfScan-complete-page = RTF Scan Complete
rtfScan-complete-page-description = Your document has now been scanned and processed. Please ensure that it is formatted correctly.
rtfScan-action-find-match =
.title = Select matching item
rtfScan-action-accept-match =
.title = Accept this match
runJS-title = Run JavaScript
runJS-editor-label = Code:
runJS-run = Run
runJS-help = { general-help }
runJS-completed = completed successfully
runJS-result = {
$type ->
[async] Return value:
*[other] Result:
}
runJS-run-async = Run as async function
bibliography-window =
.title = { -app-name } - Create Citation/Bibliography
bibliography-style-label = { citation-style-label }
bibliography-locale-label = { language-label }
bibliography-displayAs-label = Display citations as:
bibliography-advancedOptions-label = Advanced Options
bibliography-outputMode-label = Output Mode:
bibliography-outputMode-citations =
.label = {
$type ->
[citation] Citations
[note] Notes
*[other] Citations
}
bibliography-outputMode-bibliography =
.label = Bibliography
bibliography-outputMethod-label = Output Method:
bibliography-outputMethod-saveAsRTF =
.label = Save as RTF
bibliography-outputMethod-saveAsHTML =
.label = Save as HTML
bibliography-outputMethod-copyToClipboard =
.label = Copy to Clipboard
bibliography-outputMethod-print =
.label = Print
bibliography-manageStyles-label = Manage Styles…
styleEditor-locatorType =
.aria-label = Locator type
styleEditor-locatorInput = Locator input
styleEditor-citationStyle = { citation-style-label }
styleEditor-locale = { language-label }
styleEditor-editor =
.aria-label = Style editor
styleEditor-preview =
.aria-label = Preview
stylePreview-generating = Generating previews…
publications-intro-page = My Publications
publications-intro = Items you add to My Publications will be shown on your profile page on zotero.org. If you choose to include attached files, they will be made publicly available under the license you specify. Only add work you yourself have created, and only include files if you have the rights to distribute them and wish to do so.
publications-include-checkbox-files =
.label = Include files
publications-include-checkbox-notes =
.label = Include notes
publications-include-adjust-at-any-time = You can adjust what to show at any time from the My Publications collection.
publications-intro-authorship =
.label = I created this work.
publications-intro-authorship-files =
.label = I created this work and have the rights to distribute included files.
publications-sharing-page = Choose how your work may be shared
publications-sharing-keep-rights-field =
.label = Keep the existing Rights field
publications-sharing-keep-rights-field-where-available =
.label = Keep the existing Rights field where available
publications-sharing-text = You can reserve all rights to your work, license it under a Creative Commons license, or dedicate it to the public domain. In all cases, the work will be made publicly available via zotero.org.
publications-sharing-prompt = Would you like to allow your work to be shared by others?
publications-sharing-reserved =
.label = No, only publish my work on zotero.org
publications-sharing-cc =
.label = Yes, under a Creative Commons license
publications-sharing-cc0 =
.label = Yes, and place my work in the public domain
publications-license-page = Choose a Creative Commons license
publications-choose-license-text = A Creative Commons license allows others to copy and redistribute your work as long as they give appropriate credit, provide a link to the license, and indicate if changes were made. Additional conditions can be specified below.
publications-choose-license-adaptations-prompt = Allow adaptations of your work to be shared?
publications-choose-license-yes =
.label = Yes
.accesskey = Y
publications-choose-license-no =
.label = No
.accesskey = N
publications-choose-license-sharealike =
.label = Yes, as long as others share alike
.accesskey = S
publications-choose-license-commercial-prompt = Allow commercial uses of your work?
publications-buttons-add-to-my-publications =
.label = Add to My Publications
publications-buttons-next-sharing =
.label = Next: Sharing
publications-buttons-next-choose-license =
.label = Choose a License
licenses-cc-0 = CC0 1.0 Universal Public Domain Dedication
licenses-cc-by = Creative Commons Attribution 4.0 International License
licenses-cc-by-nd = Creative Commons Attribution-NoDerivatives 4.0 International License
licenses-cc-by-sa = Creative Commons Attribution-ShareAlike 4.0 International License
licenses-cc-by-nc = Creative Commons Attribution-NonCommercial 4.0 International License
licenses-cc-by-nc-nd = Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License
licenses-cc-by-nc-sa = Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License
licenses-cc-more-info = Be sure you have read the Creative Commons <a data-l10n-name="license-considerations">Considerations for licensors</a> before placing your work under a CC license. Note that the license you apply cannot be revoked, even if you later choose different terms or cease publishing the work.
licenses-cc0-more-info = Be sure you have read the Creative Commons <a data-l10n-name="license-considerations">CC0 FAQ</a> before applying CC0 to your work. Please note that dedicating your work to the public domain is irreversible, even if you later choose different terms or cease publishing the work.
debug-output-logging-restart-in-troubleshooting-mode-checkbox = { general-restartInTroubleshootingMode }
restart-in-troubleshooting-mode-menuitem =
.label = Restart in Troubleshooting Mode…
.accesskey = T
restart-in-troubleshooting-mode-dialog-title = { general-restartInTroubleshootingMode }
restart-in-troubleshooting-mode-dialog-description = { -app-name } will restart with all plugins disabled. Some features may not function correctly while Troubleshooting Mode is enabled.
menu-ui-density =
.label = Density
menu-ui-density-comfortable =
.label = Comfortable
menu-ui-density-compact =
.label = Compact
pane-item-details = Item Details
pane-info = Info
pane-abstract = Abstract
pane-attachments = Attachments
pane-notes = Notes
pane-note-info = Note Info
pane-libraries-collections = Libraries and Collections
pane-tags = Tags
pane-related = Related
pane-attachment-info = Attachment Info
pane-attachment-preview = Preview
pane-attachment-annotations = Annotations
pane-header-attachment-associated =
.label = Rename associated file
item-details-pane =
.aria-label = { pane-item-details }
section-info =
.label = { pane-info }
section-abstract =
.label = { pane-abstract }
section-attachments =
.label = { $count ->
[one] { $count } Attachment
*[other] { $count } Attachments
}
section-attachment-preview =
.label = { pane-attachment-preview }
section-attachments-annotations =
.label = { $count ->
[one] { $count } Annotation
*[other] { $count } Annotations
}
section-attachments-move-to-trash-message = Are you sure you want to move “{ $title }” to the trash?
section-notes =
.label = { $count ->
[one] { $count } Note
*[other] { $count } Notes
}
section-libraries-collections =
.label = { pane-libraries-collections }
section-tags =
.label = { $count ->
[one] { $count } Tag
*[other] { $count } Tags
}
section-related =
.label = { $count } Related
section-attachment-info =
.label = { pane-attachment-info }
section-button-remove =
.tooltiptext = { general-remove }
section-button-add =
.tooltiptext = { general-add }
section-button-expand =
.dynamic-tooltiptext = Expand section
.label = Expand { $section } section
section-button-collapse =
.dynamic-tooltiptext = Collapse section
.label = Collapse { $section } section
annotations-count =
{ $count ->
[one] { $count } Annotation
*[other] { $count } Annotations
}
section-button-annotations =
.title = { annotations-count }
.aria-label = { annotations-count }
attachment-preview =
.aria-label = { pane-attachment-preview }
sidenav-info =
.tooltiptext = { pane-info }
sidenav-abstract =
.tooltiptext = { pane-abstract }
sidenav-attachments =
.tooltiptext = { pane-attachments }
sidenav-notes =
.tooltiptext = { pane-notes }
sidenav-note-info =
.tooltiptext = { pane-note-info }
sidenav-attachment-info =
.tooltiptext = { pane-attachment-info }
sidenav-attachment-preview =
.tooltiptext = { pane-attachment-preview }
sidenav-attachment-annotations =
.tooltiptext = { pane-attachment-annotations }
sidenav-libraries-collections =
.tooltiptext = { pane-libraries-collections }
sidenav-tags =
.tooltiptext = { pane-tags }
sidenav-related =
.tooltiptext = { pane-related }
sidenav-main-btn-grouping =
.aria-label = { pane-item-details }
sidenav-reorder-up =
.label = Move Section Up
sidenav-reorder-down =
.label = Move Section Down
sidenav-reorder-reset =
.label = Reset Section Order
toggle-item-pane =
.tooltiptext = Toggle Item Pane
toggle-context-pane =
.tooltiptext = Toggle Context Pane
pin-section =
.label = Pin Section
unpin-section =
.label = Unpin Section
collapse-other-sections =
.label = Collapse Other Sections
expand-all-sections =
.label = Expand All Sections
abstract-field =
.placeholder = Add abstract…
tag-field =
.aria-label = { general-tag }
tagselector-search =
.placeholder = Filter Tags
context-notes-search =
.placeholder = Search Notes
context-notes-return-button =
.aria-label = { general-go-back }
new-collection = New Collection…
menu-new-collection =
.label = { new-collection }
toolbar-new-collection =
.tooltiptext = { new-collection }
new-collection-dialog =
.title = New Collection
.buttonlabelaccept = Create Collection
new-collection-name = Name:
new-collection-create-in = Create in:
show-publications-menuitem =
.label = Show My Publications
attachment-info-title = Title
attachment-info-filename = Filename
attachment-info-accessed = Accessed
attachment-info-pages = Pages
attachment-info-modified = Modified
attachment-info-index = Indexed
attachment-info-convert-note =
.label = Migrate to {
$type ->
[standalone] Standalone
[child] Item
*[unknown] New
} Note
.tooltiptext = Adding notes to attachments is no longer supported, but you can edit this note by migrating it to a separate note.
section-note-info =
.label = { pane-note-info }
note-info-title = Title
note-info-parent-item = Parent Item
note-info-parent-item-button = {
$hasParentItem ->
[true] { $parentItemTitle }
*[false] None
}
.title = {
$hasParentItem ->
[true] View parent item in library
*[false] View note item in library
}
note-info-date-created = Created
note-info-date-modified = Modified
note-info-size = Size
note-info-word-count = Word Count
note-info-character-count = Character Count
item-title-empty-note = Untitled Note
attachment-preview-placeholder = No attachment to preview
attachment-rename-from-parent =
.tooltiptext = Rename File to Match Parent Item
account-log-in = Log In
account-not-logged-in-text = Log in to your Zotero account to sync your data.
account-error-login-session-expired = Your login session has expired. Please try again.
toggle-preview =
.label = {
$type ->
[open] Hide
[collapsed] Show
*[unknown] Toggle
} Attachment Preview
annotation-image-not-available = [Image not available]
quicksearch-mode =
.aria-label = Quick Search mode
quicksearch-input =
.aria-label = Quick Search
.placeholder = { $placeholder }
.aria-description = { $placeholder }
advanced-search = Advanced Search
menuitem-advanced-search =
.label = { advanced-search }
quicksearch-advanced-search-button =
.tooltiptext = { advanced-search }
.aria-label = { advanced-search }
advanced-search-close =
.tooltiptext = Close Advanced Search
advanced-search-expand =
.tooltiptext = Expand Advanced Search
advanced-search-collapse =
.tooltiptext = Collapse Advanced Search
item-pane-header-view-as =
.label = View As
item-pane-header-none =
.label = None
item-pane-header-title =
.label = Title
item-pane-header-titleCreatorYear =
.label = Title, Creator, Year
item-pane-header-bibEntry =
.label = Bibliography Entry
item-pane-header-more-options =
.label = More Options
item-pane-message-items-selected = { $count ->
[0] No items selected
[one] {
+21
View File
@@ -14,6 +14,27 @@
min-height: 0 !important; min-height: 0 !important;
} }
collapsible-section.chatpapers-adaptive-height[open] {
--open-height: auto;
}
collapsible-section.chatpapers-adaptive-height[open] > :not(.head) .chatpapers-pane-body {
max-height: none;
overflow: visible;
}
collapsible-section.chatpapers-fixed-height[open] > :not(.head) .chatpapers-pane-body {
overflow-y: auto;
}
#chatpapers-window-chat-root,
#chatpapers-window-lecture-root {
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
}
collapsible-section:not([open]) > :not(.head) .chatpapers-root, collapsible-section:not([open]) > :not(.head) .chatpapers-root,
collapsible-section:not([open]) > :not(.head) .chatpapers-lecture-root { collapsible-section:not([open]) > :not(.head) .chatpapers-lecture-root {
min-height: 0 !important; min-height: 0 !important;
+4
View File
@@ -4,6 +4,10 @@ prefs-title = ChatPapers
item-section-chat-head = ChatPapers item-section-chat-head = ChatPapers
item-section-chat-sidenav = Chat with paper 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)
chat-loading = Loading ChatPapers… chat-loading = Loading ChatPapers…
chat-placeholder = Ask a question about this paper… chat-placeholder = Ask a question about this paper…
+4
View File
@@ -4,6 +4,10 @@ prefs-title = ChatPapers
item-section-chat-head = ChatPapers item-section-chat-head = ChatPapers
item-section-chat-sidenav = 论文对话 item-section-chat-sidenav = 论文对话
item-pane-open-window = 在独立窗口中打开
item-pane-adaptive-height = 自适应高度
item-pane-adaptive-height-on = 自适应高度(已开启)
item-pane-adaptive-height-off = 自适应高度(已关闭)
chat-loading = 正在加载 ChatPapers… chat-loading = 正在加载 ChatPapers…
chat-placeholder = 就这篇论文提问… chat-placeholder = 就这篇论文提问…
+2
View File
@@ -20,3 +20,5 @@ pref("lectureTeacherStyle", "balanced");
pref("mineruPath", ""); pref("mineruPath", "");
pref("piperPath", ""); pref("piperPath", "");
pref("piperModelPath", ""); pref("piperModelPath", "");
pref("chatPaneAdaptiveHeight", true);
pref("lecturePaneAdaptiveHeight", true);
+2
View File
@@ -1,6 +1,7 @@
import { initLocale } from "./utils/locale"; import { initLocale } from "./utils/locale";
import { createZToolkit } from "./utils/ztoolkit"; import { createZToolkit } from "./utils/ztoolkit";
import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane"; import { registerLecturePane } from "./modules/lecture/ui/registerLecturePane";
import { registerSelectionExplainPopup } from "./modules/lecture/ui/registerSelectionExplainPopup";
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane"; import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
import { registerPrefsScripts } from "./modules/ui/prefs"; import { registerPrefsScripts } from "./modules/ui/prefs";
import { registerItemMenus } from "./modules/ui/itemMenu"; import { registerItemMenus } from "./modules/ui/itemMenu";
@@ -18,6 +19,7 @@ async function onStartup() {
registerChatPane(); registerChatPane();
registerLecturePane(); registerLecturePane();
registerReaderSelectionHook(); registerReaderSelectionHook();
registerSelectionExplainPopup();
await Promise.all( await Promise.all(
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)), Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
@@ -0,0 +1,246 @@
import type { SummaryJson } from "../domain/types";
import { isPrimarilyEnglish } from "../infrastructure/llm/paragraphLlm";
import { generateSentenceExplanation } from "../infrastructure/llm/sentenceLlm";
import { ensureAudioDir } from "../infrastructure/storage/audioStorage";
import {
loadLectureData,
mergeLectureData,
type AdHocSelectionExplain,
type StoredParagraph,
} from "../infrastructure/storage/lectureStore";
import { audioFilePath } from "../infrastructure/storage/paths";
import { synthesizeSpeech } from "../infrastructure/tts/client";
import { TtsError } from "../infrastructure/tts/types";
import {
getRegularItem,
resolvePaperAttachment,
} from "../infrastructure/zotero/adapter";
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import type { TextChunk } from "../infrastructure/pdf/chunker";
import {
findMatchingPreparedSentence,
normalizeSelectionText,
} from "./matchPreparedSentence";
const TTS_ZH_MAX = 280;
const TTS_EN_MAX = 600;
export interface SelectionExplainResult {
original: string;
translation: string;
syntaxNote: string;
contextRole: string;
audioPathEn?: string;
audioPathZh: string;
page?: number;
fromCache: boolean;
fromPrepared: boolean;
matched?: {
paragraphId: string;
sentenceId: string;
};
}
function selectionUnitId(text: string): string {
const norm = normalizeSelectionText(text);
let hash = 0;
for (let i = 0; i < norm.length; i++) {
hash = (hash * 31 + norm.charCodeAt(i)) | 0;
}
return `adhoc-${Math.abs(hash).toString(36)}`;
}
function findSurroundingContext(
chunks: TextChunk[] | undefined,
selectedText: string,
): string {
if (!chunks?.length || !selectedText.trim()) return "";
const needle = normalizeSelectionText(selectedText).slice(0, 48);
if (!needle) return "";
for (const chunk of chunks) {
const hay = normalizeSelectionText(chunk.text);
const idx = hay.indexOf(needle);
if (idx < 0) continue;
const start = Math.max(0, idx - 220);
const end = Math.min(chunk.text.length, idx + selectedText.length + 220);
return chunk.text.slice(start, end);
}
return "";
}
function findAdHocCache(
entries: AdHocSelectionExplain[] | undefined,
selectedText: string,
): AdHocSelectionExplain | undefined {
if (!entries?.length) return undefined;
const norm = normalizeSelectionText(selectedText);
return entries.find(
(entry) => normalizeSelectionText(entry.original) === norm && entry.audioPathZh,
);
}
function preparedToResult(
match: NonNullable<ReturnType<typeof findMatchingPreparedSentence>>,
): SelectionExplainResult {
const { paragraph, sentence } = match;
return {
original: sentence.text,
translation: sentence.translation,
syntaxNote: "",
contextRole: paragraph.roleInPaper || paragraph.contextLink || "",
audioPathEn:
sentence.ttsEnStatus === "ready" ? sentence.audioPathEn : undefined,
audioPathZh: sentence.audioPathZh!,
page: sentence.page ?? paragraph.page,
fromCache: true,
fromPrepared: true,
matched: {
paragraphId: paragraph.id,
sentenceId: sentence.id,
},
};
}
function adHocToResult(entry: AdHocSelectionExplain): SelectionExplainResult {
return {
original: entry.original,
translation: entry.translation,
syntaxNote: entry.syntaxNote,
contextRole: entry.contextRole,
audioPathEn: entry.audioPathEn,
audioPathZh: entry.audioPathZh!,
page: entry.page,
fromCache: true,
fromPrepared: false,
};
}
async function synthesizeSelectionAudio(options: {
cacheKey: string;
unitId: string;
original: string;
translation: string;
}): Promise<{ audioPathEn?: string; audioPathZh: string }> {
await ensureAudioDir(options.cacheKey);
const en = isPrimarilyEnglish(options.original);
let audioPathEn: string | undefined;
if (en) {
const outPath = audioFilePath(options.cacheKey, `${options.unitId}-en`, "wav");
const result = await synthesizeSpeech({
text: options.original.slice(0, TTS_EN_MAX),
outputPath: outPath,
lang: "en",
});
audioPathEn = result.audioPath;
}
const zhPath = audioFilePath(options.cacheKey, `${options.unitId}-zh`, "wav");
const zhResult = await synthesizeSpeech({
text: options.translation.slice(0, TTS_ZH_MAX),
outputPath: zhPath,
lang: "zh",
});
return { audioPathEn, audioPathZh: zhResult.audioPath };
}
export async function explainSelectionLecture(options: {
item: Zotero.Item;
selectedText: string;
onProgress?: (message: string) => void;
}): Promise<SelectionExplainResult> {
const selectedText = options.selectedText.trim();
if (!selectedText) {
throw new Error("请先选中 PDF 中的文本。");
}
const ctx = await resolvePaperAttachment(options.item);
const data = await loadLectureData(ctx.cacheKey);
const paragraphs = data?.paragraphs;
const prepared = findMatchingPreparedSentence(paragraphs, selectedText);
if (prepared?.sentence.audioPathZh) {
return preparedToResult(prepared);
}
const cached = findAdHocCache(data?.adHocSelections, selectedText);
if (cached?.audioPathZh) {
return adHocToResult(cached);
}
options.onProgress?.("正在生成单句讲解…");
const parent = getRegularItem(options.item);
const title = String(parent.getField("title") || "");
const summary = data?.summary as SummaryJson | undefined;
const surroundingContext = findSurroundingContext(data?.chunks, selectedText);
const llm = await generateSentenceExplanation({
paperTitle: title,
summary,
selectedText,
surroundingContext,
});
options.onProgress?.("正在合成语音…");
const unitId = selectionUnitId(selectedText);
let audioPathEn: string | undefined;
let audioPathZh: string;
try {
const audio = await synthesizeSelectionAudio({
cacheKey: ctx.cacheKey,
unitId,
original: selectedText,
translation: llm.translation,
});
audioPathEn = audio.audioPathEn;
audioPathZh = audio.audioPathZh;
} catch (e) {
if (e instanceof TtsError) throw e;
throw new Error(e instanceof Error ? e.message : String(e));
}
const entry: AdHocSelectionExplain = {
id: unitId,
original: selectedText,
translation: llm.translation,
syntaxNote: llm.syntaxNote,
contextRole: llm.contextRole,
audioPathEn,
audioPathZh,
createdAt: Date.now(),
};
const existing = data?.adHocSelections ?? [];
const nextEntries = [
entry,
...existing.filter(
(e) => normalizeSelectionText(e.original) !== normalizeSelectionText(selectedText),
),
].slice(0, 40);
await mergeLectureData(ctx.cacheKey, paperIdFromCacheKey(ctx.cacheKey), {
adHocSelections: nextEntries,
});
return {
original: selectedText,
translation: llm.translation,
syntaxNote: llm.syntaxNote,
contextRole: llm.contextRole,
audioPathEn,
audioPathZh,
fromCache: false,
fromPrepared: false,
};
}
export function findPreparedMatchForSelection(
paragraphs: StoredParagraph[] | undefined,
selectedText: string,
) {
return findMatchingPreparedSentence(paragraphs, selectedText);
}
@@ -0,0 +1,87 @@
import type {
StoredParagraph,
StoredSentence,
} from "../infrastructure/storage/lectureStore";
export function normalizeSelectionText(text: string): string {
return text
.replace(/[\u0000-\u001f\f]/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
export interface PreparedSentenceMatch {
paragraph: StoredParagraph;
sentence: StoredSentence;
}
function overlapScore(a: string, b: string): number {
const na = normalizeSelectionText(a);
const nb = normalizeSelectionText(b);
if (!na || !nb) return 0;
if (na === nb) return 100;
if (na.includes(nb) || nb.includes(na)) {
const shorter = Math.min(na.length, nb.length);
const longer = Math.max(na.length, nb.length);
return Math.round((shorter / longer) * 90);
}
const aWords = new Set(na.split(/\s+/).filter((w) => w.length > 2));
const bWords = nb.split(/\s+/).filter((w) => w.length > 2);
if (!aWords.size || !bWords.length) return 0;
let shared = 0;
for (const w of bWords) {
if (aWords.has(w)) shared += 1;
}
return Math.round((shared / bWords.length) * 70);
}
export function findMatchingPreparedSentence(
paragraphs: StoredParagraph[] | undefined,
selectedText: string,
options?: { requireTts?: boolean },
): PreparedSentenceMatch | undefined {
if (!paragraphs?.length || !selectedText.trim()) return undefined;
const requireTts = options?.requireTts !== false;
let best: PreparedSentenceMatch | undefined;
let bestScore = 0;
for (const paragraph of paragraphs) {
if (paragraph.genStatus !== "ready") continue;
for (const sentence of paragraph.sentences ?? []) {
if (requireTts && sentence.ttsZhStatus !== "ready") continue;
const score = overlapScore(selectedText, sentence.text);
if (score > bestScore) {
bestScore = score;
best = { paragraph, sentence };
}
}
if (!paragraph.sentences?.length) {
const score = overlapScore(selectedText, paragraph.originalText);
if (score > bestScore && (!requireTts || paragraph.ttsZhStatus === "ready")) {
bestScore = score;
best = {
paragraph,
sentence: {
id: `${paragraph.id}-whole`,
orderIndex: 0,
text: paragraph.originalText,
translation: paragraph.translation,
page: paragraph.page,
searchText: paragraph.searchText,
audioPathEn: paragraph.audioPathEn,
audioPathZh: paragraph.audioPathZh,
ttsEnStatus: paragraph.ttsEnStatus,
ttsZhStatus: paragraph.ttsZhStatus,
},
};
}
}
}
return bestScore >= 55 ? best : undefined;
}
@@ -0,0 +1,65 @@
import { pathToFileUrl } from "../infrastructure/platform/paths";
import type { SelectionExplainResult } from "./explainSelectionLecture";
function waitAudioEnded(audio: HTMLAudioElement): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
audio.onended = null;
audio.onerror = null;
};
audio.onended = () => {
cleanup();
resolve();
};
audio.onerror = () => {
cleanup();
reject(new Error("Audio playback failed"));
};
});
}
function audioPaths(result: SelectionExplainResult): string[] {
const paths: string[] = [];
if (result.audioPathEn) paths.push(result.audioPathEn);
if (result.audioPathZh) paths.push(result.audioPathZh);
return paths;
}
export async function playSelectionExplainAudio(
audio: HTMLAudioElement,
result: SelectionExplainResult,
): Promise<void> {
const paths = audioPaths(result);
if (!paths.length) return;
audio.pause();
for (const path of paths) {
audio.src = pathToFileUrl(path);
audio.load();
await audio.play();
await waitAudioEnded(audio);
}
}
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;
}
export async function playSelectionExplainStandalone(
result: SelectionExplainResult,
): Promise<void> {
await playSelectionExplainAudio(getStandaloneAudio(), result);
}
export function stopSelectionExplainStandalone(): void {
standaloneAudio?.pause();
}
@@ -9,6 +9,12 @@ export type LectureEventMap = {
lectureStatus: string; lectureStatus: string;
message?: string; message?: string;
}; };
"lecture:selection_explain": {
paperId: string;
cacheKey: string;
itemId: string;
result: import("../application/explainSelectionLecture").SelectionExplainResult;
};
}; };
type Handler<K extends keyof LectureEventMap> = ( type Handler<K extends keyof LectureEventMap> = (
@@ -0,0 +1,79 @@
import { parseJsonLoose } from "../../../llm/jsonParse";
import { LlmError } from "../../../llm/types";
import type { SummaryJson } from "../../domain/types";
import { requestStructuredOutput } from "./structuredOutput";
const SENTENCE_SYSTEM = `你是 ChatPapers 论文伴读助手。用户选中论文中的一句原文,需要你解释并用于语音朗读。
必须只输出一个 JSON 对象,不要 markdown 代码块。
格式:{"translation":"...","syntaxNote":"...","contextRole":"..."}
要求:
- translation:准确中文翻译,并用 30–80 字口语解释该句含义(不要念 LaTeX,公式转述为口语)。
- syntaxNote:1–2 句,拆解句法结构或关键术语(英文句为主;中文句可简述逻辑)。
- contextRole:1 句,说明该句在段落或全文论证中的作用。
- 术语翻译与全文保持一致。`;
export interface SentenceLlmResult {
translation: string;
syntaxNote: string;
contextRole: string;
}
function parseSentenceJson(raw: string): SentenceLlmResult {
const parsed = parseJsonLoose<Record<string, unknown>>(raw);
const translation = String(parsed.translation || parsed.zh || "").trim();
if (!translation) {
throw new LlmError("provider", "单句讲解 JSON 缺少 translation 字段。");
}
return {
translation,
syntaxNote: String(parsed.syntaxNote || parsed.syntax || "").trim(),
contextRole: String(parsed.contextRole || parsed.role || parsed.context || "").trim(),
};
}
function fallbackSentence(selectedText: string): SentenceLlmResult {
return {
translation: selectedText.slice(0, 200),
syntaxNote: "",
contextRole: "(模型未能生成上下文说明,请重试或更换模型。)",
};
}
function formatSummaryBlock(summary?: SummaryJson): string {
if (!summary) return "(暂无全文摘要)";
return [
`问题:${summary.problem}`,
`方法:${summary.method}`,
`结果:${summary.result}`,
`局限:${summary.limitation}`,
].join("\n");
}
export async function generateSentenceExplanation(options: {
paperTitle: string;
summary?: SummaryJson;
selectedText: string;
surroundingContext?: string;
}): Promise<SentenceLlmResult> {
const user = `【论文标题】${options.paperTitle}
【全文摘要】
${formatSummaryBlock(options.summary)}
【选区附近上下文(可能为空)】
${options.surroundingContext?.trim() || "(无)"}
【用户选中的句子】
${options.selectedText.trim()}
请输出 JSON。`;
return requestStructuredOutput({
system: SENTENCE_SYSTEM,
user,
parse: parseSentenceJson,
jsonMode: true,
fallback: () => fallbackSentence(options.selectedText),
});
}
@@ -44,6 +44,18 @@ export interface StoredParagraph {
genStatus: "pending" | "ready" | "failed"; genStatus: "pending" | "ready" | "failed";
} }
export interface AdHocSelectionExplain {
id: string;
original: string;
translation: string;
syntaxNote: string;
contextRole: string;
page?: number;
audioPathEn?: string;
audioPathZh?: string;
createdAt: number;
}
export interface LectureData { export interface LectureData {
paperId: string; paperId: string;
cacheKey: string; cacheKey: string;
@@ -53,6 +65,7 @@ export interface LectureData {
chunks?: TextChunk[]; chunks?: TextChunk[];
beats?: StoredBeat[]; beats?: StoredBeat[];
paragraphs?: StoredParagraph[]; paragraphs?: StoredParagraph[];
adHocSelections?: AdHocSelectionExplain[];
noteItemId?: string; noteItemId?: string;
extractChars?: number; extractChars?: number;
extractSource?: string; extractSource?: string;
+60 -2
View File
@@ -1,4 +1,4 @@
import { Headphones, Pause, Play, SkipBack, SkipForward, Sparkles, Volume2 } from "lucide"; import { Headphones, Highlighter, Pause, Play, SkipBack, SkipForward, Sparkles, Volume2 } from "lucide";
import type { LectureStatus, PaperCache } from "../domain/types"; import type { LectureStatus, PaperCache } from "../domain/types";
import { prepareLecture } from "../application/prepareLecture"; import { prepareLecture } from "../application/prepareLecture";
import { import {
@@ -42,6 +42,12 @@ import {
import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash"; import { paperIdFromCacheKey } from "../infrastructure/pdf/fileHash";
import { chunkById } from "../infrastructure/pdf/chunker"; import { chunkById } from "../infrastructure/pdf/chunker";
import { highlightTextInPdf } from "../../pdf/readerSync"; import { highlightTextInPdf } from "../../pdf/readerSync";
import { getReaderSelectedText } from "../../pdf/selection";
import {
explainSelectionLecture,
type SelectionExplainResult,
} from "../application/explainSelectionLecture";
import { playSelectionExplainAudio } from "../application/playSelectionExplain";
import { getString } from "../../../utils/locale"; import { getString } from "../../../utils/locale";
import { createLucideIcon } from "../../../utils/icons"; import { createLucideIcon } from "../../../utils/icons";
@@ -62,12 +68,15 @@ export class LecturePaneView {
private activeParagraphId?: string; private activeParagraphId?: string;
private activeSentenceId?: string; private activeSentenceId?: string;
private pdfSyncEnabled = true; private pdfSyncEnabled = true;
private explainingSelection = false;
private lastSelectionResult?: SelectionExplainResult;
private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[]; private lectureChunks?: import("../infrastructure/pdf/chunker").TextChunk[];
private statusEl!: HTMLElement; private statusEl!: HTMLElement;
private statusTextEl!: HTMLElement; private statusTextEl!: HTMLElement;
private ttsMetaEl!: HTMLElement; private ttsMetaEl!: HTMLElement;
private actionBtn!: HTMLButtonElement; private actionBtn!: HTMLButtonElement;
private explainSelectionBtn!: HTMLButtonElement;
private ttsTestBtn!: HTMLButtonElement; private ttsTestBtn!: HTMLButtonElement;
private noteBtn!: HTMLButtonElement; private noteBtn!: HTMLButtonElement;
private hintEl!: HTMLElement; private hintEl!: HTMLElement;
@@ -76,6 +85,7 @@ export class LecturePaneView {
private granularityTabsEl!: HTMLElement; private granularityTabsEl!: HTMLElement;
private beatListEl!: HTMLElement; private beatListEl!: HTMLElement;
private paragraphDetailEl!: HTMLElement; private paragraphDetailEl!: HTMLElement;
private selectionExplainEl!: HTMLElement;
private playerTitleEl!: HTMLElement; private playerTitleEl!: HTMLElement;
private playBtn!: HTMLButtonElement; private playBtn!: HTMLButtonElement;
private pauseBtn!: HTMLButtonElement; private pauseBtn!: HTMLButtonElement;
@@ -154,6 +164,13 @@ export class LecturePaneView {
this.noteBtn.textContent = getString("lecture-save-note"); this.noteBtn.textContent = getString("lecture-save-note");
this.noteBtn.addEventListener("click", () => void this.onSaveNote()); this.noteBtn.addEventListener("click", () => void this.onSaveNote());
this.explainSelectionBtn = this.makeActionBtn(
Highlighter,
getString("lecture-explain-selection"),
() => void this.onExplainSelection(),
{ preserveSelection: true },
);
this.playerSection = this.el("div", "chatpapers-lecture-player"); this.playerSection = this.el("div", "chatpapers-lecture-player");
this.playerSection.hidden = true; this.playerSection.hidden = true;
@@ -175,6 +192,8 @@ export class LecturePaneView {
this.playerTitleEl = playerTitle; this.playerTitleEl = playerTitle;
this.beatListEl = this.el("div", "chatpapers-lecture-beat-list"); this.beatListEl = this.el("div", "chatpapers-lecture-beat-list");
this.paragraphDetailEl = this.el("div", "chatpapers-lecture-para-detail"); this.paragraphDetailEl = this.el("div", "chatpapers-lecture-para-detail");
this.selectionExplainEl = this.el("div", "chatpapers-lecture-selection-explain");
this.selectionExplainEl.hidden = true;
const playerControls = this.el("div", "chatpapers-lecture-player-controls"); const playerControls = this.el("div", "chatpapers-lecture-player-controls");
@@ -203,6 +222,7 @@ export class LecturePaneView {
playerTitle, playerTitle,
this.beatListEl, this.beatListEl,
this.paragraphDetailEl, this.paragraphDetailEl,
this.selectionExplainEl,
playerControls, playerControls,
); );
@@ -215,7 +235,12 @@ export class LecturePaneView {
this.updatePlatformHint(); this.updatePlatformHint();
const actions = this.el("div", "chatpapers-lecture-actions"); const actions = this.el("div", "chatpapers-lecture-actions");
actions.append(this.actionBtn, this.noteBtn, this.ttsTestBtn); actions.append(
this.actionBtn,
this.explainSelectionBtn,
this.noteBtn,
this.ttsTestBtn,
);
this.body.append( this.body.append(
header, header,
@@ -277,9 +302,42 @@ export class LecturePaneView {
if (!this.shouldHandleEvent(d.paperId)) return; if (!this.shouldHandleEvent(d.paperId)) return;
void this.refreshPlayer(); void this.refreshPlayer();
}), }),
lectureEvents.on("lecture:selection_explain", (d) => {
if (String(getRegularItem(this.item).id) !== d.itemId) return;
void this.applySelectionExplainResult(d.result);
}),
); );
} }
private makeActionBtn(
icon: typeof Play,
label: string,
onClick: () => void,
options?: { preserveSelection?: boolean },
): HTMLButtonElement {
const btn = this.doc.createElement("button");
btn.type = "button";
btn.className = "chatpapers-lecture-secondary";
btn.title = label;
btn.append(
createLucideIcon(this.doc, icon, {
size: 14,
className: "chatpapers-lecture-btn-icon",
}),
this.doc.createTextNode(label),
);
if (options?.preserveSelection) {
btn.addEventListener("mousedown", (ev) => {
if ((ev as MouseEvent).button !== 0) return;
ev.preventDefault();
onClick();
});
} else {
btn.addEventListener("click", onClick);
}
return btn;
}
private makeModeTab( private makeModeTab(
mode: "overview" | "paragraph", mode: "overview" | "paragraph",
label: string, label: string,
@@ -3,8 +3,11 @@ import { getLocaleID, getString } from "../../../utils/locale";
import { findPdfAttachment } from "../../pdf/extractor"; import { findPdfAttachment } from "../../pdf/extractor";
import { ensureChatPapersStyles } from "../../ui/readerPane"; import { ensureChatPapersStyles } from "../../ui/readerPane";
import { import {
applyAdaptiveHeight,
buildItemPaneSectionButtons,
onItemPaneSectionToggle, onItemPaneSectionToggle,
prepareItemPaneBody, prepareItemPaneBody,
refreshSectionOpenHeight,
} from "../../ui/itemPaneSection"; } from "../../ui/itemPaneSection";
import { LecturePaneView } from "./lecturePane"; import { LecturePaneView } from "./lecturePane";
@@ -26,10 +29,12 @@ export function registerLecturePane() {
l10nID: getLocaleID("item-section-lecture-sidenav"), l10nID: getLocaleID("item-section-lecture-sidenav"),
icon: iconURL("lecture.svg"), icon: iconURL("lecture.svg"),
}, },
sectionButtons: buildItemPaneSectionButtons("lecture"),
onInit: ({ body }) => { onInit: ({ body }) => {
const doc = body.ownerDocument; const doc = body.ownerDocument;
if (doc) ensureChatPapersStyles(doc); if (doc) ensureChatPapersStyles(doc);
prepareItemPaneBody(body); prepareItemPaneBody(body);
applyAdaptiveHeight(body, "lecture");
}, },
onDestroy: ({ body }) => { onDestroy: ({ body }) => {
views.get(body)?.destroy(); views.get(body)?.destroy();
@@ -65,8 +70,11 @@ export function registerLecturePane() {
err.textContent = getString("lecture-mount-error"); err.textContent = getString("lecture-mount-error");
body.append(err); body.append(err);
} }
applyAdaptiveHeight(body, "lecture");
const section = body.closest("collapsible-section"); const section = body.closest("collapsible-section");
if (section && !section.hasAttribute("open")) { if (section?.hasAttribute("open")) {
refreshSectionOpenHeight(body);
} else if (section) {
onItemPaneSectionToggle({ body }); onItemPaneSectionToggle({ body });
} }
}, },
@@ -0,0 +1,114 @@
import { config } from "../../../../package.json";
import { getString } from "../../../utils/locale";
import { cacheReaderSelection } 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 { lectureEvents } from "../infrastructure/events";
function tryString(value: unknown): string {
if (typeof value === "string") return value.trim();
if (value == null) return "";
return String(value).trim();
}
function itemFromReader(reader: any): Zotero.Item | undefined {
const attachmentId = reader?.itemID ?? reader?._itemID;
if (!attachmentId) return undefined;
const attachment = Zotero.Items.get(attachmentId);
if (!attachment) return undefined;
return getRegularItem(attachment);
}
function showHeadline(text: string): void {
try {
const pw = new Zotero.ProgressWindow({ closeOnClick: true });
pw.changeHeadline(text);
pw.show();
pw.startCloseTimer(4000);
} catch {
// ignore
}
}
export async function runSelectionExplainFromReader(
reader: any,
selectedText: string,
): Promise<void> {
const text = tryString(selectedText);
if (!text) return;
const item = itemFromReader(reader);
if (!item) {
showHeadline(getString("lecture-selection-no-item"));
return;
}
cacheReaderSelection(text);
showHeadline(getString("lecture-selection-explaining"));
try {
const ctx = await resolvePaperAttachment(item);
const result = await explainSelectionLecture({
item,
selectedText: text,
onProgress: (message) => showHeadline(message),
});
await highlightTextInPdf(item, {
text: result.original,
page: result.page,
});
lectureEvents.emit("lecture:selection_explain", {
paperId: paperIdFromCacheKey(ctx.cacheKey),
cacheKey: ctx.cacheKey,
itemId: String(getRegularItem(item).id),
result,
});
await playSelectionExplainStandalone(result);
showHeadline(getString("lecture-selection-done"));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
ztoolkit.log("[ChatPapers:Lecture] selection explain failed", e);
showHeadline(msg);
}
}
export function registerSelectionExplainPopup(): void {
try {
Zotero.Reader.registerEventListener(
"renderTextSelectionPopup",
(event: any) => {
try {
const { doc, params, append, reader } = event;
const text =
tryString(params?.annotation?.text) || tryString(params?.text);
if (!text) return;
cacheReaderSelection(text);
const btn = doc.createElement("button");
btn.type = "button";
btn.className =
"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) => {
ev.preventDefault();
ev.stopPropagation();
void runSelectionExplainFromReader(reader, text);
});
append(btn);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] selection popup button failed", e);
}
},
config.addonID,
);
} catch (e) {
ztoolkit.log("[ChatPapers:Lecture] registerSelectionExplainPopup failed", e);
}
}
+133 -10
View File
@@ -1,10 +1,104 @@
/** Helpers for Zotero ItemPane collapsible sections. */ /** Helpers for Zotero ItemPane collapsible sections. */
import { getLocaleID, getString } from "../../utils/locale";
import { getPref, setPref } from "../../utils/prefs";
import { openChatPaneWindow, openLecturePaneWindow } from "./itemPaneWindow";
export type ItemPaneKind = "chat" | "lecture";
const ADAPTIVE_PREF: Record<ItemPaneKind, keyof _ZoteroTypes.Prefs["PluginPrefsMap"]> =
{
chat: "chatPaneAdaptiveHeight",
lecture: "lecturePaneAdaptiveHeight",
};
const FIXED_HEIGHT: Record<ItemPaneKind, number> = {
chat: 360,
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";
export function prepareItemPaneBody(body: HTMLElement): void { export function prepareItemPaneBody(body: HTMLElement): void {
body.classList.add("chatpapers-pane-body"); body.classList.add("chatpapers-pane-body");
body.style.minHeight = "0"; body.style.minHeight = "0";
} }
export function isAdaptiveHeight(kind: ItemPaneKind): boolean {
const val = getPref(ADAPTIVE_PREF[kind]);
return val !== false;
}
export function applyAdaptiveHeight(
body: HTMLElement,
kind: ItemPaneKind,
adaptive = isAdaptiveHeight(kind),
): void {
body.classList.toggle("chatpapers-pane-adaptive", adaptive);
body.classList.toggle("chatpapers-pane-fixed", !adaptive);
body.dataset.chatpapersPaneKind = kind;
const section = resolveCollapsibleSection(body);
section?.classList.toggle("chatpapers-adaptive-height", adaptive);
section?.classList.toggle("chatpapers-fixed-height", !adaptive);
updateAdaptiveButtonState(body, adaptive);
}
export function toggleAdaptiveHeight(
body: HTMLElement,
kind: ItemPaneKind,
): boolean {
const next = !isAdaptiveHeight(kind);
setPref(ADAPTIVE_PREF[kind], next);
applyAdaptiveHeight(body, kind, next);
refreshSectionOpenHeight(body);
return next;
}
export function refreshSectionOpenHeight(body: HTMLElement): void {
const section = resolveCollapsibleSection(body);
if (!section?.hasAttribute("open")) return;
if (section.classList.contains("chatpapers-adaptive-height")) {
section.style.setProperty("--open-height", "auto");
body.style.removeProperty("max-height");
body.style.removeProperty("overflow");
return;
}
const kind = (body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind;
const maxH = FIXED_HEIGHT[kind] ?? 360;
body.style.maxHeight = `${maxH}px`;
body.style.overflowY = "auto";
section.style.setProperty("--open-height", `${maxH}px`);
}
export function updateAdaptiveButtonState(
body: HTMLElement,
adaptive: boolean,
): void {
const section = resolveCollapsibleSection(body);
const btn = section?.querySelector(
".adaptive-height.section-custom-button",
) as HTMLElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", adaptive ? "true" : "false");
btn.classList.toggle("chatpapers-adaptive-active", adaptive);
btn.style.setProperty(
"--custom-button-icon-light",
`url('${adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF}')`,
);
btn.style.setProperty(
"--custom-button-icon-dark",
`url('${adaptive ? ADAPTIVE_ICON : ADAPTIVE_ICON_OFF}')`,
);
btn.title = getString(
adaptive ? "item-pane-adaptive-height-on" : "item-pane-adaptive-height-off",
);
}
export function onItemPaneSectionToggle(options: { export function onItemPaneSectionToggle(options: {
body: HTMLElement; body: HTMLElement;
event?: Event; event?: Event;
@@ -13,28 +107,57 @@ export function onItemPaneSectionToggle(options: {
if (!section) return; if (!section) return;
const open = section.hasAttribute("open"); const open = section.hasAttribute("open");
const adaptive = section.classList.contains("chatpapers-adaptive-height");
if (open) { if (open) {
options.body.style.removeProperty("height"); options.body.style.removeProperty("height");
options.body.style.removeProperty("overflow");
options.body.style.minHeight = "0"; options.body.style.minHeight = "0";
const raf = options.body.ownerDocument?.defaultView?.requestAnimationFrame;
const updateHeight = () => { if (adaptive) {
const h = options.body.scrollHeight; options.body.style.removeProperty("overflow");
if (h > 0) { options.body.style.removeProperty("max-height");
section.style.setProperty("--open-height", `${h}px`); section.style.setProperty("--open-height", "auto");
} return;
}; }
if (raf) raf(updateHeight);
else updateHeight(); options.body.style.overflowY = "auto";
const kind = (options.body.dataset.chatpapersPaneKind || "chat") as ItemPaneKind;
const maxH = FIXED_HEIGHT[kind] ?? 360;
options.body.style.maxHeight = `${maxH}px`;
section.style.setProperty("--open-height", `${maxH}px`);
return; return;
} }
options.body.style.minHeight = "0"; options.body.style.minHeight = "0";
options.body.style.height = "0"; options.body.style.height = "0";
options.body.style.overflow = "hidden"; options.body.style.overflow = "hidden";
options.body.style.removeProperty("max-height");
section.style.setProperty("--open-height", "0px"); section.style.setProperty("--open-height", "0px");
} }
export function buildItemPaneSectionButtons(kind: ItemPaneKind) {
return [
{
type: "open-in-window",
icon: OPEN_WINDOW_ICON,
l10nID: getLocaleID("item-pane-open-window"),
onClick: ({ item }: { item?: Zotero.Item }) => {
if (!item) return;
if (kind === "chat") openChatPaneWindow(item);
else openLecturePaneWindow(item);
},
},
{
type: "adaptive-height",
icon: ADAPTIVE_ICON,
l10nID: getLocaleID("item-pane-adaptive-height"),
onClick: ({ body }: { body: HTMLElement }) => {
toggleAdaptiveHeight(body, kind);
},
},
];
}
function resolveCollapsibleSection( function resolveCollapsibleSection(
body: HTMLElement, body: HTMLElement,
event?: Event, event?: Event,
+227
View File
@@ -0,0 +1,227 @@
import { getString } from "../../utils/locale";
import { ChatView } from "./chatView";
import { LecturePaneView } from "../lecture/ui/lecturePane";
import { ensureChatPapersStyles } from "./readerPane";
type PaneKind = "chat" | "lecture";
interface OpenPaneWindow {
dialog: any;
view: ChatView | LecturePaneView;
onResize?: () => void;
}
const openWindows = new Map<string, OpenPaneWindow>();
function windowKey(kind: PaneKind, itemId: number): string {
return `${kind}:${itemId}`;
}
function ensureDialogStyles(doc: Document): void {
ensureChatPapersStyles(doc);
}
function getPreferredDialogSize(): { width: number; height: number } {
try {
const win = Zotero.getMainWindow?.() as Window | undefined;
const screen = win?.screen;
const aw = screen?.availWidth || win?.outerWidth || 1280;
const ah = screen?.availHeight || win?.outerHeight || 800;
return {
width: Math.max(720, Math.min(Math.round(aw * 0.82), aw - 48)),
height: Math.max(560, Math.min(Math.round(ah * 0.82), ah - 48)),
};
} catch {
return { width: 920, height: 680 };
}
}
function hideDialogButtonBox(doc: Document): void {
for (const sel of [
"#dialog-button-box",
".dialog-button-box",
"dialog > vbox.dialog-button-box",
"[anonid='buttons']",
]) {
for (const node of Array.from(doc.querySelectorAll(sel))) {
(node as HTMLElement).style.display = "none";
}
}
}
function fitPaneRoot(win: Window, rootId: string): void {
const doc = win.document;
hideDialogButtonBox(doc);
const root = doc.getElementById(rootId) as HTMLElement | null;
if (!root) return;
for (const el of [doc.documentElement, doc.body]) {
if (!el) continue;
(el as HTMLElement).style.height = "100%";
(el as HTMLElement).style.margin = "0";
(el as HTMLElement).style.padding = "0";
(el as HTMLElement).style.overflow = "hidden";
(el as HTMLElement).style.display = "flex";
(el as HTMLElement).style.flexDirection = "column";
}
for (const node of Array.from(doc.querySelectorAll("dialog"))) {
(node as HTMLElement).style.padding = "0";
(node as HTMLElement).style.margin = "0";
(node as HTMLElement).style.height = "100%";
}
const h = Math.max(400, (win.innerHeight || 600) - 8);
root.style.width = "100%";
root.style.height = `${h}px`;
root.style.maxHeight = `${h}px`;
root.style.minHeight = "0";
root.style.flex = "1 1 auto";
root.style.overflow = "hidden";
}
function focusExisting(key: string): boolean {
const existing = openWindows.get(key);
const win = existing?.dialog?.window as Window | undefined;
if (!win) return false;
try {
win.focus();
return true;
} catch {
openWindows.delete(key);
return false;
}
}
function closeWindow(key: string): void {
const existing = openWindows.get(key);
if (!existing) return;
try {
if (existing.dialog?.window && existing.onResize) {
existing.dialog.window.removeEventListener("resize", existing.onResize);
}
existing.view.destroy();
existing.dialog?.window?.close?.();
} catch {
// ignore
}
openWindows.delete(key);
}
function openPaneWindow(options: {
kind: PaneKind;
item: Zotero.Item;
rootId: string;
title: string;
mount: (doc: Document, root: HTMLElement) => Promise<ChatView | LecturePaneView>;
}): void {
const key = windowKey(options.kind, options.item.id);
if (focusExisting(key)) return;
closeWindow(key);
const size = getPreferredDialogSize();
let activeView: ChatView | LecturePaneView | null = null;
let onResize: (() => void) | null = null;
const dialogHelper = new ztoolkit.Dialog(1, 1)
.addCell(0, 0, {
tag: "div",
namespace: "html",
id: options.rootId,
styles: {
width: "100%",
height: "100%",
minWidth: "560px",
minHeight: "0",
overflow: "hidden",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
},
})
.setDialogData({
loadCallback: () => {
try {
const win = dialogHelper.window as Window;
const doc = win.document;
ensureDialogStyles(doc);
fitPaneRoot(win, options.rootId);
onResize = () => fitPaneRoot(win, options.rootId);
win.addEventListener("resize", onResize);
const root = doc.getElementById(options.rootId) as HTMLElement | null;
if (!root) return;
void options.mount(doc, root).then((view) => {
activeView = view;
openWindows.set(key, {
dialog: dialogHelper,
view,
onResize: onResize ?? undefined,
});
fitPaneRoot(win, options.rootId);
});
} catch (e) {
ztoolkit.log("[ChatPapers] openPaneWindow load failed", e);
}
},
unloadCallback: () => {
try {
if (dialogHelper.window && onResize) {
dialogHelper.window.removeEventListener("resize", onResize);
}
} catch {
// ignore
}
activeView?.destroy();
activeView = null;
onResize = null;
openWindows.delete(key);
},
});
openWindows.set(key, {
dialog: dialogHelper,
view: null as unknown as ChatView,
onResize: onResize ?? undefined,
});
dialogHelper.open(options.title, {
centerscreen: true,
resizable: true,
noDialogMode: true,
fitContent: false,
width: size.width,
height: size.height,
});
}
export function openChatPaneWindow(item: Zotero.Item): void {
const title = String(item.getField("title") || "ChatPapers");
openPaneWindow({
kind: "chat",
item,
rootId: "chatpapers-window-chat-root",
title: `ChatPapers — ${title}`,
mount: async (doc, root) => {
const view = new ChatView(doc, root, item);
await view.mount();
return view;
},
});
}
export function openLecturePaneWindow(item: Zotero.Item): void {
const title = String(item.getField("title") || getString("item-section-lecture-head"));
openPaneWindow({
kind: "lecture",
item,
rootId: "chatpapers-window-lecture-root",
title: `${getString("item-section-lecture-head")}${title}`,
mount: async (doc, root) => {
const view = new LecturePaneView(doc, root, item);
await view.mount();
return view;
},
});
}
+13 -2
View File
@@ -1,7 +1,13 @@
import { config } from "../../../package.json"; import { config } from "../../../package.json";
import { getLocaleID, getString } from "../../utils/locale"; import { getLocaleID, getString } from "../../utils/locale";
import { ChatView } from "./chatView"; import { ChatView } from "./chatView";
import { onItemPaneSectionToggle, prepareItemPaneBody } from "./itemPaneSection"; import {
applyAdaptiveHeight,
buildItemPaneSectionButtons,
onItemPaneSectionToggle,
prepareItemPaneBody,
refreshSectionOpenHeight,
} from "./itemPaneSection";
const views = new WeakMap<HTMLElement, ChatView>(); const views = new WeakMap<HTMLElement, ChatView>();
@@ -21,10 +27,12 @@ export function registerChatPane() {
l10nID: getLocaleID("item-section-chat-sidenav"), l10nID: getLocaleID("item-section-chat-sidenav"),
icon: iconURL("chat.svg"), icon: iconURL("chat.svg"),
}, },
sectionButtons: buildItemPaneSectionButtons("chat"),
onInit: ({ body }) => { onInit: ({ body }) => {
const doc = body.ownerDocument; const doc = body.ownerDocument;
if (doc) ensureStyles(doc); if (doc) ensureStyles(doc);
prepareItemPaneBody(body); prepareItemPaneBody(body);
applyAdaptiveHeight(body, "chat");
}, },
onDestroy: ({ body }) => { onDestroy: ({ body }) => {
views.get(body)?.destroy(); views.get(body)?.destroy();
@@ -53,8 +61,11 @@ export function registerChatPane() {
const view = new ChatView(doc, body, item); const view = new ChatView(doc, body, item);
views.set(body, view); views.set(body, view);
await view.mount(); await view.mount();
applyAdaptiveHeight(body, "chat");
const section = body.closest("collapsible-section"); const section = body.closest("collapsible-section");
if (section && !section.hasAttribute("open")) { if (section?.hasAttribute("open")) {
refreshSectionOpenHeight(body);
} else if (section) {
onItemPaneSectionToggle({ body }); onItemPaneSectionToggle({ body });
} }
}, },
+4
View File
@@ -32,6 +32,10 @@ export type FluentMessageId =
| 'cite-exported' | 'cite-exported'
| 'cite-loading' | 'cite-loading'
| 'cite-style' | 'cite-style'
| 'item-pane-adaptive-height'
| 'item-pane-adaptive-height-off'
| 'item-pane-adaptive-height-on'
| 'item-pane-open-window'
| 'item-section-chat-head' | 'item-section-chat-head'
| 'item-section-chat-sidenav' | 'item-section-chat-sidenav'
| 'item-section-lecture-head' | 'item-section-lecture-head'
+2
View File
@@ -29,6 +29,8 @@ declare namespace _ZoteroTypes {
"mineruPath": string; "mineruPath": string;
"piperPath": string; "piperPath": string;
"piperModelPath": string; "piperModelPath": string;
"chatPaneAdaptiveHeight": boolean;
"lecturePaneAdaptiveHeight": boolean;
}; };
} }
} }