diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx
index a6095e7ea5..56aaf429f4 100644
--- a/chrome/content/zotero/collectionViewItemTree.jsx
+++ b/chrome/content/zotero/collectionViewItemTree.jsx
@@ -387,6 +387,16 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
return kept;
}
+ /**
+ * The session holding the passages of the active best-match search, or
+ * null when no such search is running
+ *
+ * @return {Zotero.BestMatch.Session|null}
+ */
+ get bestMatchSession() {
+ return this._bestMatchSession ?? null;
+ }
+
/**
* Called for every pending search-match row the tree draws: rendering
* is the demand signal for deriving previews. Reports are collected
diff --git a/chrome/content/zotero/customElements.js b/chrome/content/zotero/customElements.js
index 59b5b319bc..04cb9b3ef4 100644
--- a/chrome/content/zotero/customElements.js
+++ b/chrome/content/zotero/customElements.js
@@ -74,6 +74,9 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['attachment-row', 'chrome://zotero/content/elements/attachmentRow.js'],
['attachment-annotations-box', 'chrome://zotero/content/elements/attachmentAnnotationsBox.js'],
['annotation-row', 'chrome://zotero/content/elements/annotationRow.js'],
+ ['search-results-box', 'chrome://zotero/content/elements/searchResultsBox.js'],
+ ['search-result-row', 'chrome://zotero/content/elements/searchResultRow.js'],
+ ['search-results-pane', 'chrome://zotero/content/elements/searchResultsPane.js'],
['annotation-items-pane', 'chrome://zotero/content/elements/annotationItemsPane.js'],
['context-notes-list', 'chrome://zotero/content/elements/contextNotesList.js'],
['note-row', 'chrome://zotero/content/elements/noteRow.js'],
diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js
index 9a1c49d455..67f9a8784d 100644
--- a/chrome/content/zotero/elements/itemDetails.js
+++ b/chrome/content/zotero/elements/itemDetails.js
@@ -56,6 +56,8 @@
+
+
diff --git a/chrome/content/zotero/elements/itemPane.js b/chrome/content/zotero/elements/itemPane.js
index 8dac4ac63a..201d426db4 100644
--- a/chrome/content/zotero/elements/itemPane.js
+++ b/chrome/content/zotero/elements/itemPane.js
@@ -45,6 +45,7 @@
+
`);
@@ -55,6 +56,7 @@
this._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
this._messagePane = this.querySelector("#zotero-item-message");
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
+ this._searchResultsPane = this.querySelector("#zotero-search-results-pane");
this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
@@ -113,12 +115,12 @@
}
get mode() {
- return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt"][this._deck.selectedIndex];
+ return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt", "search-results"][this._deck.selectedIndex];
}
/**
* Set mode of item pane
- * @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt"} type view type
+ * @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt" | "search-results"} type view type
*/
set mode(type) {
this.setAttribute("view-type", type);
@@ -133,6 +135,11 @@
}
render() {
+ // Passages of a search match, rather than items: nothing an item
+ // pane shows describes one, so the passages are all there is
+ if (this.searchMatches?.length) {
+ return this.renderSearchResults(this.searchMatches);
+ }
if (!this.data) return false;
let renderStatus = false;
// Only annotations selected
@@ -193,6 +200,13 @@
return true;
}
+ renderSearchResults(matches) {
+ this.mode = "search-results";
+ this._searchResultsPane.matches = matches;
+ this._searchResultsPane.render();
+ return true;
+ }
+
renderNoteEditor(item) {
this.mode = "note";
@@ -627,8 +641,12 @@
getCurrentPane(mode = undefined) {
if (!mode) {
// Guess a mode from the current data
+ // Passages of a search match, which aren't items at all
+ if (this.searchMatches?.length) {
+ mode = "search-results";
+ }
// Only annotation items selected
- if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
+ else if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
mode = "annotations";
}
// No/multiple objects are selected OR selected object is a trashed collection/search
@@ -648,7 +666,8 @@
item: "_itemDetails",
note: "_noteEditor",
duplicates: "_duplicatesPane",
- annotations: "_annotationsPane"
+ annotations: "_annotationsPane",
+ "search-results": "_searchResultsPane"
};
return this[map[mode]];
}
@@ -736,6 +755,10 @@
this._deck.selectedIndex = 5;
break;
}
+ case "search-results": {
+ this._deck.selectedIndex = 6;
+ break;
+ }
}
let isViewingItem = type == "item";
let isViewingDuplicates = type == "duplicates";
diff --git a/chrome/content/zotero/elements/itemPaneSidenav.js b/chrome/content/zotero/elements/itemPaneSidenav.js
index 8da724e7da..da0b3539e3 100644
--- a/chrome/content/zotero/elements/itemPaneSidenav.js
+++ b/chrome/content/zotero/elements/itemPaneSidenav.js
@@ -102,7 +102,7 @@
}
get _builtInPanes() {
- return ["info", "abstract", "attachments", "notes", "note-info", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related"];
+ return ["info", "abstract", "attachments", "notes", "note-info", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related", "search-results"];
}
get container() {
diff --git a/chrome/content/zotero/elements/searchResultRow.js b/chrome/content/zotero/elements/searchResultRow.js
new file mode 100644
index 0000000000..a59770adcb
--- /dev/null
+++ b/chrome/content/zotero/elements/searchResultRow.js
@@ -0,0 +1,161 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2026 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ https://www.zotero.org
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+"use strict";
+
+{
+ // A best-match search result card: one passage of an item's text that the
+ // query matched (see Zotero.BestMatch.Session#getPreviews()) -- where the
+ // passage sits in the document as the head, the passage itself as the
+ // quote with the query's words marked in it, presented like an
+ // annotation-row (the two share their styling, see
+ // scss/elements/_annotationRow.scss).
+ //
+ // The whole passage is quoted, not the line the tree shows: the card is
+ // where a match is read rather than scanned. A quote too tall for the
+ // card is clamped, with a toggle to see the rest.
+ class SearchResultRow extends XULElementBase {
+ content = MozXULElement.parseXULToFragment(`
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ _result = null;
+
+ get result() {
+ return this._result;
+ }
+
+ set result(result) {
+ this._result = result;
+ this.render();
+ }
+
+ init() {
+ this._path = this.querySelector('.path');
+ this._part = this.querySelector('.part');
+ this._location = this.querySelector('.location');
+ this._quote = this.querySelector('.quote');
+ this._showMore = this.querySelector('.show-more');
+ this._showMore.addEventListener('click', (event) => {
+ // The card's activation (open the attachment) shouldn't fire
+ // for the toggle
+ event.stopPropagation();
+ this._toggleExpanded();
+ });
+ this.render();
+ }
+
+ render() {
+ if (!this.initialized || !this._result) return;
+
+ // Where the passage sits: the headings it falls under, or the
+ // generic fulltext label for a passage from a document with no
+ // outline to read
+ if (this._result.outlinePath) {
+ this._path.removeAttribute('data-l10n-id');
+ this._path.textContent = this._result.outlinePath;
+ }
+ else {
+ document.l10n.setAttributes(this._path, 'search-result-row-fulltext');
+ }
+ // Which piece of a split section this is, so a match reads as
+ // coming from the middle or the end of its section
+ let parts = this._result.sectionParts;
+ this._part.hidden = !(parts > 1);
+ if (parts > 1) {
+ this._part.textContent = `${this._result.sectionPart}/${parts}`;
+ }
+ // The page the chunk's section starts on, labeled the way
+ // annotation rows label theirs
+ this._location.hidden = !this._result.pageLabel;
+ if (this._result.pageLabel) {
+ this._location.textContent
+ = Zotero.getString('pdfReader.page') + ' ' + this._result.pageLabel;
+ }
+
+ this._renderQuote();
+
+ // Offer "Show More" only when the quote is actually clamped,
+ // which is only measurable once the card has a layout
+ this.classList.remove('expanded');
+ this._showMore.hidden = true;
+ requestAnimationFrame(() => {
+ this._showMore.hidden
+ = this._quote.scrollHeight <= this._quote.clientHeight;
+ });
+
+ // A11y - make focusable and describe the card
+ this.setAttribute('tabindex', 0);
+ this.setAttribute('aria-label', [
+ this._result.outlinePath,
+ this._location.hidden ? '' : this._location.textContent,
+ this._result.text
+ ].filter(Boolean).join('. '));
+ }
+
+ // The passage's text, with any matched ranges wrapped for highlighting
+ _renderQuote() {
+ let text = this._result.text || '';
+ let ranges = this._result.ranges || [];
+ if (!ranges.length) {
+ this._quote.textContent = text;
+ return;
+ }
+ this._quote.replaceChildren();
+ let position = 0;
+ for (let [start, end] of ranges) {
+ if (start > position) {
+ this._quote.append(text.slice(position, start));
+ }
+ let match = document.createElement('span');
+ match.className = 'match';
+ match.textContent = text.slice(start, end);
+ this._quote.append(match);
+ position = end;
+ }
+ if (position < text.length) {
+ this._quote.append(text.slice(position));
+ }
+ }
+
+ _toggleExpanded() {
+ let expanded = this.classList.toggle('expanded');
+ document.l10n.setAttributes(this._showMore,
+ expanded ? 'search-result-row-show-less' : 'search-result-row-show-more');
+ }
+ }
+
+ customElements.define('search-result-row', SearchResultRow);
+}
diff --git a/chrome/content/zotero/elements/searchResultsBox.js b/chrome/content/zotero/elements/searchResultsBox.js
new file mode 100644
index 0000000000..9970955a71
--- /dev/null
+++ b/chrome/content/zotero/elements/searchResultsBox.js
@@ -0,0 +1,176 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2026 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ https://www.zotero.org
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+{
+ const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
+ "chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
+ { global: "current" }
+ );
+
+ // Why the selected item matched the active best-match search: a card per
+ // passage of the item's own text that the search matched (see
+ // Zotero.BestMatch.Session#getPreviews()), each carrying the whole
+ // passage rather than the line the tree quotes, so a match can be read
+ // without opening anything.
+ //
+ // Shows every passage the selected item matched in. A single passage
+ // selected on its own is a search-results-pane, not an item with a
+ // section.
+ class SearchResultsBox extends ItemPaneSectionElementBase {
+ content = MozXULElement.parseXULToFragment(`
+
+
+
+
+ `);
+
+ get item() {
+ return this._item;
+ }
+
+ set item(item) {
+ super.item = item instanceof Zotero.Item ? item : null;
+ // A new item's emptiness isn't known until asyncRender scores it
+ this._count = undefined;
+ }
+
+ get collectionTreeRows() {
+ return super.collectionTreeRows;
+ }
+
+ // The item pane sets collectionTreeRows after item, so this is where
+ // everything visibility depends on is finally known
+ set collectionTreeRows(collectionTreeRows) {
+ super.collectionTreeRows = collectionTreeRows;
+ this._updateHidden();
+ }
+
+ init() {
+ this.initCollapsibleSection();
+ this._body = this.querySelector('.body');
+ // The header's count placeholder needs a value before the first
+ // async render fills in the real one
+ this._section.setCount(0);
+ // Double-click (or Enter on a focused card) opens the attachment
+ // at the chunk
+ this._body.addEventListener('dblclick', this._handleActivate);
+ this._body.addEventListener('keydown', (event) => {
+ if (event.key == 'Enter') {
+ this._handleActivate(event);
+ }
+ });
+ }
+
+ get _itemsView() {
+ return this.closest('item-pane')?.itemsView ?? null;
+ }
+
+ // The session holding the passages of the active best-match search,
+ // or null when no such search is running. It derives a preview once
+ // per item and keeps it, so reading one costs nothing after the first
+ // time.
+ get _session() {
+ return this._itemsView?.bestMatchSession ?? null;
+ }
+
+ // A new search re-renders even when the item didn't change
+ get _renderDependencies() {
+ return [...super._renderDependencies, this._session];
+ }
+
+ render() {}
+
+ async asyncRender() {
+ if (!this.initialized) return;
+ if (this._isAlreadyRendered("async")) return;
+
+ let item = this.item;
+ let session = this._session;
+ this._body.replaceChildren();
+ if (!item || !session) {
+ this._count = 0;
+ this._updateHidden();
+ return;
+ }
+
+ let preview = session.getPreviews(item.id);
+ if (preview?.state == 'pending') {
+ // Selecting an item asks for its passages outright, rather
+ // than waiting for its rows to be scrolled to
+ await session.preload([item.id]);
+ // The selection, or the search, may have moved on while
+ // deriving
+ if (this.item !== item || this._session !== session) {
+ return;
+ }
+ preview = session.getPreviews(item.id);
+ }
+ let entries = preview?.state == 'filled' ? preview.entries : [];
+ this._count = entries.length;
+ this._section.setCount(entries.length);
+ this._updateHidden();
+ // Left in the order the preview holds them, strongest match
+ // first: with only a handful of cards shown, the best one earning
+ // the top slot matters more than reading them in document order
+ for (let entry of entries) {
+ let row = document.createXULElement('search-result-row');
+ row.result = entry;
+ this._body.append(row);
+ }
+ }
+
+ // For a file attachment, open it where the activated card's passage
+ // is: for a PDF with a stored chunk position, scrolled to and
+ // highlighting the section; without one (EPUB, snapshot, a passage
+ // cut from flat text), just open it.
+ _handleActivate = (event) => {
+ let row = event.target.closest('search-result-row');
+ // The Show More toggle isn't an activation
+ if (!row || !this.item || !this.item.isFileAttachment()
+ || event.target.closest('.show-more')) {
+ return;
+ }
+ if (typeof ZoteroPane == 'undefined') {
+ return;
+ }
+ let position = row.result?.position;
+ ZoteroPane.viewAttachment(this.item.id, null, false,
+ position ? { location: { position } } : undefined)
+ .catch(e => Zotero.logError(e));
+ };
+
+ _updateHidden() {
+ // Visible only during a best-match search; asyncRender hides it
+ // again when nothing matched. Deciding emptiness needs the async
+ // derivation, so unlike the annotations section this one can't
+ // know its final state synchronously -- it appears, then empties
+ // out, rather than flickering in late.
+ this.hidden = !this.item || !this._session || this.tabType == 'reader'
+ || this._count === 0;
+ }
+ }
+
+ customElements.define("search-results-box", SearchResultsBox);
+}
diff --git a/chrome/content/zotero/elements/searchResultsPane.js b/chrome/content/zotero/elements/searchResultsPane.js
new file mode 100644
index 0000000000..493ebd26d6
--- /dev/null
+++ b/chrome/content/zotero/elements/searchResultsPane.js
@@ -0,0 +1,140 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2026 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ https://www.zotero.org
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+"use strict";
+
+{
+ // The pane shown when what's selected is search matches rather than
+ // items: a card per selected passage (see
+ // Zotero.BestMatch.Session#getPreviews()), grouped under the attachment
+ // each came from.
+ //
+ // A passage isn't an item, so nothing an item pane says about one -- its
+ // fields, its attachments, its tags -- has anything to describe. What
+ // there is to show is the passage itself.
+ class SearchResultsPane extends XULElementBase {
+ content = MozXULElement.parseXULToFragment(`
+
+
+ `);
+
+ _matches = [];
+
+ // @param {Object[]} matches - { itemID, entry }, in the order they're shown
+ set matches(matches) {
+ this._matches = matches || [];
+ }
+
+ get matches() {
+ return this._matches;
+ }
+
+ init() {
+ this._body = this.querySelector('.body');
+ // Double-click, or Enter on a focused card, opens the attachment
+ // at the passage
+ this._body.addEventListener('dblclick', this._handleActivate);
+ this._body.addEventListener('keydown', (event) => {
+ if (event.key == 'Enter') {
+ this._handleActivate(event);
+ }
+ });
+ }
+
+ render() {
+ if (!this.initialized) return;
+ this._body.replaceChildren();
+
+ // Grouped by attachment, in the order the matches arrive, so the
+ // pane reads in the order the rows do
+ let byItem = new Map();
+ for (let match of this._matches) {
+ if (!byItem.has(match.itemID)) {
+ byItem.set(match.itemID, []);
+ }
+ byItem.get(match.itemID).push(match.entry);
+ }
+
+ for (let [itemID, entries] of byItem) {
+ let item = Zotero.Items.get(itemID);
+ let section = document.createXULElement('collapsible-section');
+ section.dataset.l10nId = 'section-search-results';
+ section.dataset.pane = `search-results-${itemID}`;
+ section.summary = item ? item.getDisplayTitle() : '';
+ document.l10n.setArgs(section, { count: entries.length });
+
+ let body = document.createElement('div');
+ body.className = 'body';
+ section.append(body);
+ this._body.append(section);
+
+ for (let entry of entries) {
+ let row = document.createXULElement('search-result-row');
+ row.result = entry;
+ row.dataset.itemId = itemID;
+ body.append(row);
+ }
+ }
+ }
+
+ // The buttons the pane's host puts above the cards, if any
+ renderCustomHead(callback) {
+ let customHead = this.querySelector(".custom-head");
+ customHead.replaceChildren();
+ if (callback) {
+ callback({
+ doc: document,
+ append: (...args) => customHead.append(...args),
+ });
+ }
+ }
+
+ // Open the activated card's attachment where its passage is: for a
+ // PDF with a stored chunk position, scrolled to and highlighting the
+ // section; without one (EPUB, snapshot, a passage cut from flat
+ // text), just open it.
+ _handleActivate = (event) => {
+ let row = event.target.closest('search-result-row');
+ // The Show More toggle isn't an activation
+ if (!row || event.target.closest('.show-more')) {
+ return;
+ }
+ if (typeof ZoteroPane == 'undefined') {
+ return;
+ }
+ let itemID = parseInt(row.dataset.itemId);
+ let item = Zotero.Items.get(itemID);
+ if (!item || !item.isFileAttachment()) {
+ return;
+ }
+ let position = row.result?.position;
+ ZoteroPane.viewAttachment(itemID, null, false,
+ position ? { location: { position } } : undefined)
+ .catch(e => Zotero.logError(e));
+ };
+ }
+
+ customElements.define("search-results-pane", SearchResultsPane);
+}
diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx
index a6057d04f9..4766055c80 100644
--- a/chrome/content/zotero/itemTree.jsx
+++ b/chrome/content/zotero/itemTree.jsx
@@ -31,7 +31,7 @@ const LibraryTree = require('./libraryTree');
const VirtualizedTable = require('components/virtualized-table');
const { VirtualizedTree, formatColumnName } = VirtualizedTable;
const { COLUMNS } = require("zotero/itemTreeColumns");
-const { ItemTreeRow } = require('zotero/itemTreeRow');
+const { ItemTreeRow, SearchMatch } = require('zotero/itemTreeRow');
const { OS } = ChromeUtils.importESModule("chrome://zotero/content/osfile.mjs");
const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.mjs');
@@ -1975,6 +1975,36 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
+ /**
+ * The session holding the passages of the active best-match search, when
+ * the view's rows come from one
+ *
+ * @return {Zotero.BestMatch.Session|null}
+ */
+ get bestMatchSession() {
+ return this.rowProvider?.bestMatchSession ?? null;
+ }
+
+ /**
+ * The passages the selection names, when search-match rows are all it
+ * holds. Empty for any selection with something else in it, so a caller
+ * can tell "these are passages" from "these are items".
+ *
+ * A pending row stands in for passages that don't exist yet and names
+ * none.
+ *
+ * @return {Object[]} - { itemID, entry } per selected passage
+ */
+ getSelectedSearchMatches() {
+ let selected = this.getSelectedObjects();
+ if (!selected.length || !selected.every(ref => ref instanceof SearchMatch)) {
+ return [];
+ }
+ return selected
+ .filter(ref => ref.entry)
+ .map(ref => ({ itemID: ref.itemID, entry: ref.entry }));
+ }
+
/**
* Get selected items, omitting collections and searches in the trash
*/
diff --git a/chrome/content/zotero/itemTreeRow.js b/chrome/content/zotero/itemTreeRow.js
index 7f05756c5c..3ff74c8b78 100644
--- a/chrome/content/zotero/itemTreeRow.js
+++ b/chrome/content/zotero/itemTreeRow.js
@@ -620,9 +620,13 @@ class SearchMatch {
/**
* The search-match refs to materialize under an item, from its
* best-match preview: one pending ref while the preview is being
- * derived, one ref per derived entry once it's filled, and nothing when
+ * derived, one ref per quoted entry once it's filled, and nothing when
* the item has no preview or its preview derived nothing.
*
+ * A preview holds every passage the item matched in; the tree shows the
+ * strongest few, which are the ones with a line quoted. The rest are
+ * read whole in the item pane.
+ *
* @param {Zotero.Item} item
* @param {Function} [getMatchPreviews] - itemID -> preview accessor (see
* Zotero.BestMatch.Session#getPreviews()), passed by the row
@@ -637,7 +641,9 @@ class SearchMatch {
if (preview.state == 'pending') {
return [new SearchMatch(item.id)];
}
- return preview.entries.map(entry => new SearchMatch(item.id, entry));
+ return preview.entries
+ .slice(0, Zotero.BestMatch.MAX_QUOTED_PASSAGES)
+ .map(entry => new SearchMatch(item.id, entry));
}
}
diff --git a/chrome/content/zotero/xpcom/bestMatch.js b/chrome/content/zotero/xpcom/bestMatch.js
index bb33d32143..bf0c64a02f 100644
--- a/chrome/content/zotero/xpcom/bestMatch.js
+++ b/chrome/content/zotero/xpcom/bestMatch.js
@@ -46,10 +46,13 @@ Zotero.BestMatch = new function () {
const LEXICAL_WEIGHT = 0.3;
// About a line: what a passage is quoted down to for a one-line preview
const SNIPPET_CHARS = 150;
- // Most passages shown for one item. The strongest few say what the item
- // has to offer, and quoting a passage costs work -- sometimes the model's
- // -- so passages past this are not worth deriving.
- const MAX_PASSAGES = 3;
+ // Most passages quoted for one item. Quoting one costs work -- sometimes
+ // the model's -- and the strongest few already say what the item has to
+ // offer at a glance. The rest are still derived: they're read whole
+ // rather than quoted, which needs no line chosen.
+ const MAX_QUOTED_PASSAGES = 3;
+
+ this.MAX_QUOTED_PASSAGES = MAX_QUOTED_PASSAGES;
//
// Errors
@@ -415,14 +418,13 @@ Zotero.BestMatch = new function () {
* piece of the document that knows where it sits, rather than a
* window cut around a word.
*
- * At most MAX_PASSAGES come back: the strongest few say what the item
- * has to offer, and quoting the rest costs more than it shows.
+ * Every passage that clears its engine's threshold comes back, so a
+ * consumer showing passages whole can show all of them.
*
- * Each passage carries the whole chunk's `text` and a `snippet`
- * extent within it -- the one line that best shows the query (see
- * _pickSnippets()) -- so a consumer can quote the line or read the
- * passage from the same entry. `ranges` locate the query's literal
- * matches in the full text.
+ * Each passage carries the whole chunk's `text`, and the strongest
+ * MAX_QUOTED_PASSAGES of them also carry a `snippet` extent within it
+ * -- the one line that best shows the query (see _pickSnippets()).
+ * `ranges` locate the query's literal matches in the full text.
*
* Only the engines scoring recorded a match in are asked (see
* score()), so an item that matched one of them never pays the
@@ -430,9 +432,10 @@ Zotero.BestMatch = new function () {
* nothing.
*
* @param {Number} itemID
- * @return {Promise