zotero/chrome/content/zotero/tabs.js
Bogdan Abaev c6799bc3c2 itembox focus edits and refactoring (#4096)
- removed ztabindex logic from itemBox. It is no longer needed, adds
  unnecessary complexity and is likely at the root of multiple glitches
  if a plugin inserts an arbitrary row that does not have ztabindex set.
- if a creator row is deleted when the focus is inside of the row, focus
  another creator row to not loose the focus.
- more centralized button handling in `_ensureButtonsFocusable` and
  `_updateCreatorButtonsStatus`
- refactoring of hidden toolbarbuttons css so that the icons are still
  hidden and don't occupy space (if desired) but are still visible for
  screen readers, so they are focusable without JS changing their
  visibility (this with ztabindex removal fixes vpat 24)
- removed `escape_enter` event from `editable-text`. It was a workaround
  to know when itemBox should move focus back to itemTree. Unhandled
  Enter on an input or Escape should focus itemTree (or reader) from
  anywhere in the itemPane/contextPane (not just itemBox), so that logic
  is moved to itemDetails.js. To avoid conflicts with Shift-Enter, do
  not propagate that event outside of multiline editable-text. Fixes:
  #3896
- removed not necessary keyboard nav handling from itemDetails.js. It
  was only needed for mac, and is redundant if "Keyboard navigation"
  setting is on
- using `keydown` instead of `keypress` for itemDetails keyboard nav
  handling because `Enter` `keypress` does not seem to get out of
  `editable-text` but `keydown` does.
- old handleKeyPress from itemBox is no longer relevant for most
  elements, so it is removed and substituted with a dedicated handler
  just for creator row.
- moved the creator's paste handler into its own dedicated function
  from the autocomplete handler (which was confusing)
- special handling for `enter` and `escape` events on `editable-text`
  with autocomplete to not stop event propagation, so that the events
  can bubble and be handled in `itemDetails`. It avoids some cases of
  the focus being lost and returned to the `window`. It was unnecessary
  earlier due to `escape_enter` workaround but only within itemBox and
  only within itemPane.
- removed explicit tab navigation handling from `collapsible-section`
  header. Currently, it may get stuck when buttons are hidden (e.g. in
  the trash mode). It was only added to enable keyboard navigation on
  mac before special "Keyboard navigation" setting was discovered (it
  was never an issue on windows), so now it's easier to just let mozilla
  handle it.
- always use `getTitleField` to find and focus the proper title field in
  itemBox
- on shift-tab from the focused tab, just move focus to the first
  focusable element before the splitter without any special handling for
  attachments, notes and duplicates pane as before. It ensures a more
  consistent and predictable keyboard navigation, especially now that
  itemPane is fairly keyboard accessible.

Fixes: #4076
2024-05-21 02:45:19 -04:00

1162 lines
36 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2020 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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
'use strict';
// Using 'import' breaks hooks
var React = require('react');
var ReactDOM = require('react-dom');
import TabBar from 'components/tabBar';
import { CSSIcon, CSSItemTypeIcon } from 'components/icons';
// Reduce loaded tabs limit if the system has 8 GB or less memory.
// TODO: Revise this after upgrading to Zotero 7
const MAX_LOADED_TABS = Services.sysinfo.getProperty("memsize") / 1024 / 1024 / 1024 <= 8 ? 3 : 5;
const UNLOAD_UNUSED_AFTER = 86400; // 24h
var Zotero_Tabs = new function () {
Object.defineProperty(this, 'selectedID', {
get: () => this._selectedID
});
Object.defineProperty(this, 'selectedType', {
get: () => this._getTab(this._selectedID).tab.type
});
Object.defineProperty(this, 'selectedIndex', {
get: () => this._getTab(this._selectedID).tabIndex
});
Object.defineProperty(this, 'deck', {
get: () => document.getElementById('tabs-deck')
});
Object.defineProperty(this, 'numTabs', {
get: () => this._tabs.length
});
Object.defineProperty(this, 'focusOptions', {
get: () => this._focusOptions
});
Object.defineProperty(this, 'tabsMenuList', {
get: () => document.getElementById('zotero-tabs-menu-list')
});
Object.defineProperty(this, 'tabsMenuPanel', {
get: () => document.getElementById('zotero-tabs-menu-panel')
});
this._tabBarRef = React.createRef();
this._tabs = [{
id: 'zotero-pane',
type: 'library',
title: ''
}];
this._selectedID = 'zotero-pane';
this._prevSelectedID = null;
this._history = [];
this._focusOptions = {};
this._tabsMenuFilter = "";
this._tabsMenuFocusedIndex = 0;
this._tabsMenuIgnoreMouseover = false;
this._unloadInterval = setInterval(() => {
this.unloadUnusedTabs();
}, 60000); // Trigger every minute
this._getTab = function (id) {
var tabIndex = this._tabs.findIndex(tab => tab.id == id);
return { tab: this._tabs[tabIndex], tabIndex };
};
this._update = function () {
this._tabBarRef.current.setTabs(this._tabs.map((tab) => {
let icon = null;
if (tab.id === 'zotero-pane') {
let index = ZoteroPane.collectionsView?.selection?.focused;
if (typeof index !== 'undefined' && ZoteroPane.collectionsView.getRow(index)) {
let iconName = ZoteroPane.collectionsView.getIconName(index);
icon = <CSSIcon name={iconName} className="tab-icon" />;
}
}
else if (tab.data?.itemID) {
try {
let item = Zotero.Items.get(tab.data.itemID);
icon = <CSSItemTypeIcon itemType={item.getItemTypeIconName(true)} className="tab-icon" />;
}
catch (e) {
// item might not yet be loaded, we will get the icon on the next update
}
}
return {
id: tab.id,
type: tab.type,
title: tab.title,
selected: tab.id == this._selectedID,
icon,
};
}));
// Disable File > Close menuitem if multiple tabs are open
const multipleTabsOpen = this._tabs.length > 1;
document.getElementById('cmd_close').setAttribute('disabled', multipleTabsOpen);
var { tab } = this._getTab(this._selectedID);
if (!tab) {
return;
}
document.title = (tab.title.length ? tab.title + ' - ' : '') + Zotero.appName;
if (this.isTabsMenuVisible()) {
this.refreshTabsMenuList();
if (document.activeElement.id !== "zotero-tabs-menu-filter") {
focusTabsMenuEntry();
}
}
// Disable tabs menu button when no reader tabs are present
document.getElementById("zotero-tb-tabs-menu").disabled = this._tabs.length == 1;
// Close tabs menu if all tabs are closed
if (this._tabs.length == 1 && this.isTabsMenuVisible()) {
this.tabsMenuPanel.hidePopup();
}
};
this.getTabIDByItemID = function (itemID) {
let tab = this._tabs.find(tab => tab.data && tab.data.itemID === itemID);
return tab && tab.id;
};
this.setSecondViewState = function (tabID, state) {
let { tab } = this._getTab(tabID);
tab.data.secondViewState = state;
Zotero.Session.debounceSave();
};
this.init = function () {
ReactDOM.render(
<TabBar
ref={this._tabBarRef}
onTabSelect={this.select.bind(this)}
onTabMove={this.move.bind(this)}
onTabClose={this.close.bind(this)}
onContextMenu={this._openMenu.bind(this)}
refocusReader={this.refocusReader.bind(this)}
/>,
document.getElementById('tab-bar-container'),
() => {
this._update();
}
);
};
this.getState = function () {
return this._tabs.map((tab) => {
let type = tab.type;
if (type === 'reader-unloaded') {
type = 'reader';
}
var o = {
type,
title: tab.title,
timeUnselected: tab.timeUnselected
};
if (tab.data) {
o.data = tab.data;
}
if (tab.id == this._selectedID) {
o.selected = true;
}
return o;
});
};
this.restoreState = function (tabs) {
for (let i = 0; i < tabs.length; i++) {
let tab = tabs[i];
if (tab.type === 'library') {
this.rename('zotero-pane', tab.title);
}
else if (tab.type === 'reader') {
if (Zotero.Items.exists(tab.data.itemID)) {
if (tab.selected) {
Zotero.Reader.open(tab.data.itemID,
null,
{
title: tab.title,
tabIndex: i,
openInBackground: !tab.selected,
secondViewState: tab.data.secondViewState
}
);
}
else {
this.add({
type: 'reader-unloaded',
title: tab.title,
index: i,
data: tab.data
});
}
}
}
}
// Unset the previously selected tab id, because it was set when restoring tabs
this._prevSelectedID = null;
};
/**
* Add a new tab
*
* @param {String} type
* @param {String} title
* @param {String} data - Extra data about the tab to pass to notifier and session
* @param {Integer} index
* @param {Boolean} select
* @param {Function} onClose
* @return {{ id: string, container: XULElement}} id - tab id, container - a new tab container created in the deck
*/
this.add = function ({ id, type, data, title, index, select, onClose, preventJumpback }) {
if (typeof type != 'string') {
}
if (typeof title != 'string') {
throw new Error(`'title' should be a string (was ${typeof title})`);
}
if (index !== undefined && (!Number.isInteger(index) || index < 1)) {
throw new Error(`'index' should be an integer > 0 (was ${index} (${typeof index})`);
}
if (onClose !== undefined && typeof onClose != 'function') {
throw new Error(`'onClose' should be a function (was ${typeof onClose})`);
}
id = id || 'tab-' + Zotero.Utilities.randomString();
var container = document.createXULElement('vbox');
container.id = id;
this.deck.appendChild(container);
var tab = { id, type, title, data, onClose };
index = index || this._tabs.length;
this._tabs.splice(index, 0, tab);
this._update();
Zotero.Notifier.trigger('add', 'tab', [id], { [id]: Object.assign({}, data, { type }) }, true);
if (select) {
let previousID = this._selectedID;
this.select(id);
if (!preventJumpback) {
this._prevSelectedID = previousID;
}
}
return { id, container };
};
/**
* Set a new tab title
*
* @param {String} id
* @param {String} title
*/
this.rename = function (id, title) {
if (typeof title != 'string') {
throw new Error(`'title' should be a string (was ${typeof title})`);
}
var { tab } = this._getTab(id);
if (!tab) {
return;
}
tab.title = title;
this._update();
};
/**
* Close tabs
*
* @param {String|Array<String>|undefined} ids One or more ids, or empty for the current tab
*/
this.close = function (ids) {
if (!ids) {
ids = [this._selectedID];
}
else if (!Array.isArray(ids)) {
ids = [ids];
}
if (ids.includes('zotero-pane')) {
throw new Error('Library tab cannot be closed');
}
var historyEntry = [];
var closedIDs = [];
var tmpTabs = this._tabs.slice();
for (var id of ids) {
let { tab, tabIndex } = this._getTab(id);
if (!tab) {
continue;
}
if (tab.id == this._selectedID) {
this.select(this._prevSelectedID || (this._tabs[tabIndex + 1] || this._tabs[tabIndex - 1]).id);
}
if (tab.id == this._prevSelectedID) {
this._prevSelectedID = null;
}
tabIndex = this._tabs.findIndex(x => x.id === id);
this._tabs.splice(tabIndex, 1);
if (tab.onClose) {
tab.onClose();
}
historyEntry.push({ index: tmpTabs.indexOf(tab), data: tab.data });
closedIDs.push(id);
setTimeout(() => {
document.getElementById(tab.id).remove();
// For unknown reason fx102, unlike 60, sometimes doesn't automatically update selected index
let selectedIndex = Array.from(this.deck.children).findIndex(x => x.id == this._selectedID);
if (this.deck.selectedIndex !== selectedIndex) {
this.deck.selectedIndex = selectedIndex;
}
});
}
this._history.push(historyEntry);
Zotero.Notifier.trigger('close', 'tab', [closedIDs], true);
this._update();
};
/**
* Close all tabs except the first one
*/
this.closeAll = function () {
this.close(this._tabs.slice(1).map(x => x.id));
};
/**
* Undo tabs closing
*/
this.undoClose = async function () {
var historyEntry = this._history.pop();
if (historyEntry) {
let maxIndex = -1;
let openPromises = [];
for (let tab of historyEntry) {
if (Zotero.Items.exists(tab.data.itemID)) {
openPromises.push(Zotero.Reader.open(tab.data.itemID,
null,
{
tabIndex: tab.index,
openInBackground: true
}
));
if (tab.index > maxIndex) {
maxIndex = tab.index;
}
}
}
await Promise.all(openPromises);
// Select last reopened tab
if (maxIndex > -1) {
this.jump(maxIndex);
}
}
};
/**
* Move a tab to the specified index
*
* @param {String} id
* @param {Integer} newIndex
*/
this.move = function (id, newIndex) {
if (!Number.isInteger(newIndex) || newIndex < 1) {
throw new Error(`'newIndex' should be an interger > 0 (was ${newIndex} (${typeof newIndex})`);
}
var { tab, tabIndex } = this._getTab(id);
if (tabIndex == 0) {
throw new Error('Library tab cannot be moved');
}
if (!tab || tabIndex == newIndex) {
return;
}
if (newIndex > tabIndex) {
newIndex--;
}
this._tabs.splice(tabIndex, 1);
this._tabs.splice(newIndex, 0, tab);
this._update();
};
/**
* Select a tab
*
* @param {String} id
* @param {Boolean} reopening
*/
this.select = function (id, reopening, options = {}) {
var { tab, tabIndex } = this._getTab(id);
// Move focus to the last focused element of zoteroPane if any or itemTree otherwise
let focusZoteroPane = () => {
if (tab.id !== 'zotero-pane') return;
if (options.focusElementID) {
tab.lastFocusedElement = document.getElementById(options.focusElementID);
}
// Small delay to make sure the focus does not remain on the actual
// tab after mouse click
setTimeout(() => {
if (tab.lastFocusedElement) {
tab.lastFocusedElement.focus();
}
if (document.activeElement !== tab.lastFocusedElement) {
ZoteroPane_Local.itemsView.focus();
}
tab.lastFocusedElement = null;
});
};
if (!tab || tab.id === this._selectedID) {
// Focus on reader or zotero pane when keepTabFocused is explicitly false
// E.g. when a tab is selected via Space or Enter
if (options.keepTabFocused === false && tab?.id === this._selectedID) {
var reader = Zotero.Reader.getByTabID(this._selectedID);
if (reader) {
reader.focus();
}
if (tab.id == 'zotero-pane') {
focusZoteroPane();
}
}
return;
}
let selectedTab;
if (this._selectedID) {
selectedTab = this._getTab(this._selectedID).tab;
if (selectedTab) {
selectedTab.timeUnselected = Zotero.Date.getUnixTimestamp();
}
}
// If the last focus data was recorded for a different item, discard it
if (!this._focusOptions.itemID || this._focusOptions.itemID != tab?.data?.itemID) {
this._focusOptions = {};
}
// Save focus option for this item to tell reader and contextPane how to handle focus
if (Object.keys(options).length && selectedTab) {
this._focusOptions.keepTabFocused = !!options.keepTabFocused;
this._focusOptions.itemID = tab?.data?.itemID;
}
if (this._selectedID === 'zotero-pane'
&& !document.activeElement.classList.contains("tab")
&& document.activeElement.tagName !== 'window') {
// never return focus to another tab or <window>
selectedTab.lastFocusedElement = document.activeElement;
}
if (tab.type === 'reader-unloaded') {
this.close(tab.id);
Zotero.Reader.open(tab.data.itemID, options && options.location, {
tabID: tab.id,
title: tab.title,
tabIndex,
allowDuplicate: true,
secondViewState: tab.data.secondViewState,
preventJumpback: true
});
return;
}
this._prevSelectedID = reopening ? this._selectedID : null;
this._selectedID = id;
this.deck.selectedIndex = Array.from(this.deck.children).findIndex(x => x.id == id);
this._update();
Zotero.Notifier.trigger('select', 'tab', [tab.id], { [tab.id]: { type: tab.type } }, true);
if (tab.id === 'zotero-pane' && (options.keepTabFocused !== true)) {
focusZoteroPane();
}
let tabNode = document.querySelector(`.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') {
// Since there is more than one zotero-pane tab (pinned and not pinned),
// use moveFocus() to focus on the visible one
this.moveFocus('first');
}
else {
tabNode.focus();
}
}
// Allow React to create a tab node
setTimeout(() => {
tabNode.scrollIntoView({ behavior: 'smooth' });
});
// Border is not included when scrolling element node into view, therefore we do it manually.
// TODO: `scroll-padding` since Firefox 68 can probably be used instead
setTimeout(() => {
if (!tabNode) {
return;
}
let tabsContainerNode = document.querySelector('#tab-bar-container .tabs');
if (tabNode.offsetLeft + tabNode.offsetWidth - tabsContainerNode.offsetWidth + 1 >= tabsContainerNode.scrollLeft) {
document.querySelector('#tab-bar-container .tabs').scrollLeft += 1;
}
else if (tabNode.offsetLeft - 1 <= tabsContainerNode.scrollLeft) {
document.querySelector('#tab-bar-container .tabs').scrollLeft -= 1;
}
}, 500);
tab.timeSelected = Zotero.Date.getUnixTimestamp();
// Without `setTimeout` the tab closing that happens in `unloadUnusedTabs` results in
// tabs deck selection index bigger than the deck children count. It feels like something
// isn't update synchronously
setTimeout(() => this.unloadUnusedTabs());
};
this.unload = function (id) {
var { tab, tabIndex } = this._getTab(id);
if (!tab || tab.id === this._selectedID || tab.type !== 'reader') {
return;
}
this.close(tab.id);
this.add({
id: tab.id,
type: 'reader-unloaded',
title: tab.title,
index: tabIndex,
data: tab.data
});
};
this.unloadUnusedTabs = function () {
for (let tab of this._tabs) {
if (Zotero.Date.getUnixTimestamp() - tab.timeUnselected > UNLOAD_UNUSED_AFTER) {
this.unload(tab.id);
}
}
let tabs = this._tabs.slice().filter(x => x.type === 'reader');
tabs.sort((a, b) => b.timeUnselected - a.timeUnselected);
tabs = tabs.slice(MAX_LOADED_TABS);
for (let tab of tabs) {
this.unload(tab.id);
}
};
/**
* Select the previous tab (closer to the library tab)
*/
this.selectPrev = function (options) {
var { tabIndex } = this._getTab(this._selectedID);
this.select((this._tabs[tabIndex - 1] || this._tabs[this._tabs.length - 1]).id, false, options || {});
};
/**
* Select the next tab (farther to the library tab)
*/
this.selectNext = function (options) {
var { tabIndex } = this._getTab(this._selectedID);
this.select((this._tabs[tabIndex + 1] || this._tabs[0]).id, false, options || {});
};
/**
* Select the last tab
*/
this.selectLast = function () {
this.select(this._tabs[this._tabs.length - 1].id);
};
/**
* Return focus into the reader of the selected tab.
* Required to move focus from the tab into the reader after drag.
*/
this.refocusReader = function () {
var reader = Zotero.Reader.getByTabID(this._selectedID);
if (!reader) return;
setTimeout(() => {
reader.focus();
});
}
/**
* Moves focus to a tab in the specified direction.
* @param {String} direction. "first", "last", "left", "right", or "current"
* If document.activeElement is a tab, "left" or "right" direction moves focus from that tab.
* Otherwise, focus is moved in the given direction from the currently selected tab.
*/
this.moveFocus = function (direction) {
let focusedTabID = document.activeElement.getAttribute('data-id');
var { tabIndex } = this._getTab(this._selectedID);
let tabIndexToFocus = null;
if (direction === "last") {
tabIndexToFocus = this._tabs.length - 1;
}
else if (direction == "first") {
tabIndexToFocus = 0;
}
else if (direction == "current") {
tabIndexToFocus = tabIndex;
}
else {
let focusedTabIndex = this._tabs.findIndex(tab => tab.id === focusedTabID);
// If the currently focused element is not a tab, use tab that is selected
if (focusedTabIndex === -1) {
focusedTabIndex = tabIndex;
}
switch (direction) {
case "left":
tabIndexToFocus = focusedTabIndex > 0 ? focusedTabIndex - 1 : null;
break;
case "right":
tabIndexToFocus = focusedTabIndex < this._tabs.length - 1 ? focusedTabIndex + 1 : null;
break;
default:
throw new Error(`${direction} is an invalid direction.`);
}
}
if (tabIndexToFocus !== null) {
const nextTab = this._tabs[tabIndexToFocus];
// There may be duplicate tabs - in normal tab array and in pinned tabs
// So to get the right one, fetch all tabs with a given id and filter out one
// that's visible
let candidates = document.querySelectorAll(`[data-id="${nextTab.id}"]`);
for (let node of candidates) {
if (node.offsetParent) {
node.focus();
return;
}
}
}
};
/**
* Jump to the tab at a particular index. If the index points beyond the array, jump to the last
* tab.
*
* @param {Integer} index
*/
this.jump = function (index) {
this.select(this._tabs[Math.min(index, this._tabs.length - 1)].id);
};
this._openMenu = function (x, y, id) {
var { tab, tabIndex } = this._getTab(id);
let menuitem;
let popup = document.createXULElement('menupopup');
document.querySelector('popupset').appendChild(popup);
popup.addEventListener('popuphidden', function (event) {
if (event.target === popup) {
popup.remove();
}
});
if (id !== 'zotero-pane') {
// Show in library
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('general.showInLibrary'));
menuitem.addEventListener('command', () => {
let { tab } = this._getTab(id);
if (tab && (tab.type === 'reader' || tab.type === 'reader-unloaded')) {
let itemID = tab.data.itemID;
let item = Zotero.Items.get(itemID);
if (item && item.parentItemID) {
itemID = item.parentItemID;
}
ZoteroPane_Local.selectItem(itemID);
}
});
popup.appendChild(menuitem);
// Move tab
let menu = document.createXULElement('menu');
menu.setAttribute('label', Zotero.getString('tabs.move'));
let menupopup = document.createXULElement('menupopup');
menu.append(menupopup);
popup.appendChild(menu);
// Move to start
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('tabs.moveToStart'));
menuitem.setAttribute('disabled', tabIndex == 1);
menuitem.addEventListener('command', () => {
this.move(id, 1);
});
menupopup.appendChild(menuitem);
// Move to end
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('tabs.moveToEnd'));
menuitem.setAttribute('disabled', tabIndex == this._tabs.length - 1);
menuitem.addEventListener('command', () => {
this.move(id, this._tabs.length);
});
menupopup.appendChild(menuitem);
// Move to new window
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('tabs.moveToWindow'));
menuitem.setAttribute('disabled', false);
menuitem.addEventListener('command', () => {
let { tab } = this._getTab(id);
if (tab && (tab.type === 'reader' || tab.type === 'reader-unloaded')) {
this.close(id);
let { itemID, secondViewState } = tab.data;
Zotero.Reader.open(itemID, null, { openInWindow: true, secondViewState });
}
});
menupopup.appendChild(menuitem);
// Duplicate tab
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('tabs.duplicate'));
menuitem.addEventListener('command', () => {
if (tab.data.itemID) {
tabIndex++;
let { secondViewState } = tab.data;
Zotero.Reader.open(tab.data.itemID, null, { tabIndex, allowDuplicate: true, secondViewState });
}
});
popup.appendChild(menuitem);
// Separator
popup.appendChild(document.createXULElement('menuseparator'));
}
// Close
if (id != 'zotero-pane') {
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('general.close'));
menuitem.addEventListener('command', () => {
this.close(id);
});
popup.appendChild(menuitem);
}
// Close other tabs
if (!(this._tabs.length == 2 && id != 'zotero-pane')) {
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.getString('tabs.closeOther'));
menuitem.addEventListener('command', () => {
this.close(this._tabs.slice(1).filter(x => x.id != id).map(x => x.id));
});
popup.appendChild(menuitem);
}
// Undo close
menuitem = document.createXULElement('menuitem');
menuitem.setAttribute(
'label',
Zotero.getString(
'tabs.undoClose',
[],
// If not disabled, show proper plural for tabs to reopen
this._history.length ? this._history[this._history.length - 1].length : 1
)
);
menuitem.setAttribute('disabled', !this._history.length);
menuitem.addEventListener('command', () => {
this.undoClose();
});
popup.appendChild(menuitem);
popup.openPopupAtScreen(x, y, true);
};
// Used to move focus back to itemTree or contextPane from the tabs.
this.focusWrapAround = function () {
// Focus the last field of contextPane when reader is opened
if (Zotero_Tabs.selectedIndex > 0) {
Services.focus.moveFocus(window, document.getElementById("zotero-context-pane-sidenav"),
Services.focus.MOVEFOCUS_BACKWARD, 0);
return;
}
// Focus the last field of itemPane
// We do that by moving focus backwards from the element following the pane, because Services.focus doesn't
// support MOVEFOCUS_LAST on subtrees
Services.focus.moveFocus(window, document.getElementById("zotero-context-splitter"),
Services.focus.MOVEFOCUS_BACKWARD, 0);
};
/**
* @param {title} String - Tab's title
* @returns <description> with bold substrings of title matching this._tabsMenuFilter
*/
let createTabsMenuLabel = (title) => {
let desc = document.createElement('label');
let regex = new RegExp(`(${Zotero.Utilities.quotemeta(this._tabsMenuFilter)})`, 'gi');
let matches = title.matchAll(regex);
let lastIndex = 0;
for (let match of matches) {
if (match.index > lastIndex) {
// Add preceding text
desc.appendChild(document.createTextNode(title.substring(lastIndex, match.index)));
}
// Add matched text wrapped in <b>
if (match[0]) {
let b = document.createElement('b');
b.textContent = match[0];
desc.appendChild(b);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < title.length) {
// Add remaining text
desc.appendChild(document.createTextNode(title.substring(lastIndex)));
}
return desc;
};
this.isTabsMenuVisible = () => {
return ['showing', 'open'].includes(this.tabsMenuPanel.state);
};
/**
* Create the list of opened tabs in tabs menu.
*/
this.refreshTabsMenuList = () => {
// Empty existing nodes
this.tabsMenuList.replaceChildren();
this._tabsMenuFocusedIndex = 0;
let index = 1;
for (let tab of this._tabs) {
// Skip tabs whose title wasn't added yet
if (tab.title == "") {
continue;
}
// Filter tabs that do not match the filter
if (!tab.title.toLowerCase().includes(this._tabsMenuFilter)) {
continue;
}
// Top-level entry of the opened tabs array
let row = document.createElement('div');
let rowIndex = this._tabs.findIndex(x => x.id === tab.id);
row.classList = "row";
row.setAttribute("index", rowIndex);
row.setAttribute("draggable", true);
// Title of the tab
let tabName = document.createElement('div');
tabName.setAttribute('flex', '1');
tabName.setAttribute('class', 'zotero-tabs-menu-entry title');
tabName.setAttribute('tabindex', `${index++}`);
tabName.setAttribute('aria-label', tab.title);
tabName.setAttribute('title', tab.title);
// Cross button to close a tab
let closeButton = document.createElement('div');
closeButton.className = "zotero-tabs-menu-entry close";
let closeIcon = document.createElement('span');
closeIcon.setAttribute('class', 'icon icon-css icon-x-8 icon-16');
closeButton.setAttribute('data-l10n-id', 'zotero-tabs-menu-close-button');
closeButton.appendChild(closeIcon);
closeButton.addEventListener("click", () => {
// Keep the focus on the cross at the same spot
if (this._tabsMenuFocusedIndex == this.tabsMenuList.childElementCount * 2) {
this._tabsMenuFocusedIndex = Math.max(this._tabsMenuFocusedIndex - 2, 0);
}
this.close(tab.id);
});
// Library tab has no close button
if (tab.id == "zotero-pane") {
closeButton.hidden = true;
}
closeButton.setAttribute('tabindex', `${index++}`);
// Item type icon
let span = document.createElement("span");
span.className = "icon icon-css tab-icon";
if (tab.id == 'zotero-pane') {
// Determine which icon from the collection view rows to use (same as in _update())
let index = ZoteroPane.collectionsView?.selection?.focused;
if (typeof index !== 'undefined' && ZoteroPane.collectionsView.getRow(index)) {
let iconName = ZoteroPane.collectionsView.getIconName(index);
span.classList.add(`icon-${iconName}`);
}
}
else {
span.classList.add("icon-item-type");
let item = Zotero.Items.get(tab.data.itemID);
let dataTypeLabel = item.getItemTypeIconName(true);
span.setAttribute("data-item-type", dataTypeLabel);
}
tabName.appendChild(span);
// Actual label with bolded substrings matching the filter
let tabLabel = createTabsMenuLabel(tab.title, this._tabsMenuFilter);
tabName.appendChild(tabLabel);
// Selected tab is bold
if (tab.id == this._selectedID) {
tabName.classList.add('selected');
}
// Onclick, go to selected tab + close popup
tabName.addEventListener("click", () => {
this.tabsMenuPanel.hidePopup();
this.select(tab.id);
});
row.appendChild(tabName);
row.appendChild(closeButton);
row.addEventListener("dragstart", (e) => {
// No drag-drop on the cross button or the library tab
if (tab.id == 'zotero-pane' || e.target.classList.contains("close")) {
e.preventDefault();
e.stopPropagation();
return;
}
e.dataTransfer.setData('zotero/tab', tab.id);
setTimeout(() => {
row.classList.remove("hover");
row.setAttribute("id", "zotero-tabs-menu-dragged");
});
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
let tabId = e.dataTransfer.getData("zotero/tab");
if (!tabId || tab.id == "zotero-pane") {
return false;
}
if (row.getAttribute("id") == "zotero-tabs-menu-dragged") {
return true;
}
let placeholder = document.getElementById("zotero-tabs-menu-dragged");
if (row.previousSibling?.id == placeholder.id) {
// If the placeholder exists before the row, swap the placeholder and the row
row.parentNode.insertBefore(row, placeholder);
placeholder.setAttribute("index", parseInt(row.getAttribute("index")) + 1);
}
else {
// Insert placeholder before the row
row.parentNode.insertBefore(placeholder, row);
placeholder.setAttribute("index", parseInt(row.getAttribute("index")));
}
return false;
});
row.addEventListener('drop', (e) => {
let tabId = e.dataTransfer.getData("zotero/tab");
let rowIndex = parseInt(row.getAttribute("index"));
if (rowIndex == 0) return;
this.move(tabId, rowIndex);
});
row.addEventListener('dragend', (_) => {
// If this.move() wasn't called, just re-render the menu
if (document.getElementById("zotero-tabs-menu-dragged")) {
this.refreshTabsMenuList();
}
});
this.tabsMenuList.appendChild(row);
}
};
this.showTabsMenu = function (button) {
this.tabsMenuPanel.openPopup(button, "after_start", -20, -2, false, false);
};
this.handleTabsMenuHiding = function (event) {
if (event.originalTarget.id != 'zotero-tabs-menu-panel') return;
// Empty out the filter input field
let menuFilter = document.getElementById('zotero-tabs-menu-filter');
menuFilter.value = "";
this._tabsMenuFilter = "";
this.tabsMenuList.closest("panel").style.removeProperty('max-height');
};
this.handleTabsMenuShown = function (_) {
focusTabsMenuEntry(0);
};
this.handleTabsMenuShowing = function (_) {
this.refreshTabsMenuList();
// Make sure that if the menu is very long, there is a small
// gap left between the top/bottom of the menu and the edge of the screen
let valuesAreWithinMargin = (valueOne, valueTwo, margin) => {
return Math.abs(valueOne - valueTwo) <= margin;
};
let panel = document.getElementById("zotero-tabs-menu-panel");
let panelRect = panel.getBoundingClientRect();
const gapBeforeScreenEdge = 25;
let absoluteTabsMenuTop = window.screenY - panelRect.height + panelRect.bottom;
let absoluteTabsMenuBottom = window.screenY + panelRect.height + panelRect.top;
// On windows, getBoundingClientRect does not give us correct top and bottom values
// until popupshown, so instead use the anchor's position
if (Zotero.isWin) {
let anchor = document.getElementById("zotero-tb-tabs-menu");
let anchorRect = anchor.getBoundingClientRect();
absoluteTabsMenuTop = window.screenY - panelRect.height + anchorRect.top;
absoluteTabsMenuBottom = window.screenY + panelRect.height + anchorRect.bottom;
}
// screen.availTop is not always right on Linux, so ignore it
let availableTop = Zotero.isLinux ? 0 : screen.availTop;
// Check if the end of the tabs menu is close to the edge of the screen
let atTopScreenEdge = valuesAreWithinMargin(absoluteTabsMenuTop, availableTop, gapBeforeScreenEdge);
let atBottomScreenEdge = valuesAreWithinMargin(absoluteTabsMenuBottom, screen.availHeight + availableTop, gapBeforeScreenEdge);
let gap;
// Limit max height of the menu to leave the specified gap till the screen's edge.
// Due to screen.availTop behavior on linux, the menu can go outside of what is supposed
// to be the available screen area, so special treatment for those edge cases.
if (atTopScreenEdge || (Zotero.isLinux && absoluteTabsMenuTop < 0)) {
gap = gapBeforeScreenEdge - (absoluteTabsMenuTop - availableTop);
}
if (atBottomScreenEdge || (Zotero.isLinux && absoluteTabsMenuBottom > screen.availHeight)) {
gap = gapBeforeScreenEdge - (screen.availHeight + availableTop - absoluteTabsMenuBottom);
}
if (gap) {
panel.style.maxHeight = `${panelRect.height - gap}px`;
}
// Try to scroll selected tab into the center
let selectedTab = this.tabsMenuList.querySelector(".selected");
if (selectedTab) {
selectedTab.scrollIntoView({ block: 'center' });
}
};
/**
* Record the value of the filter
*/
this.handleTabsMenuFilterInput = function (_, input) {
if (this._tabsMenuFilter == input.value.toLowerCase()) {
return;
}
this._tabsMenuFilter = input.value.toLowerCase();
this.refreshTabsMenuList();
};
this.resetFocusIndex = (_) => {
this._tabsMenuFocusedIndex = 0;
};
/**
* Focus on the element in the tabs menu with [tabindex=tabIndex] if given
* or [tabindex=this._tabsMenuFocusedIndex] otherwise
*/
let focusTabsMenuEntry = (tabIndex = null) => {
tabIndex = tabIndex !== null ? tabIndex : this._tabsMenuFocusedIndex;
if (tabIndex === null) {
return;
}
var nextTab = this.tabsMenuList.parentElement.querySelector(`[tabindex="${tabIndex}"]`);
if (!nextTab) {
return;
}
this._tabsMenuIgnoreMouseover = true;
this._tabsMenuFocusedIndex = tabIndex;
let hovered = this.tabsMenuList.querySelector(".hover");
if (hovered) {
hovered.classList.remove("hover");
}
nextTab.focus();
// For some reason (likely a mozilla bug),
// a mouseover event fires at the location where the drag event started after the drop.
// To not mark the wrong entry as hovered, ignore mouseover events for a bit after the focus change
setTimeout(() => {
this._tabsMenuIgnoreMouseover = false;
}, 250);
};
/**
* Keyboard navigation within the tabs menu
* - Tab/Shift-Tab moves focus from the input field across tab titles and close buttons
* - Enter from the input field focuses the first tab
* - Enter on a toolbarbutton clicks it
* - ArrowUp/ArrowDown on a toolbarbutton moves focus to the next/previous toolbarbutton of the
* same type (e.g. arrowDown from title focuses the next title)
* - ArrowUp from the first tab or ArrowDown from the last tab focuses the filter field
* - ArrowDown from the filter field focuses the first tab
* - ArrowUp from the filter field focuses the last tab
* - Home/PageUp focuses the filter field
* - End/PageDown focues the last tab title
* - CMD-f will focus the input field
*/
this.handleTabsMenuKeyPress = function (event) {
let tabindex = this._tabsMenuFocusedIndex;
if (event.key == "Tab") {
event.preventDefault();
let isShift = event.shiftKey;
let moveTabIndex = () => tabindex++;
if (isShift) {
moveTabIndex = () => tabindex--;
}
moveTabIndex();
let candidate = this.tabsMenuList.parentElement.querySelector(`[tabindex="${tabindex}"]`);
// If the candidate is hidden (e.g. close button of library tab), skip it
if (candidate && candidate.hidden) {
moveTabIndex();
}
focusTabsMenuEntry(tabindex);
}
else if (["Home", "PageUp"].includes(event.key)) {
event.preventDefault();
focusTabsMenuEntry(0);
}
else if (["End", "PageDown"].includes(event.key)) {
event.preventDefault();
focusTabsMenuEntry(this.tabsMenuList.childElementCount * 2 - 1);
}
else if (["ArrowUp", "ArrowDown"].includes(event.key)) {
event.preventDefault();
let isFirstRow = tabindex <= 2 && tabindex > 0;
// Step over 1 index to jump over close button, unless we move
// from the filter field
let step = tabindex == 0 ? 1 : 2;
if (event.key == "ArrowDown") {
tabindex += step;
}
else {
tabindex -= step;
}
// If the candidate is a disabled element (e.g. close button of the library tab),
// move focus to the element before it
let candidate = this.tabsMenuList.parentElement.querySelector(`[tabindex="${tabindex}"]`);
if (candidate && candidate.disabled) {
tabindex--;
}
if (tabindex <= 0) {
// ArrowUp from the first tab or the first close button focuses the filter field.
// ArrowUp from the filter field focuses the last tab
if (isFirstRow) {
tabindex = 0;
}
else {
tabindex = this.tabsMenuList.childElementCount * 2 - 1;
}
}
// ArrowDown from the bottom focuses the filter field
if (tabindex > this.tabsMenuList.childElementCount * 2) {
tabindex = 0;
}
focusTabsMenuEntry(tabindex);
}
else if (["Enter", " "].includes(event.key)) {
event.preventDefault();
if (event.target.id == "zotero-tabs-menu-filter") {
focusTabsMenuEntry(1);
return;
}
event.target.click();
}
else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
}
else if (event.key == "f" && (Zotero.isMac ? event.metaKey : event.ctrlKey)) {
focusTabsMenuEntry(0);
event.preventDefault();
event.stopPropagation();
}
};
};