diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx
index f224e09384..8710cb475a 100644
--- a/chrome/content/zotero/collectionViewItemTree.jsx
+++ b/chrome/content/zotero/collectionViewItemTree.jsx
@@ -43,11 +43,13 @@ const React = require('react');
const ReactDOM = require('react-dom');
const ItemTree = require('zotero/itemTree');
const { ItemTreeRowProvider } = ItemTree;
-const { LibraryHeaderItemTreeRow, SpacerItemTreeRow } = require('zotero/itemTreeRow');
+const { LibraryHeaderItemTreeRow, SpacerItemTreeRow, SearchMatch } = require('zotero/itemTreeRow');
const { OS } = ChromeUtils.importESModule("chrome://zotero/content/osfile.mjs");
const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.mjs');
+const PRELOADED_MATCH_PREVIEWS = 10;
+
const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$");
// Minimal CollectionTreeRow-like object for callers that pass plain objects to
@@ -270,18 +272,36 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
let itemsByID = new Map(candidates.map(item => [item.id, item]));
let scores;
let generation = this._bestMatchGeneration;
+ // The session scores the query and owns the match previews the tree
+ // shows as child rows. A new query gets a fresh session -- the old
+ // one's fills must never touch rows again -- while a re-score of the
+ // same query (an item edit, an index update) keeps it, so
+ // already-derived previews survive; the previews of the items that
+ // actually changed are invalidated in notify().
+ let session = this._bestMatchSession;
+ let newQuery = !session || session.queryText !== query;
+ if (newQuery) {
+ session?.dispose();
+ session = Zotero.BestMatch.createSession(query);
+ session.onUpdate = itemIDs => this._onMatchPreviewsUpdate(session, itemIDs);
+ this._bestMatchSession = session;
+ }
try {
- ({ scores } = await Zotero.BestMatch.scoreItemIDs(query, [...itemsByID.keys()], {
+ scores = await session.score([...itemsByID.keys()], {
// A newer filter (e.g. more typed search text) makes this
// query obsolete -- stop scoring and let its refresh take over
shouldCancel: () => generation !== this._bestMatchGeneration
- }));
+ });
}
catch (e) {
if (e instanceof Zotero.BestMatch.ScoringCancelledError) {
throw e;
}
Zotero.logError(e);
+ session.dispose();
+ if (this._bestMatchSession == session) {
+ this._bestMatchSession = null;
+ }
this._bestMatchRanks = new Map();
this._bestMatchIndexState = await this._getBestMatchIndexState();
// A rank-only search's membership doesn't depend on scoring, so
@@ -333,6 +353,24 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
ranks.set(item.treeViewID, rankOfScore.get(score));
fractions.set(item.treeViewID, scores.get(itemID) || 0);
}
+ // A new query's results are shown from the top (see _refresh()), so
+ // the previews the reader lands on are the best-ranked ones. Deriving
+ // them before the rows appear is what keeps those rows from visibly
+ // growing into their matches a moment after they're drawn; the rest
+ // fill in on demand as they're scrolled to.
+ if (newQuery) {
+ this._scrollToTopOnUpdate = true;
+ await session.preload(
+ [...scores.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([itemID]) => itemID)
+ .filter(itemID => session.getPreviews(itemID))
+ .slice(0, PRELOADED_MATCH_PREVIEWS)
+ );
+ if (generation !== this._bestMatchGeneration) {
+ throw new Zotero.BestMatch.ScoringCancelledError();
+ }
+ }
let kept = [];
for (let item of items) {
if (!(item instanceof Zotero.Item) || !effectiveScores.has(item.id)) {
@@ -349,6 +387,89 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
return kept;
}
+ /**
+ * Called for every pending search-match row the tree draws: rendering
+ * is the demand signal for deriving previews. Reports are collected
+ * across the render pass and flushed as one request on a microtask -- a
+ * request per row would re-enter from the render that answers the first
+ * one. Each flush replaces the session's previous request, so scrolling
+ * past unfilled rows discards their work; rows still pending on screen
+ * are restated by the re-render that follows each fill.
+ *
+ * @param {Number} itemID - The item whose pending preview was drawn
+ */
+ onSearchMatchRendered(itemID) {
+ if (!this._bestMatchSession) {
+ return;
+ }
+ if (!this._renderedMatchItemIDs) {
+ this._renderedMatchItemIDs = new Set();
+ Promise.resolve().then(() => {
+ let itemIDs = [...this._renderedMatchItemIDs];
+ this._renderedMatchItemIDs = null;
+ this._bestMatchSession?.request(itemIDs);
+ });
+ }
+ this._renderedMatchItemIDs.add(itemID);
+ }
+
+ /**
+ * A session reported previews that settled: replace each affected
+ * container's placeholder row with the derived match rows -- or with
+ * nothing, when derivation found nothing to show. Runs after any
+ * in-flight refresh, and only while the session is still the view's;
+ * a superseded session's fills never touch rows. A selected placeholder
+ * hands its selection to the first derived row.
+ *
+ * @param {Zotero.BestMatch.Session} session
+ * @param {Number[]} itemIDs
+ */
+ async _onMatchPreviewsUpdate(session, itemIDs) {
+ try {
+ // A refresh in flight materializes the settled previews itself
+ await this.itemTree._refreshPromise;
+ if (session !== this._bestMatchSession) {
+ return;
+ }
+ this.itemTree._cacheState();
+ let handoffID = null;
+ let changed = false;
+ for (let itemID of itemIDs) {
+ let index = this._rowMap[itemID];
+ // A collapsed container materializes its rows on reopen
+ if (index === undefined || !this.isContainerOpen(index)) {
+ continue;
+ }
+ let placeholderIndex = this._rowMap['SM' + itemID + '-pending'];
+ if (placeholderIndex !== undefined
+ && this.itemTree.selection.isSelected(placeholderIndex)) {
+ let preview = session.getPreviews(itemID);
+ // The first derived row, or the container itself when
+ // nothing derived
+ handoffID = preview?.state == 'filled'
+ ? 'SM' + itemID + '-' + preview.entries[0].key
+ : itemID;
+ }
+ this._refreshContainer(index, true);
+ changed = true;
+ }
+ if (!changed) {
+ return;
+ }
+ this.refreshRowMap();
+ if (handoffID !== null && this._rowMap[handoffID] !== undefined) {
+ this.itemTree.selection.select(this._rowMap[handoffID]);
+ }
+ this.runListeners('update', true, {
+ restoreSelection: handoffID === null,
+ restoreScroll: true
+ });
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
+ }
+
/**
* When showing multiple libraries, group rows by library in collections-list
* order -- independent of the active sort direction
@@ -652,6 +773,11 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
});
}
// The ranking stage: one scoring pass over the merged results
+ if (!bestMatchSearch && this._bestMatchSession) {
+ // Leaving best-match search: the previews go with it
+ this._bestMatchSession.dispose();
+ this._bestMatchSession = null;
+ }
if (bestMatchSearch) {
try {
newSearchItems = await this._applyBestMatch(newSearchItems);
@@ -707,6 +833,11 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
if (!row.isObjectRow) {
continue;
}
+ // Don't copy search-match rows -- they're rebuilt from the new
+ // query's previews when their container reopens
+ if (row.ref instanceof SearchMatch) {
+ continue;
+ }
// Top-level items
if (row.level == 0) {
// A top-level attachment moved into a parent. Don't copy, it will be added
@@ -833,11 +964,18 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
if (!this.isContainer(i) || this.isContainerOpen(i)) {
continue;
}
- let item = this.getRow(i).ref;
+ let row = this.getRow(i);
+ if (!(row.ref instanceof Zotero.Item)) {
+ continue;
+ }
+ let item = row.ref;
let attachments = item.isRegularItem() ? item.getAttachments() : [];
// expand item row if it is a parent of a match
// OR if it has a child that is a parent of a match
- let shouldBeOpened = searchParentIDs.has(item.id) || attachments.some(id => searchParentIDs.has(id));
+ // OR if it has best-match preview rows to show
+ let shouldBeOpened = searchParentIDs.has(item.id)
+ || attachments.some(id => searchParentIDs.has(id))
+ || !!this._bestMatchSession?.getPreviews(item.id);
if (shouldBeOpened) {
this._toggleOpenState(i, true);
}
@@ -861,6 +999,12 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
try {
await this._refresh(options);
+ // A new best-match query shows its results from the top (see
+ // _applyBestMatch()), wherever the previous ones were scrolled to
+ if (this._scrollToTopOnUpdate) {
+ this._scrollToTopOnUpdate = false;
+ options = { ...options, scrollToTop: true };
+ }
this.runListeners('update', true, options);
await this.itemTree.waitForLoad();
this.itemTree.runListeners('refresh');
@@ -897,6 +1041,13 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
const cachedSelection = this.itemTree._cachedSelection;
const collectionTreeRows = this.collectionTreeRows;
+ // A changed item's derived match previews are stale: back to
+ // placeholders, re-derived on their next render. The re-score the
+ // change triggers below keeps every other item's derived text.
+ if (type == 'item' && ['modify', 'refresh'].includes(action) && this._bestMatchSession) {
+ this._bestMatchSession.invalidate(ids.map(id => parseInt(id)));
+ }
+
var madeChanges = false;
var refresh = false;
var reuseSearchResults = false;
diff --git a/chrome/content/zotero/customElements.js b/chrome/content/zotero/customElements.js
index 03f2108a53..59b5b319bc 100644
--- a/chrome/content/zotero/customElements.js
+++ b/chrome/content/zotero/customElements.js
@@ -74,8 +74,6 @@ 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'],
['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 4fbcd62137..9a1c49d455 100644
--- a/chrome/content/zotero/elements/itemDetails.js
+++ b/chrome/content/zotero/elements/itemDetails.js
@@ -56,7 +56,6 @@
-
diff --git a/chrome/content/zotero/elements/itemPaneSidenav.js b/chrome/content/zotero/elements/itemPaneSidenav.js
index da0b3539e3..8da724e7da 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", "search-results"];
+ return ["info", "abstract", "attachments", "notes", "note-info", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related"];
}
get container() {
diff --git a/chrome/content/zotero/elements/searchResultRow.js b/chrome/content/zotero/elements/searchResultRow.js
deleted file mode 100644
index 20f68e5f85..0000000000
--- a/chrome/content/zotero/elements/searchResultRow.js
+++ /dev/null
@@ -1,181 +0,0 @@
-/*
- ***** 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 excerpt of an item's text that
- // matches the query (see Zotero.BestMatch.getMatchingExcerpts()) --
- // where the excerpt came from as the head, its text as the quote,
- // presented like an annotation-row (the two share their styling, see
- // scss/elements/_annotationRow.scss). A semantic chunk heads with its
- // place in the document (outline path, section part, page); a lexical
- // excerpt heads with its source's name and marks its matched ranges in
- // the quote.
- 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();
- }
-
- // The head's label for where a lexical excerpt came from, localized
- // with the names the rest of the UI gives those parts of an item.
- // Attachment content ('content') isn't here: it labels with the same
- // generic fulltext string an outline-less chunk falls back to.
- _getSourceLabel(source) {
- switch (source) {
- case 'title':
- return Zotero.ItemFields.getLocalizedString('title');
- case 'abstract':
- return Zotero.ItemFields.getLocalizedString('abstractNote');
- case 'note':
- return Zotero.ItemTypes.getLocalizedString('note');
- case 'annotation':
- return Zotero.ItemTypes.getLocalizedString('annotation');
- }
- return null;
- }
-
- render() {
- if (!this.initialized || !this._result) return;
-
- // Where the excerpt came from: a chunk's outline path, a lexical
- // source's name, or the generic fulltext label
- let sourceLabel = this._getSourceLabel(this._result.source);
- if (this._result.outlinePath) {
- this._path.removeAttribute('data-l10n-id');
- this._path.textContent = this._result.outlinePath;
- }
- else if (sourceLabel) {
- this._path.removeAttribute('data-l10n-id');
- this._path.textContent = sourceLabel;
- }
- 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 || sourceLabel,
- this._location.hidden ? '' : this._location.textContent,
- this._result.text
- ].filter(Boolean).join('. '));
- }
-
- // The excerpt'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
deleted file mode 100644
index 5f54cdc625..0000000000
--- a/chrome/content/zotero/elements/searchResultsBox.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- ***** 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" }
- );
-
- // Most match excerpts shown for an item
- const MAX_RESULTS = 5;
-
- // Why the selected item matched the active best-match search: cards with
- // excerpts of the item's own text around the matches (see
- // Zotero.BestMatch.getMatchingExcerpts()), so they can be read without
- // opening anything. Shown only while a best-match search is active, for
- // items with something to show.
- 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);
- }
- });
- }
-
- // The query the selected collection rows are ranked by, or false when
- // no best-match search is active. Rows can be duck-typed stand-ins
- // (e.g. the citation dialog's), which implement only part of the row
- // API.
- get _query() {
- // Quick-search state (setSearch()) lives on collection tree row
- // *instances*, and the ones passed down the item pane are
- // re-fetched from the collections view at item-selection time --
- // which can have rebuilt its rows since the items view got its
- // own set. Only the items view's instances are guaranteed to
- // carry the active search, so prefer those; the passed rows are
- // the fallback for hosts without an items view.
- let itemsView = this.closest('item-pane')?.itemsView;
- let rows = itemsView?.collectionTreeRows?.length
- ? itemsView.collectionTreeRows
- : this.collectionTreeRows;
- for (let row of rows || []) {
- if (typeof row.getBestMatchQuery == 'function') {
- let query = row.getBestMatchQuery();
- if (query) {
- return query;
- }
- }
- }
- return false;
- }
-
- // A query change re-renders even when the item didn't change
- get _renderDependencies() {
- return [...super._renderDependencies, this._query];
- }
-
- render() {}
-
- async asyncRender() {
- if (!this.initialized) return;
- if (this._isAlreadyRendered("async")) return;
-
- let item = this.item;
- let query = this._query;
- this._body.replaceChildren();
- if (!item || !query) {
- return;
- }
-
- let excerpts = [];
- try {
- excerpts = await Zotero.BestMatch.getMatchingExcerpts(query, item.id,
- { limit: MAX_RESULTS });
- }
- catch (e) {
- Zotero.logError(e);
- }
- // The selection may have moved on while scoring
- if (this.item !== item) {
- return;
- }
- this._count = excerpts.length;
- this._section.setCount(excerpts.length);
- this._updateHidden();
- // Left in the order getMatchingExcerpts() returns 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 excerpt of excerpts) {
- let row = document.createXULElement('search-result-row');
- row.result = excerpt;
- this._body.append(row);
- }
- }
-
- // For a file attachment, open it where the activated card's excerpt
- // is: for a PDF with a stored chunk position, scrolled to and
- // highlighting the section; without one (EPUB, snapshot, a lexical
- // excerpt), just open it. Other item types show their matched text in
- // the pane already, so a card activation has nowhere to go.
- _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
- // scoring, 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._query || this.tabType == 'reader'
- || this._count === 0;
- }
- }
-
- customElements.define("search-results-box", SearchResultsBox);
-}
diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx
index fb6b8eced7..444f0eae4e 100644
--- a/chrome/content/zotero/itemTree.jsx
+++ b/chrome/content/zotero/itemTree.jsx
@@ -107,6 +107,9 @@ class ItemTreeRowProvider {
this._searchItemIDs = new Set();
this._searchParentIDs = new Set();
this._includeTrashed = false;
+ // The best-match search session whose previews rows show as match
+ // children (see SearchMatch in itemTreeRow.js), while one is active
+ this._bestMatchSession = null;
this.onUpdate = this.createEventBinding('update');
}
@@ -231,6 +234,7 @@ class ItemTreeRowProvider {
}
return row.isContainerEmpty({
includeTrashed: this._includeTrashed,
+ getMatchPreviews: this._bestMatchSession?.getPreviews,
});
}
@@ -248,8 +252,22 @@ class ItemTreeRowProvider {
_refreshContainer(index, skipRowMapRefresh = false) {
if (!this.isContainer(index)) return;
+ // Reopening recreates child rows closed, so remember which
+ // descendants were open and reopen them afterward
+ let level = this.getLevel(index);
+ let openDescendantIDs = [];
+ for (let i = index + 1; i < this._rows.length && this.getLevel(i) > level; i++) {
+ if (this.isContainer(i) && this.isContainerOpen(i)) {
+ openDescendantIDs.push(this.getRow(i).id);
+ }
+ }
this._closeContainer(index, true);
this._openContainer(index, true);
+ if (openDescendantIDs.length) {
+ // _restoreOpenState() looks rows up by id
+ this.refreshRowMap();
+ this._restoreOpenState(openDescendantIDs);
+ }
if (!skipRowMapRefresh) {
this.refreshRowMap();
}
@@ -282,6 +300,7 @@ class ItemTreeRowProvider {
searchItemIDs: this._searchItemIDs,
includeTrashed: this._includeTrashed,
filterChildItems: this.itemTree.props.filterChildItems,
+ getMatchPreviews: this._bestMatchSession?.getPreviews,
});
let childRows = childRefs.map(ref => this.createRow(ref, level + 1, false));
@@ -1280,6 +1299,8 @@ var ItemTree = class ItemTree extends LibraryTree {
* @param {boolean} options.restoreSelection - Whether to restore the cached selection.
* @param {boolean} options.ensureRowsAreVisible - Whether to ensure selected rows are visible.
* @param {boolean} options.restoreScroll - Whether to restore the cached scroll position.
+ * @param {boolean} options.scrollToTop - Whether to show the list from the top, ignoring
+ * the cached scroll position.
* @param {boolean} options.loading - Whether to show loading state (hides tree, shows message).
* @param {string} options.message - Optional message to display (for loading, errors, intro text).
*/
@@ -1338,7 +1359,8 @@ var ItemTree = class ItemTree extends LibraryTree {
const itemsViewInActiveWindow = Zotero.getActiveZoteroPane()?.itemsView == this;
const prioritizeRestore = !(options.selectInActiveWindow && itemsViewInActiveWindow);
- const ensureVisible = options.restoreScroll ? false : options.ensureRowsAreVisible;
+ const ensureVisible = options.restoreScroll || options.scrollToTop
+ ? false : options.ensureRowsAreVisible;
if (prioritizeRestore && options.restoreSelection) {
this._restoreSelection(null, options.expandCollapsedParents, ensureVisible);
@@ -1352,7 +1374,10 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
- if (options.restoreScroll) {
+ if (options.scrollToTop) {
+ this._treebox?.scrollTo(0);
+ }
+ else if (options.restoreScroll) {
this._restoreScrollPosition();
}
@@ -2358,6 +2383,13 @@ var ItemTree = class ItemTree extends LibraryTree {
row.renderRow(div, index, columns, rowData, this._renderCtx);
+ // A pending search-match row on screen is the demand signal for
+ // deriving its item's previews: the virtualized list only renders
+ // what's visible, so rendering names exactly what's worth deriving
+ if (row.type == 'search-match-placeholder') {
+ this.rowProvider.onSearchMatchRendered?.(row.ref.itemID);
+ }
+
if (!oldDiv) {
if (this.props.dragAndDrop && row.isDraggable) {
div.setAttribute('draggable', true);
diff --git a/chrome/content/zotero/itemTreeColumns.jsx b/chrome/content/zotero/itemTreeColumns.jsx
index c5812631d0..88ee2eb85a 100644
--- a/chrome/content/zotero/itemTreeColumns.jsx
+++ b/chrome/content/zotero/itemTreeColumns.jsx
@@ -397,9 +397,13 @@ const COLUMNS = [
renderCell(index, data, column, isFirstColumn, doc) {
let cell = doc.createElement('span');
cell.className = `cell ${column.className}`;
- let fraction = this.rowProvider.getBestMatchBarFractions()
- .get(this.getRow(index).id);
- if (fraction !== undefined) {
+ let row = this.getRow(index);
+ // Rows that carry their own relevance (e.g. a search-match row,
+ // showing the strength of the evidence it displays) report it
+ // themselves; every other row's bar comes from the view's scores
+ let fraction = this.rowProvider.getBestMatchBarFractions().get(row.id)
+ ?? row.getRelevanceFraction();
+ if (fraction !== null && fraction !== undefined) {
let bar = doc.createElement('span');
bar.className = 'relevance-bar';
let fill = doc.createElement('span');
@@ -408,9 +412,11 @@ const COLUMNS = [
bar.append(fill);
cell.append(bar);
// The rank reaches assistive technology via the row label; show
- // it visually as a tooltip
- doc.l10n.formatValue('items-column-relevance-rank', { rank: data })
- .then(label => cell.title = label);
+ // it visually as a tooltip. Match rows carry no rank of their own.
+ if (data) {
+ doc.l10n.formatValue('items-column-relevance-rank', { rank: data })
+ .then(label => cell.title = label);
+ }
}
return cell;
}
diff --git a/chrome/content/zotero/itemTreeRow.js b/chrome/content/zotero/itemTreeRow.js
index 0389ebb3bb..9482d37371 100644
--- a/chrome/content/zotero/itemTreeRow.js
+++ b/chrome/content/zotero/itemTreeRow.js
@@ -103,6 +103,17 @@ class ItemTreeRow {
return getCSSItemTypeIcon('document');
}
+ /**
+ * The 0-1 fraction the Relevance column's bar shows for this row on its
+ * own, for rows carrying their own relevance rather than taking it from
+ * the view's best-match scores, or null for rows that don't
+ *
+ * @return {Number|null}
+ */
+ getRelevanceFraction() {
+ return null;
+ }
+
renderRow(div, index, columns, rowData, renderCtx) {
for (let column of columns) {
if (column.hidden) continue;
@@ -461,11 +472,16 @@ class FileItemTreeRow extends ZoteroItemTreeRow {
return true;
}
- isContainerEmpty() {
+ isContainerEmpty({ getMatchPreviews } = {}) {
+ // An attachment with search matches to show can be expanded even
+ // with no annotations of its own
+ if (getMatchPreviews?.(this.ref.id)) {
+ return false;
+ }
return this.ref.numAnnotations() == 0;
}
- getChildItems({ searchMode, searchItemIDs } = {}) {
+ getChildItems({ searchMode, searchItemIDs, getMatchPreviews } = {}) {
let annotations = this.ref.getAnnotations();
// With "Hide Non-Matching Annotations" enabled, if any of the attachment's
// annotations match a search, show only those and hide the rest. If none match,
@@ -477,7 +493,8 @@ class FileItemTreeRow extends ZoteroItemTreeRow {
annotations = matches;
}
}
- return annotations;
+ // Fulltext match rows come after the annotations
+ return [...annotations, ...SearchMatch.forItem(this.ref, getMatchPreviews)];
}
_supportsBestAttachmentState() {
@@ -574,6 +591,155 @@ class AnnotationItemTreeRow extends ZoteroItemTreeRow {
}
}
+/**
+ * The reference a search-match row wraps: one place a best-match search
+ * matched inside an item, or -- with no entry yet -- a stand-in for that
+ * item's matches while its preview is still being derived.
+ *
+ * Item tree rows normally wrap data objects. A preview isn't a stored
+ * object, so this stands in as the tree's reference to one.
+ */
+class SearchMatch {
+ constructor(itemID, entry = null) {
+ this.itemID = itemID;
+ // A preview entry (see Zotero.BestMatch.Session#getPreviews()), or
+ // null while the item's previews are still pending
+ this.entry = entry;
+ this.treeViewID = 'SM' + itemID + (entry ? '-' + entry.key : '-pending');
+ this.id = this.treeViewID;
+ }
+
+ get isPending() {
+ return !this.entry;
+ }
+
+ /**
+ * 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
+ * the item has no preview or its preview derived nothing.
+ *
+ * @param {Zotero.Item} item
+ * @param {Function} [getMatchPreviews] - itemID -> preview accessor (see
+ * Zotero.BestMatch.Session#getPreviews()), passed by the row
+ * provider while a best-match search is active
+ * @return {SearchMatch[]}
+ */
+ static forItem(item, getMatchPreviews) {
+ let preview = getMatchPreviews?.(item.id);
+ if (!preview) {
+ return [];
+ }
+ if (preview.state == 'pending') {
+ return [new SearchMatch(item.id)];
+ }
+ return preview.entries.map(entry => new SearchMatch(item.id, entry));
+ }
+}
+
+/**
+ * Row showing one place a best-match search matched inside its parent row's
+ * item: a derived excerpt with its matches highlighted. The ref is a
+ * SearchMatch carrying the preview entry it shows.
+ */
+class SearchMatchItemTreeRow extends ItemTreeRow {
+ get type() {
+ return 'search-match';
+ }
+
+ getDisplayTitle() {
+ return this.ref.entry.text;
+ }
+
+ getField(field) {
+ if (field == 'title') {
+ return this.getDisplayTitle();
+ }
+ return super.getField(field);
+ }
+
+ /**
+ * A match row's bar shows the strength of the evidence it displays,
+ * rather than its item's relevance
+ */
+ getRelevanceFraction() {
+ return this.ref.entry?.strength ?? null;
+ }
+
+ getIcon() {
+ let icon = getCSSIcon('search');
+ icon.classList.add('icon-item-type');
+ return icon;
+ }
+
+ renderRow(div, index, columns, rowData, renderCtx) {
+ let titleColumn = Object.assign(
+ {},
+ columns.find(column => column.dataKey == 'title'),
+ { className: 'title' }
+ );
+ div.appendChild(renderCtx.renderCell(index, rowData.title, titleColumn, true));
+ // The relevance bar while a best-match search shows the Relevance column
+ let relevanceColumn = columns.find(column => column.dataKey == 'relevance');
+ if (relevanceColumn && !relevanceColumn.hidden) {
+ let cell = renderCtx.renderCell(index, rowData?.relevance, relevanceColumn, false);
+ if (cell) {
+ div.appendChild(cell);
+ }
+ }
+ }
+
+ renderPrimaryCell(index, data, column) {
+ let span = document.createElement('span');
+ span.className = `cell ${column.className} primary`;
+ let textSpan = document.createElement('span');
+ textSpan.className = 'cell-text';
+ let { text, ranges } = this.ref.entry;
+ let last = 0;
+ for (let [start, end] of ranges || []) {
+ if (start > last) {
+ textSpan.append(text.slice(last, start));
+ }
+ let mark = document.createElement('span');
+ mark.className = 'search-match-highlight';
+ mark.textContent = text.slice(start, end);
+ textSpan.append(mark);
+ last = end;
+ }
+ if (last < text.length) {
+ textSpan.append(text.slice(last));
+ }
+ span.append(textSpan);
+ return span;
+ }
+}
+
+/**
+ * Row standing in for an item's search-match rows while its preview is
+ * still pending: a single row showing that matches are on their way, which
+ * the fill replaces with the item's SearchMatchItemTreeRows. The ref is a
+ * SearchMatch with no entry yet.
+ */
+class SearchMatchPlaceholderItemTreeRow extends SearchMatchItemTreeRow {
+ get type() {
+ return 'search-match-placeholder';
+ }
+
+ getDisplayTitle() {
+ return '';
+ }
+
+ renderPrimaryCell(index, data, column) {
+ let span = document.createElement('span');
+ span.className = `cell ${column.className} primary`;
+ let textSpan = document.createElement('span');
+ textSpan.className = 'cell-text search-match-pending';
+ textSpan.textContent = Zotero.ftl.formatValueSync('items-search-match-pending');
+ span.append(textSpan);
+ return span;
+ }
+}
+
/**
* Row wrapping a Zotero.Collection (shown in trash view).
*/
@@ -751,6 +917,11 @@ class SpacerItemTreeRow extends ItemTreeRow {
ItemTreeRow.create = function (ref, level, isOpen) {
if (ref instanceof Zotero.Collection) return new CollectionItemTreeRow(ref, level, isOpen);
if (ref instanceof Zotero.Search) return new SearchItemTreeRow(ref, level, isOpen);
+ if (ref instanceof SearchMatch) {
+ return ref.isPending
+ ? new SearchMatchPlaceholderItemTreeRow(ref, level, isOpen)
+ : new SearchMatchItemTreeRow(ref, level, isOpen);
+ }
if (ref.isAnnotation?.()) return new AnnotationItemTreeRow(ref, level, isOpen);
if (ref.isFileAttachment?.()) return new FileItemTreeRow(ref, level, isOpen);
return new ZoteroItemTreeRow(ref, level, isOpen);
@@ -761,6 +932,9 @@ module.exports.ItemTreeRow = ItemTreeRow;
module.exports.ZoteroItemTreeRow = ZoteroItemTreeRow;
module.exports.FileItemTreeRow = FileItemTreeRow;
module.exports.AnnotationItemTreeRow = AnnotationItemTreeRow;
+module.exports.SearchMatchItemTreeRow = SearchMatchItemTreeRow;
+module.exports.SearchMatchPlaceholderItemTreeRow = SearchMatchPlaceholderItemTreeRow;
+module.exports.SearchMatch = SearchMatch;
module.exports.CollectionItemTreeRow = CollectionItemTreeRow;
module.exports.SearchItemTreeRow = SearchItemTreeRow;
module.exports.SpacerItemTreeRow = SpacerItemTreeRow;
diff --git a/chrome/content/zotero/xpcom/bestMatch.js b/chrome/content/zotero/xpcom/bestMatch.js
index 6adcf5e72b..8146085bb5 100644
--- a/chrome/content/zotero/xpcom/bestMatch.js
+++ b/chrome/content/zotero/xpcom/bestMatch.js
@@ -60,6 +60,10 @@ Zotero.BestMatch = new function () {
return Zotero.Embeddings.isEnabled();
}
+ function _hasPreviews(itemID) {
+ return !!Zotero.Items.get(itemID)?.isFileAttachment?.();
+ }
+
/**
* Whether a query has anything for best-match search to rank by. The
* lexical engine needs at least one scoring unit; failing that, the
@@ -171,74 +175,310 @@ Zotero.BestMatch = new function () {
};
/**
- * Excerpts showing why an item matches a query, for the item pane's
- * search-results section: the union of both engines' evidence, so an
- * item ranked by either kind of match -- or both -- explains itself.
+ * A best-match search session: one query's scoring pass plus the
+ * previews explaining its matches, derived on demand.
*
- * The lexical engine's excerpts around the query's literal matches (see
- * Zotero.Lexical.getMatchingExcerpts()) are always collected; they carry
- * a source name and highlight ranges. With a semantic model enabled, the
- * item's most similar indexed chunks join them (see
- * Zotero.Embeddings.getMatchingChunks()), carrying document locations --
- * with the query's literal matches highlighted within their text too, so
- * a chunk that's both similar and a literal hit tells both at once. A
- * lexical fulltext excerpt whose match a shown chunk already covers is
- * dropped as redundant; one from a passage no chunk surfaced stays.
- *
- * Entries are ordered by the strength of the evidence they show, each on
- * its engine's 0-1 display scale, and capped at the limit together. A
- * semantic index that isn't ready contributes nothing, leaving the
- * lexical excerpts alone.
- *
- * @param {String} queryText
- * @param {Number} itemID
- * @param {Object} [options]
- * @param {Number} [options.limit=5] - Most entries to return
- * @return {Promise