From f54becf32ee4df10930a48d0e099f2f9cf52fb40 Mon Sep 17 00:00:00 2001 From: Abe Jellinek Date: Mon, 26 Aug 2024 07:57:17 -0400 Subject: [PATCH] Render citeproc.js markup in tab titles (#4602) --- chrome/content/zotero/components/tabBar.jsx | 21 +++- chrome/content/zotero/itemTree.jsx | 92 +---------------- chrome/content/zotero/tabs.js | 5 +- .../zotero/xpcom/utilities_internal.js | 99 +++++++++++++++++++ test/tests/itemTreeTest.js | 19 ++++ test/tests/tabsTest.js | 43 ++++++++ 6 files changed, 183 insertions(+), 96 deletions(-) create mode 100644 test/tests/tabsTest.js diff --git a/chrome/content/zotero/components/tabBar.jsx b/chrome/content/zotero/components/tabBar.jsx index d7cabf1099..b3c0f7e916 100644 --- a/chrome/content/zotero/components/tabBar.jsx +++ b/chrome/content/zotero/components/tabBar.jsx @@ -33,7 +33,7 @@ const { CSSIcon, CSSItemTypeIcon } = require('./icons'); const SCROLL_ARROW_SCROLL_BY = 222; const Tab = memo((props) => { - const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, selected, title } = props; + const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, selected, title, renderTitle } = props; const handleTabMouseDown = useCallback(event => onTabMouseDown(event, id), [onTabMouseDown, id]); const handleContextMenu = useCallback(event => onContextMenu(event, id), [onContextMenu, id]); @@ -41,6 +41,18 @@ const Tab = memo((props) => { const handleDragStart = useCallback(event => onDragStart(event, id, index), [onDragStart, id, index]); const handleTabClose = useCallback(event => onTabClose(event, id), [onTabClose, id]); + let titleText; + let titleHTML; + if (renderTitle) { + let parentElement = document.createElement('div'); + titleText = Zotero.Utilities.Internal.renderItemTitle(title, parentElement); + titleHTML = parentElement.innerHTML; + } + else { + titleText = title; + titleHTML = null; + } + return (
{ ? : } -
{title}
+ {titleHTML + ?
+ :
{titleText}
}
': { - beginsTag: 'i', - inverseStyle: { fontStyle: 'normal' } - }, - '': { - endsTag: 'i' - }, - '': { - beginsTag: 'b', - inverseStyle: { fontWeight: 'normal' } - }, - '': { - endsTag: 'b' - }, - '': { - beginsTag: 'sub' - }, - '': { - endsTag: 'sub' - }, - '': { - beginsTag: 'sup' - }, - '': { - endsTag: 'sup' - }, - '': { - beginsTag: 'span', - style: { fontVariant: 'small-caps' } - }, - '': { - // No effect in item tree - beginsTag: 'span' - }, - '': { - endsTag: 'span' - } - }; - - _renderItemTitle(title, targetNode) { - let markupStack = []; - let nodeStack = [targetNode]; - let textContent = ''; - - for (let token of title.split(/(<[^>]+>)/)) { - if (this._titleMarkup.hasOwnProperty(token)) { - let markup = this._titleMarkup[token]; - if (markup.beginsTag) { - let node = document.createElement(markup.beginsTag); - if (markup.style) { - Object.assign(node.style, markup.style); - } - if (markup.inverseStyle && markupStack.some(otherMarkup => otherMarkup.beginsTag === markup.beginsTag)) { - Object.assign(node.style, markup.inverseStyle); - } - markupStack.push({ ...markup, token }); - nodeStack.push(node); - continue; - } - else if (markup.endsTag && markupStack.some(otherMarkup => otherMarkup.beginsTag === markup.endsTag)) { - while (markupStack.length) { - let discardedMarkup = markupStack.pop(); - let discardedNode = nodeStack.pop(); - if (discardedMarkup.beginsTag === markup.endsTag) { - nodeStack[nodeStack.length - 1].append(discardedNode); - break; - } - else { - nodeStack[nodeStack.length - 1].append(discardedMarkup.token, ...discardedNode.childNodes); - } - } - - continue; - } - } - - nodeStack[nodeStack.length - 1].append(token); - textContent += token; - } - - while (markupStack.length) { - let discardedMarkup = markupStack.pop(); - let discardedNode = nodeStack.pop(); - nodeStack[0].append(discardedMarkup.token, ...discardedNode.childNodes); - } - - return textContent; - } - _renderPrimaryCell(index, data, column) { let span = document.createElement('span'); span.className = `cell ${column.className}`; @@ -2854,7 +2764,7 @@ var ItemTree = class ItemTree extends LibraryTree { } let textSpan = document.createElement('span'); - let textWithFullStop = this._renderItemTitle(data, textSpan); + let textWithFullStop = Zotero.Utilities.Internal.renderItemTitle(data, textSpan); if (!textWithFullStop.match(/\.$/)) { textWithFullStop += '.'; } diff --git a/chrome/content/zotero/tabs.js b/chrome/content/zotero/tabs.js index bd6a570701..b18e818b62 100644 --- a/chrome/content/zotero/tabs.js +++ b/chrome/content/zotero/tabs.js @@ -121,6 +121,7 @@ var Zotero_Tabs = new function () { id: tab.id, type: tab.type, title: tab.title, + renderTitle: tab.type === 'reader' || tab.type === 'reader-unloaded', selected: tab.id == this._selectedID, isItemType: tab.id !== 'zotero-pane', icon: tab.data?.icon || null @@ -482,7 +483,7 @@ var Zotero_Tabs = new function () { if (tab.id === 'zotero-pane' && (options.keepTabFocused !== true)) { focusZoteroPane(); } - let tabNode = document.querySelector(`.tab[data-id="${tab.id}"]`); + let tabNode = document.querySelector(`#tab-bar-container .tab[data-id="${tab.id}"]`); if (this._focusOptions.keepTabFocused && document.activeElement.getAttribute('data-id') != tabNode.getAttribute('data-id')) { // Keep focus on the currently selected tab during keyboard navigation if (tab.id == 'zotero-pane') { @@ -619,7 +620,7 @@ var Zotero_Tabs = new function () { const nextTab = this._tabs[tabIndexToFocus]; // There may be duplicate tabs - in normal tab array and in pinned tabs // Go through all candidates and try to focus the visible one - let candidates = document.querySelectorAll(`[data-id="${nextTab.id}"]`); + let candidates = document.querySelectorAll(`#tab-bar-container .tab[data-id="${nextTab.id}"]`); for (let node of candidates) { node.focus(); // Visible tab was found and focused diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js index d5e668e1ed..43db799329 100644 --- a/chrome/content/zotero/xpcom/utilities_internal.js +++ b/chrome/content/zotero/xpcom/utilities_internal.js @@ -2376,6 +2376,105 @@ Zotero.Utilities.Internal = { } menupopup.prepend(...editMenuItems); } + }, + + _titleMarkup: { + '': { + beginsTag: 'i', + inverseStyle: { fontStyle: 'normal' } + }, + '': { + endsTag: 'i' + }, + '': { + beginsTag: 'b', + inverseStyle: { fontWeight: 'normal' } + }, + '': { + endsTag: 'b' + }, + '': { + beginsTag: 'sub' + }, + '': { + endsTag: 'sub' + }, + '': { + beginsTag: 'sup' + }, + '': { + endsTag: 'sup' + }, + '': { + beginsTag: 'span', + style: { fontVariant: 'small-caps' } + }, + '': { + // No effect in item tree + beginsTag: 'span' + }, + '': { + endsTag: 'span' + } + }, + + /** + * Render Citeproc.js HTML-style markup in a title + * + * @param {string} title + * @param {ParentNode} targetNode + * @returns {string} The non-tag parts of the title + */ + renderItemTitle(title, targetNode) { + let doc = targetNode.ownerDocument; + + let markupStack = []; + let nodeStack = [targetNode]; + let textContent = ''; + + for (let token of title.split(/(<[^>]+>)/)) { + if (this._titleMarkup.hasOwnProperty(token)) { + let markup = this._titleMarkup[token]; + if (markup.beginsTag) { + let node = doc.createElement(markup.beginsTag); + if (markup.style) { + Object.assign(node.style, markup.style); + } + if (markup.inverseStyle && markupStack.some(otherMarkup => otherMarkup.beginsTag === markup.beginsTag)) { + Object.assign(node.style, markup.inverseStyle); + } + markupStack.push({ ...markup, token }); + nodeStack.push(node); + continue; + } + else if (markup.endsTag && markupStack.some(otherMarkup => otherMarkup.beginsTag === markup.endsTag)) { + while (markupStack.length) { + let discardedMarkup = markupStack.pop(); + let discardedNode = nodeStack.pop(); + if (discardedMarkup.beginsTag === markup.endsTag) { + nodeStack[nodeStack.length - 1].append(discardedNode); + break; + } + else { + nodeStack[nodeStack.length - 1].append(discardedMarkup.token, ...discardedNode.childNodes); + } + } + + continue; + } + } + + nodeStack[nodeStack.length - 1].append(token); + textContent += token; + } + + while (markupStack.length) { + let discardedMarkup = markupStack.pop(); + let discardedNode = nodeStack.pop(); + nodeStack[0].append(discardedMarkup.token, ...discardedNode.childNodes); + } + + return textContent; } }; diff --git a/test/tests/itemTreeTest.js b/test/tests/itemTreeTest.js index 7843425514..ed7882d85f 100644 --- a/test/tests/itemTreeTest.js +++ b/test/tests/itemTreeTest.js @@ -1607,4 +1607,23 @@ describe("Zotero.ItemTree", function() { assert.lengthOf(zp.itemsView.getSelectedObjects(), 2); }); }); + + describe("#_renderPrimaryCell()", function () { + before(async function () { + await waitForItemsLoad(win); + }); + + it("should render citeproc.js HTML", async function () { + await createDataObject('item', { + title: 'Review of Review of Book ' + }); + let cellText; + do { + await Zotero.Promise.delay(10); + cellText = win.document.querySelector('#zotero-items-tree .row.selected .cell.title .cell-text'); + } + while (!cellText); + assert.equal(cellText.innerHTML, 'Review of Review of Book <another-tag/>'); + }); + }); }) diff --git a/test/tests/tabsTest.js b/test/tests/tabsTest.js new file mode 100644 index 0000000000..b7ef45bb29 --- /dev/null +++ b/test/tests/tabsTest.js @@ -0,0 +1,43 @@ +describe("Zotero_Tabs", function() { + var win, doc, zp; + + before(async function () { + win = await loadZoteroPane(); + doc = win.document; + zp = win.ZoteroPane; + }); + + after(function () { + win.close(); + }); + + describe("Title rendering", function () { + it("should render citeproc.js markup in a tab title", async function () { + let item = await createDataObject('item', { + title: 'Not italic, italic' + }); + let attachment = await importPDFAttachment(item); + let reader = await Zotero.Reader.open(attachment.id); + let tab; + while (!tab?.textContent.includes('Not italic, italic')) { + await Zotero.Promise.delay(10); + tab = doc.querySelector(`#tab-bar-container .tab[data-id="${reader.tabID}"]`); + } + assert.include(tab.querySelector('i').textContent, 'italic'); + }); + + it("should not render unknown markup in a tab title", async function () { + let item = await createDataObject('item', { + title: 'Something bad ' + }); + let attachment = await importPDFAttachment(item); + let reader = await Zotero.Reader.open(attachment.id); + let tab; + while (!tab?.textContent.includes('Something bad ')) { + await Zotero.Promise.delay(10); + tab = doc.querySelector(`#tab-bar-container .tab[data-id="${reader.tabID}"]`); + } + assert.notOk(tab.querySelector('img')); + }); + }); +});