Render citeproc.js markup in tab titles (#4602)

This commit is contained in:
Abe Jellinek 2024-08-26 07:57:17 -04:00 committed by GitHub
parent e3c80ac6a0
commit f54becf32e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 183 additions and 96 deletions

View file

@ -33,7 +33,7 @@ const { CSSIcon, CSSItemTypeIcon } = require('./icons');
const SCROLL_ARROW_SCROLL_BY = 222; const SCROLL_ARROW_SCROLL_BY = 222;
const Tab = memo((props) => { 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 handleTabMouseDown = useCallback(event => onTabMouseDown(event, id), [onTabMouseDown, id]);
const handleContextMenu = useCallback(event => onContextMenu(event, id), [onContextMenu, 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 handleDragStart = useCallback(event => onDragStart(event, id, index), [onDragStart, id, index]);
const handleTabClose = useCallback(event => onTabClose(event, id), [onTabClose, id]); 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 ( return (
<div <div
key={id} key={id}
@ -59,7 +71,9 @@ const Tab = memo((props) => {
? <CSSItemTypeIcon itemType={icon} className="tab-icon" /> ? <CSSItemTypeIcon itemType={icon} className="tab-icon" />
: <CSSIcon name={icon} className="tab-icon" /> : <CSSIcon name={icon} className="tab-icon" />
} }
<div className="tab-name" title={title}>{title}</div> {titleHTML
? <div className="tab-name" title={titleText} dangerouslySetInnerHTML={{ __html: titleHTML }}/>
: <div className="tab-name" title={titleText}>{titleText}</div>}
<div <div
className="tab-close" className="tab-close"
onClick={handleTabClose} onClick={handleTabClose}
@ -84,7 +98,8 @@ Tab.propTypes = {
onTabClose: PropTypes.func.isRequired, onTabClose: PropTypes.func.isRequired,
onTabMouseDown: PropTypes.func.isRequired, onTabMouseDown: PropTypes.func.isRequired,
selected: PropTypes.bool.isRequired, selected: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired title: PropTypes.string.isRequired,
renderTitle: PropTypes.bool,
}; };

View file

@ -2704,96 +2704,6 @@ var ItemTree = class ItemTree extends LibraryTree {
// //
// ////////////////////////////////////////////////////////////////////////////// // //////////////////////////////////////////////////////////////////////////////
_titleMarkup = {
'<i>': {
beginsTag: 'i',
inverseStyle: { fontStyle: 'normal' }
},
'</i>': {
endsTag: 'i'
},
'<b>': {
beginsTag: 'b',
inverseStyle: { fontWeight: 'normal' }
},
'</b>': {
endsTag: 'b'
},
'<sub>': {
beginsTag: 'sub'
},
'</sub>': {
endsTag: 'sub'
},
'<sup>': {
beginsTag: 'sup'
},
'</sup>': {
endsTag: 'sup'
},
'<span style="font-variant:small-caps;">': {
beginsTag: 'span',
style: { fontVariant: 'small-caps' }
},
'<span class="nocase">': {
// No effect in item tree
beginsTag: 'span'
},
'</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) { _renderPrimaryCell(index, data, column) {
let span = document.createElement('span'); let span = document.createElement('span');
span.className = `cell ${column.className}`; span.className = `cell ${column.className}`;
@ -2854,7 +2764,7 @@ var ItemTree = class ItemTree extends LibraryTree {
} }
let textSpan = document.createElement('span'); let textSpan = document.createElement('span');
let textWithFullStop = this._renderItemTitle(data, textSpan); let textWithFullStop = Zotero.Utilities.Internal.renderItemTitle(data, textSpan);
if (!textWithFullStop.match(/\.$/)) { if (!textWithFullStop.match(/\.$/)) {
textWithFullStop += '.'; textWithFullStop += '.';
} }

View file

@ -121,6 +121,7 @@ var Zotero_Tabs = new function () {
id: tab.id, id: tab.id,
type: tab.type, type: tab.type,
title: tab.title, title: tab.title,
renderTitle: tab.type === 'reader' || tab.type === 'reader-unloaded',
selected: tab.id == this._selectedID, selected: tab.id == this._selectedID,
isItemType: tab.id !== 'zotero-pane', isItemType: tab.id !== 'zotero-pane',
icon: tab.data?.icon || null icon: tab.data?.icon || null
@ -482,7 +483,7 @@ var Zotero_Tabs = new function () {
if (tab.id === 'zotero-pane' && (options.keepTabFocused !== true)) { if (tab.id === 'zotero-pane' && (options.keepTabFocused !== true)) {
focusZoteroPane(); 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')) { if (this._focusOptions.keepTabFocused && document.activeElement.getAttribute('data-id') != tabNode.getAttribute('data-id')) {
// Keep focus on the currently selected tab during keyboard navigation // Keep focus on the currently selected tab during keyboard navigation
if (tab.id == 'zotero-pane') { if (tab.id == 'zotero-pane') {
@ -619,7 +620,7 @@ var Zotero_Tabs = new function () {
const nextTab = this._tabs[tabIndexToFocus]; const nextTab = this._tabs[tabIndexToFocus];
// There may be duplicate tabs - in normal tab array and in pinned tabs // There may be duplicate tabs - in normal tab array and in pinned tabs
// Go through all candidates and try to focus the visible one // 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) { for (let node of candidates) {
node.focus(); node.focus();
// Visible tab was found and focused // Visible tab was found and focused

View file

@ -2376,6 +2376,105 @@ Zotero.Utilities.Internal = {
} }
menupopup.prepend(...editMenuItems); menupopup.prepend(...editMenuItems);
} }
},
_titleMarkup: {
'<i>': {
beginsTag: 'i',
inverseStyle: { fontStyle: 'normal' }
},
'</i>': {
endsTag: 'i'
},
'<b>': {
beginsTag: 'b',
inverseStyle: { fontWeight: 'normal' }
},
'</b>': {
endsTag: 'b'
},
'<sub>': {
beginsTag: 'sub'
},
'</sub>': {
endsTag: 'sub'
},
'<sup>': {
beginsTag: 'sup'
},
'</sup>': {
endsTag: 'sup'
},
'<span style="font-variant:small-caps;">': {
beginsTag: 'span',
style: { fontVariant: 'small-caps' }
},
'<span class="nocase">': {
// No effect in item tree
beginsTag: 'span'
},
'</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;
} }
}; };

View file

@ -1607,4 +1607,23 @@ describe("Zotero.ItemTree", function() {
assert.lengthOf(zp.itemsView.getSelectedObjects(), 2); 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 <i>Review of <i>B<sub>oo</sub>k</i> <another-tag/></i>'
});
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 <i xmlns="http://www.w3.org/1999/xhtml">Review of <i style="font-style: normal;">B<sub>oo</sub>k</i> &lt;another-tag/&gt;</i>');
});
});
}) })

43
test/tests/tabsTest.js Normal file
View file

@ -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, <i>italic</i>'
});
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 <img src="missing.jpg" onerror="alert(1)">'
});
let attachment = await importPDFAttachment(item);
let reader = await Zotero.Reader.open(attachment.id);
let tab;
while (!tab?.textContent.includes('Something bad <img src="missing.jpg" onerror="alert(1)">')) {
await Zotero.Promise.delay(10);
tab = doc.querySelector(`#tab-bar-container .tab[data-id="${reader.tabID}"]`);
}
assert.notOk(tab.querySelector('img'));
});
});
});