Render citeproc.js markup in tab titles (#4602)
This commit is contained in:
parent
e3c80ac6a0
commit
f54becf32e
6 changed files with 183 additions and 96 deletions
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 += '.';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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> <another-tag/></i>');
|
||||||
|
});
|
||||||
|
});
|
||||||
})
|
})
|
||||||
|
|
43
test/tests/tabsTest.js
Normal file
43
test/tests/tabsTest.js
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue