diff --git a/chrome/content/zotero/elements/collapsibleSection.js b/chrome/content/zotero/elements/collapsibleSection.js index b60ef1af19..9a5c5b92c9 100644 --- a/chrome/content/zotero/elements/collapsibleSection.js +++ b/chrome/content/zotero/elements/collapsibleSection.js @@ -334,27 +334,6 @@ event.stopPropagation(); }; - // Tab/Shift-Tab from section header through header buttons - if (event.key === "Tab") { - let nextBtn; - if (tgt.classList.contains("head") && event.shiftKey) { - return; - } - if (tgt.classList.contains("head")) { - nextBtn = this._head.querySelector("toolbarbutton"); - } - else { - nextBtn = event.shiftKey ? tgt.previousElementSibling : tgt.nextElementSibling; - } - - if (nextBtn?.tagName == "popupset") { - nextBtn = this._head; - } - if (nextBtn) { - nextBtn.focus(); - stopEvent(); - } - } if (event.target.tagName === "toolbarbutton") { // No actions on right/left on header buttons if (["ArrowRight", "ArrowLeft"].includes(event.key)) { diff --git a/chrome/content/zotero/elements/editableText.js b/chrome/content/zotero/elements/editableText.js index d6cbf2c488..0b42850b89 100644 --- a/chrome/content/zotero/elements/editableText.js +++ b/chrome/content/zotero/elements/editableText.js @@ -193,6 +193,14 @@ input.addEventListener('mousedown', this._handleMouseDown); input.addEventListener('dragover', this._handleDragOver); input.addEventListener('drop', this._handleDrop); + if (autocompleteEnabled) { + // Even through this may run multiple times on editable-text, the listener + // is added only once because we pass the reference to the same exact function. + this.addEventListener('keydown', this._captureAutocompleteKeydown, true); + } + else { + this.removeEventListener('keydown', this._captureAutocompleteKeydown, true); + } let focused = this.focused; let selectionStart = this._input?.selectionStart; @@ -333,18 +341,37 @@ if (event.key === 'Enter') { if (this.multiline === event.shiftKey) { event.preventDefault(); - this.dispatchEvent(new CustomEvent('escape_enter')); this._input.blur(); } + // Do not let out shift-enter event on multiline, since it should never do + // anything but add a linebreak to textarea + if (this.multiline && !event.shiftKey) { + event.stopPropagation(); + } } else if (event.key === 'Escape') { - this.dispatchEvent(new CustomEvent('escape_enter')); let initialValue = this._input.dataset.initialValue ?? ''; this.setAttribute('value', initialValue); this._input.value = initialValue; this._input.blur(); } }; + + _captureAutocompleteKeydown = (event) => { + // On Enter or Escape, mozilla stops propagation of the event which may interfere with out handling + // of the focus. E.g. the event should be allowed to reach itemDetails from itemBox so that focus + // can be moved to the itemTree or the reader. + // https://searchfox.org/mozilla-central/source/toolkit/content/widgets/autocomplete-input.js#564 + // To avoid it, capture Enter and Escape keydown events and handle them without stopping propagation. + if (this._input.autocomplete !== "on" || !["Enter", "Escape"].includes(event.key)) return; + event.preventDefault(); + if (event.key == "Enter") { + this._input.handleEnter(); + } + else if (event.key == "Escape") { + this._input.mController.handleEscape(); + } + }; _handleMouseDown = (event) => { this.setAttribute("mousedown", true); diff --git a/chrome/content/zotero/elements/itemBox.js b/chrome/content/zotero/elements/itemBox.js index 279d2910b7..c0d73f1af6 100644 --- a/chrome/content/zotero/elements/itemBox.js +++ b/chrome/content/zotero/elements/itemBox.js @@ -47,11 +47,8 @@ this._editableFields = []; this._fieldAlternatives = {}; this._fieldOrder = []; - this._tabIndexMinCreators = 100; - this._tabIndexMaxFields = 0; this._initialVisibleCreators = 5; this._draggedCreator = false; - this._ztabindex = 0; this._selectField = null; } @@ -116,7 +113,6 @@ ); typeBox.setAttribute('typeid', typeID); - this._lastTabIndex = -1; this.modifyCreator(index, fields); if (this.saveOnEdit) { await this.blurOpenField(); @@ -226,6 +222,23 @@ () => Zotero.Utilities.Internal.copyTextToClipboard(this._linkMenu.dataset.link) ); + // Save the last focused element, so that the focus can go back to it if + // the table is refreshed + this._infoTable.addEventListener("focusin", (e) => { + let target = e.target.closest("[fieldname], [tabindex], [focusable]"); + if (target?.id) { + this._selectField = target.id; + } + }); + + // If the focus leaves the itemBox, clear the last focused element + this._infoTable.addEventListener("focusout", (e) => { + let destination = e.relatedTarget; + if (!(destination && this._infoTable.contains(destination))) { + this._selectField = null; + } + }); + this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'itemBox'); Zotero.Prefs.registerObserver('fontSize', () => { this._forceRenderAll(); @@ -327,7 +340,6 @@ } this._item = val; - this._lastTabIndex = null; this.scrollToTop(); } @@ -457,14 +469,6 @@ if (id != this.item.id) { continue; } - let activeArea = this.getFocusedTextArea(); - // Re-select currently active area after refresh. - if (activeArea) { - this._selectField = activeArea.getAttribute("fieldname"); - } - if (document.activeElement == this.itemTypeMenu) { - this._selectField = "item-type-menu"; - } this._forceRenderAll(); break; } @@ -484,8 +488,6 @@ if (this._isAlreadyRendered()) return; - // Init tab index to begin after all creator rows - this._ztabindex = this._tabIndexMinCreators * (this.item.numCreators() || 1); delete this._linkMenu.dataset.link; // @@ -498,13 +500,6 @@ // Item type menu this.addItemTypeMenu(); this.updateItemTypeMenuSelection(); - this.itemTypeMenu.disabled = !this.showTypeMenu; - // Re-focus item type menu if it was focused before refresh - if (this._selectField == "item-type-menu") { - Services.focus.setFocus(this.itemTypeMenu, Services.focus.FLAG_SHOWRING); - this._selectField = null; - this._lastTabIndex = null; - } var fieldNames = []; // Manual field order @@ -653,7 +648,6 @@ if (!(Zotero.ItemFields.isLong(fieldName) || Zotero.ItemFields.isMultiline(fieldName))) { optionsButton.classList.add("no-display"); } - optionsButton.setAttribute("ztabindex", ++this._ztabindex); optionsButton.setAttribute('data-l10n-id', "itembox-button-options"); // eslint-disable-next-line no-loop-func let triggerPopup = (e) => { @@ -672,20 +666,14 @@ }; // Same popup triggered for right-click and options button click optionsButton.addEventListener("click", triggerPopup); - optionsButton.addEventListener('keypress', event => this.handleKeyPress(event)); rowData.appendChild(optionsButton); rowData.oncontextmenu = triggerPopup; } this.addDynamicRow(rowLabel, rowData); - if (fieldName && this._selectField == fieldName) { - valueElement.focus(); - this._selectField = null; - } - // In field merge mode, add a button to switch field versions - else if (this.mode == 'fieldmerge' && typeof this._fieldAlternatives[fieldName] != 'undefined') { + if (this.mode == 'fieldmerge' && typeof this._fieldAlternatives[fieldName] != 'undefined') { var button = document.createXULElement("toolbarbutton"); button.className = 'zotero-field-version-button zotero-clicky-merge'; button.setAttribute('type', 'menu'); @@ -715,13 +703,11 @@ rowData.appendChild(button); } } - this._tabIndexMaxFields = this._ztabindex; // Save the last tab index // // Creators // - this._ztabindex = 1; // Reset tab index to 1, since creators go before other fields // Creator type menu if (this.editable) { while (this._creatorTypeMenu.hasChildNodes()) { @@ -778,11 +764,6 @@ for (let i = 0; i < max; i++) { let data = this.item.getCreator(i); this.addCreatorRow(data, data.creatorTypeID); - - // Display "+" button on all but last row - if (i == max - 2) { - this.disableCreatorAddButtons(); - } } if (this._draggedCreator) { this._draggedCreator = false; @@ -802,8 +783,6 @@ // Additional creators not displayed if (num > max) { this.addMoreCreatorsRow(num - max); - - this.disableCreatorAddButtons(); } else { // If we didn't start with creators truncated, @@ -815,20 +794,14 @@ if (this._addCreatorRow) { this.addCreatorRow(false, this.item.getCreator(max - 1).creatorTypeID, true); this._addCreatorRow = false; - this.disableCreatorAddButtons(); } } } else if (this.editable && Zotero.CreatorTypes.itemTypeHasCreators(this.item.itemTypeID)) { // Add default row this.addCreatorRow(false, false, true, true); - this.disableCreatorAddButtons(); } - // Move to next or previous field if (shift-)tab was pressed - if (this._lastTabIndex && this._lastTabIndex != -1) { - this._focusNextField(this._lastTabIndex); - } if (this._showCreatorTypeGuidance) { let creatorTypeLabels = this.querySelectorAll(".creator-type-label"); @@ -866,11 +839,16 @@ } }); } - this._refreshed = true; - // Add tabindex=0 to all focusable element - this.querySelectorAll("[ztabindex]").forEach((node) => { - node.setAttribute("tabindex", 0); - }); + + this._ensureButtonsFocusable(); + + // Set focus on the last focused field + if (this._selectField) { + let refocusField = this.querySelector(`#${this._selectField}`); + if (refocusField) { + refocusField.focus(); + } + } // Make sure that any opened popup closes this.querySelectorAll("menupopup").forEach((popup) => { popup.hidePopup(); @@ -896,24 +874,27 @@ else { var menulist = document.createXULElement("menulist", { is: "menulist-item-types" }); menulist.id = "item-type-menu"; - menulist.className = "zotero-clicky"; + menulist.className = "zotero-clicky keyboard-clickable"; menulist.addEventListener('command', (event) => { this.changeTypeTo(event.target.value, menulist); }); menulist.addEventListener('focus', () => { this.ensureElementIsVisible(menulist); }); - menulist.addEventListener('keypress', (event) => { - if (event.keyCode == event.DOM_VK_TAB) { - this.handleKeyPress(event); + // This is instead of setting disabled=true so that the menu is not excluded + // from tab navigation. For s, we just set readonly=true but it is not + // a valid property for menulist. + menulist.addEventListener("popupshowing", (e) => { + if (!this._editable) { + e.preventDefault(); + e.stopPropagation(); } }); menulist.setAttribute("aria-labelledby", "itembox-field-itemType-label"); this.itemTypeMenu = menulist; rowData.appendChild(menulist); } - this.itemTypeMenu.setAttribute('ztabindex', '1'); - this.itemTypeMenu.disabled = !this.showTypeMenu; + this.itemTypeMenu.setAttribute("aria-disabled", !this._editable); row.appendChild(labelWrapper); row.appendChild(rowData); this._infoTable.appendChild(row); @@ -967,8 +948,10 @@ let labelWrapper = document.createElement('div'); let grippy = document.createXULElement('toolbarbutton'); - labelWrapper.className = 'creator-type-label'; + labelWrapper.className = 'creator-type-label keyboard-clickable'; + labelWrapper.setAttribute("tabindex", 0); grippy.className = "zotero-clicky zotero-clicky-grippy show-on-hover"; + grippy.setAttribute('tabindex', -1); rowLabel.appendChild(grippy); if (this.editable) { @@ -989,7 +972,7 @@ labelWrapper.setAttribute('role', 'button'); labelWrapper.setAttribute('aria-describedby', 'creator-type-label-inner'); - labelWrapper.setAttribute('ztabindex', ++this._ztabindex); + labelWrapper.setAttribute('id', `creator-${rowIndex}-label`); // If not editable or only 1 creator row, hide grippy if (!this.editable || this.item.numCreators() < 2) { @@ -1016,7 +999,6 @@ this.createValueElement( lastName, fieldName, - ++this._ztabindex ) ); @@ -1026,7 +1008,6 @@ this.createValueElement( firstName, fieldName, - ++this._ztabindex ) ); firstNameElem.placeholder = this._defaultFirstName; @@ -1039,41 +1020,27 @@ // Minus (-) button var removeButton = document.createXULElement('toolbarbutton'); removeButton.setAttribute("class", "zotero-clicky zotero-clicky-minus show-on-hover no-display"); - removeButton.setAttribute('ztabindex', ++this._ztabindex); + removeButton.setAttribute('id', `creator-${rowIndex}-remove`); removeButton.setAttribute('tooltiptext', Zotero.getString('general.delete')); - // If default first row, don't let user remove it - if (defaultRow || !this.editable) { - this.disableButton(removeButton); - } - else { - removeButton.addEventListener("click", () => { - this.removeCreator(rowIndex, rowData.parentNode); - }); - } + removeButton.addEventListener("command", () => this.removeCreator(rowIndex, rowData.parentNode)); rowData.appendChild(removeButton); // Plus (+) button var addButton = document.createXULElement('toolbarbutton'); addButton.setAttribute("class", "zotero-clicky zotero-clicky-plus show-on-hover no-display"); - addButton.setAttribute('ztabindex', ++this._ztabindex); + addButton.setAttribute('id', `creator-${rowIndex}-add`); addButton.setAttribute('tooltiptext', Zotero.getString('general.create')); - // If row isn't saved, don't let user add more - if (unsaved || !this.editable) { - this.disableButton(addButton); - } - else { - this._enablePlusButton(addButton, typeID, fieldMode); - } + addButton.addEventListener("command", () => this.addCreatorRow(null, typeID, true)); rowData.appendChild(addButton); // Options button that opens creator transform menu let optionsButton = document.createXULElement("toolbarbutton"); if (!this.editable) { optionsButton.style.visibility = "hidden"; - this.disableButton(optionsButton); + optionsButton.disabled = true; } optionsButton.className = "zotero-clicky zotero-clicky-options show-on-hover no-display"; - optionsButton.setAttribute('ztabindex', ++this._ztabindex); + optionsButton.setAttribute('id', `creator-${rowIndex}-options`); optionsButton.setAttribute('data-l10n-id', "itembox-button-options"); let triggerPopup = (e) => { document.popupNode = firstlast; @@ -1086,28 +1053,18 @@ rowData.appendChild(optionsButton); if (this.editable) { - optionsButton.addEventListener("click", triggerPopup); + optionsButton.addEventListener("command", triggerPopup); rowData.oncontextmenu = triggerPopup; } - - if (!this.preventFocus) { - for (const domEl of [labelWrapper, removeButton, addButton, optionsButton]) { - domEl.setAttribute('tabindex', '0'); - domEl.addEventListener('keypress', this.handleKeyPress.bind(this)); - domEl.addEventListener('focusin', this.updateLastFocused.bind(this)); - } - } this._creatorCount++; - if (!this.editable) { - removeButton.hidden = true; - addButton.hidden = true; - optionsButton.hidden = true; - } let row = this.addDynamicRow(rowLabel, rowData, true); + this._updateCreatorButtonsStatus(); + this._ensureButtonsFocusable(); + /** * Events handling creator drag-drop reordering */ @@ -1173,6 +1130,8 @@ this.addAutocompleteToElement(lastNameElem); this.addAutocompleteToElement(firstNameElem); + row.addEventListener("keydown", e => this.handleCreatorRowKeyDown(e)); + lastNameElem.addEventListener("paste", e => this.handleCreatorPaste(e)); // Focus new rows if (unsaved && !defaultRow) { lastNameElem.focus(); @@ -1186,7 +1145,7 @@ var rowData = document.createElement('div'); rowData.className = "meta-data"; rowData.id = 'more-creators-label'; - rowData.setAttribute("ztabindex", ++this._ztabindex); + rowData.setAttribute("tabindex", 0); rowData.addEventListener('click', () => { this._displayAllCreators = true; this._forceRenderAll(); @@ -1248,10 +1207,6 @@ delete lastName.style.width; delete lastName.style.maxWidth; - // Remove firstname field from tabindex - tab = parseInt(firstName.getAttribute('ztabindex')); - firstName.setAttribute('ztabindex', -1); - // Hide first name field and prepend to last name field firstName.hidden = true; @@ -1264,13 +1219,6 @@ } // Clear first name value after it was moved to the full name field firstName.value = ""; - - // If one of the creator fields is open, leave it open after swap - let activeField = this.getFocusedTextArea(); - if (activeField == firstName || activeField == lastName) { - this._lastTabIndex = parseInt(lastName.getAttribute('ztabindex')); - this._tabDirection = false; - } } // Switch to two-field mode else { @@ -1278,9 +1226,6 @@ lastName.setAttribute('fieldMode', '0'); lastName.placeholder = this._defaultLastName; - // Add firstname field to tabindex - tab = parseInt(lastName.getAttribute('ztabindex')); - firstName.setAttribute('ztabindex', tab + 1); if (!initial) { // Move all but last word to first name field and show it @@ -1426,26 +1371,15 @@ return false; } - disableButton(button) { - button.setAttribute('disabled', true); - button.setAttribute('onclick', false); - } - - _enablePlusButton(button, creatorTypeID, _fieldMode) { - button.removeAttribute('disabled'); - button.onclick = () => { - this.disableButton(button); - this.addCreatorRow(null, creatorTypeID, true); - }; - } - - disableCreatorAddButtons() { - // Disable the "+" button on all creator rows - var elems = this._infoTable.getElementsByClassName('zotero-clicky-plus'); - for (let elem of elems) { - this.disableButton(elem); - } + // Toolbarbuttons required tabindex=0 to be properly focusable via tab + _ensureButtonsFocusable() { + this.querySelectorAll("toolbarbutton").forEach((btn) => { + if (!btn.getAttribute('tabindex')) { + btn.setAttribute("tabindex", 0); + } + }); } + createOpenLinkIcon(value) { // In duplicates/trash mode return nothing @@ -1455,8 +1389,6 @@ let openLink = document.createXULElement("toolbarbutton"); openLink.className = "zotero-clicky zotero-clicky-open-link show-on-hover no-display"; openLink.addEventListener("click", event => ZoteroPane.loadURI(value, event)); - openLink.addEventListener('keypress', event => this.handleKeyPress(event)); - openLink.setAttribute("ztabindex", ++this._ztabindex); openLink.setAttribute('data-l10n-id', "item-button-view-online"); return openLink; } @@ -1485,23 +1417,15 @@ if (this._fieldIsClickable(fieldName)) { valueElement.addEventListener("focus", e => this.showEditor(e.target)); valueElement.addEventListener("blur", e => this.hideEditor(e.target)); - valueElement.addEventListener("escape_enter", (_) => { - setTimeout(() => { - document.getElementById('item-tree-main-default').focus(); - }); - }); } else { valueElement.setAttribute('readonly', true); } - valueElement.setAttribute('ztabindex', ++this._ztabindex); valueElement.setAttribute('id', `itembox-field-value-${fieldName}`); valueElement.setAttribute('fieldname', fieldName); valueElement.setAttribute('tight', true); - valueElement.addEventListener("focus", e => this.updateLastFocused(e)); - valueElement.addEventListener("keypress", e => this.handleKeyPress(e)); switch (fieldName) { case 'itemType': valueElement.setAttribute('itemTypeID', valueText); @@ -1557,18 +1481,19 @@ return valueElement; } - async removeCreator(index, labelToDelete) { + async removeCreator(index, creatorRow) { + // Move focus to another creator row + if (creatorRow.contains(document.activeElement)) { + let nextCreatorIndex = index ? index - 1 : 0; + this._selectField = `itembox-field-value-creator-${nextCreatorIndex}-lastName`; + } // If unsaved row, just remove element if (!this.item.hasCreatorAt(index)) { - labelToDelete.parentNode.removeChild(labelToDelete); - - // Enable the "+" button on the previous row - var elems = this._infoTable.getElementsByClassName('zotero-clicky-plus'); - var button = elems[elems.length - 1]; - var creatorFields = this.getCreatorFields(button.closest('.meta-row')); - this._enablePlusButton(button, creatorFields.creatorTypeID, creatorFields.fieldMode); + creatorRow.remove(); this._creatorCount--; + this.querySelector(`#${this._selectField}`)?.focus(); + this._updateCreatorButtonsStatus(); return; } await this.blurOpenField(); @@ -1676,93 +1601,19 @@ // https://searchfox.org/mozilla-central/rev/2d678a843ceab81e43f7ffb83212197dc10e944a/toolkit/content/widgets/autocomplete-input.js#372 // https://searchfox.org/mozilla-central/rev/2d678a843ceab81e43f7ffb83212197dc10e944a/browser/components/search/content/searchbar.js#791 elem.onTextEntered = () => { - this.handleCreatorAutoCompleteSelect(elem, true); + this.handleCreatorAutoCompleteSelect(elem); }; // Tab/Shift-Tab elem.addEventListener('change', () => { - this.handleCreatorAutoCompleteSelect(elem, true); + this.handleCreatorAutoCompleteSelect(elem); }); - - if (creatorField == 'lastName') { - elem.addEventListener('paste', (event) => { - let lastName = event.clipboardData.getData('text').trim(); - // Handle \n\r and \n delimited entries and a single line containing a tab - var rawNameArray = lastName.split(/\r\n?|\n/); - if (rawNameArray.length > 1 || rawNameArray[0].includes('\t')) { - // Pasting multiple authors; first make sure we prevent normal paste behavior - event.preventDefault(); - - // Save tab direction and add creator flags since they are reset in the - // process of adding multiple authors - var tabDirectionBuffer = this._tabDirection; - var addCreatorRowBuffer = this._addCreatorRow; - var tabIndexBuffer = this._lastTabIndex; - this._tabDirection = false; - this._addCreatorRow = false; - - // Filter out bad names - var nameArray = rawNameArray.filter(name => name); - - // If not adding names at the end of the creator list, make new creator - // entries and then shift down existing creators. - var initNumCreators = this.item.numCreators(); - var creatorsToShift = initNumCreators - creatorIndex; - if (creatorsToShift > 0) { - // Add extra creators with dummy values - for (let i = 0; i < nameArray.length; i++) { - this.modifyCreator(i + initNumCreators, { - firstName: '', - lastName: '', - fieldMode: 0, - creatorTypeID - }); - } - - // Shift existing creators - for (let i = initNumCreators - 1; i >= creatorIndex; i--) { - let shiftedCreatorData = this.item.getCreator(i); - this.item.setCreator(nameArray.length + i, shiftedCreatorData); - } - } - - let currentIndex = creatorIndex; - let newCreator = { creatorTypeID }; - // Add the creators in lastNameArray one at a time - for (let tempName of nameArray) { - // Check for tab to determine creator name format - newCreator.fieldMode = (tempName.indexOf('\t') == -1) ? 1 : 0; - if (newCreator.fieldMode == 0) { - newCreator.lastName = tempName.split('\t')[0]; - newCreator.firstName = tempName.split('\t')[1]; - } - else { - newCreator.lastName = tempName; - newCreator.firstName = ''; - } - this.modifyCreator(currentIndex, newCreator); - currentIndex++; - } - this._tabDirection = tabDirectionBuffer; - this._addCreatorRow = (creatorsToShift == 0) ? addCreatorRowBuffer : false; - if (this._tabDirection == 1) { - this._lastTabIndex = tabIndexBuffer + 2 * (nameArray.length - 1); - if (newCreator.fieldMode == 0) { - this._lastTabIndex++; - } - } - - if (this.saveOnEdit) { - this.item.saveTx(); - } - } - }); - } } elem.autocomplete = { completeSelectedIndex: true, ignoreBlurWhileSearching: false, search: 'zotero', - searchParam: JSON.stringify(params) + searchParam: JSON.stringify(params), + popup: 'PopupAutoComplete', }; } @@ -1771,7 +1622,7 @@ * Save a multiple-field selection for the creator autocomplete * (e.g. "Shakespeare, William") */ - handleCreatorAutoCompleteSelect(textbox, stayFocused) { + handleCreatorAutoCompleteSelect(textbox) { let inputField = textbox.querySelector("input"); if (!inputField) { return; @@ -1791,7 +1642,7 @@ if (!id) { return; } - + var [creatorID, numFields] = id.split('-'); // If result uses two fields, save both @@ -1803,11 +1654,6 @@ var [_field, creatorIndex, creatorField] = textbox.getAttribute('fieldname').split('-'); - if (stayFocused) { - this._lastTabIndex = parseInt(textbox.getAttribute('ztabindex')); - this._tabDirection = false; - } - var creator = Zotero.Creators.get(creatorID); var otherField = creatorField == 'lastName' ? 'firstName' : 'lastName'; @@ -1844,93 +1690,107 @@ // Otherwise let the autocomplete popup handle matters } - handleKeyPress(event) { - var target = event.target; - if ((event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === ' ') - && target.classList.contains('zotero-clicky')) { - event.preventDefault(); - target.click(); + // Handle Shift-Enter on creator input field + handleCreatorRowKeyDown(event) { + let target = event.target.closest("editable-text"); + if (!target) return; - setTimeout(() => { - this._creatorTypeMenu.dispatchEvent( - new KeyboardEvent("keydown", { key: 'ArrowDown', keyCode: 40, charCode: 0 }) - ); - }, 0); - return; + if (event.key == "Enter" && event.shiftKey) { + event.stopPropagation(); + console.log("Target ", target); + // Value has changed - focus empty creator row at the bottom + if (target.initialValue != target.value) { + this._addCreatorRow = true; + this.blurOpenField(); + return; + } + // Value hasn't changed + Zotero.debug("Value hasn't changed"); + let row = target.closest('.meta-row'); + // Next row is a creator - focus that + let nextRow = row.nextSibling; + if (nextRow.querySelector(".creator-type-value")) { + nextRow.querySelector("editable-text").focus(); + return; + } + // Next row is a "More creators" label - click that + let moreCreators = nextRow.querySelector("#more-creators-label"); + if (moreCreators) { + moreCreators.click(); + this._selectField = `itembox-field-value-creator-${this._creatorCount}-lastName`; + } + var creatorFields = this.getCreatorFields(row); + // Do nothing from the last empty row + if (creatorFields.lastName == "" && creatorFields.firstName == "") return; + this.addCreatorRow(false, creatorFields.creatorTypeID, true); + this._selectField = `itembox-field-value-creator-${this._creatorCount - 1}-lastName`; } + } - let tree; - switch (event.key) { - case "Enter": - var valueField = target.closest("editable-text"); - if (!valueField) { - return; + // Handle adding multiple creator rows via paste + handleCreatorPaste(event) { + let target = event.target.closest('editable-text'); + var fieldName = target.getAttribute('fieldname'); + let creatorTypeID = parseInt( + target.closest('.meta-row').querySelector('.meta-label').getAttribute('typeid') + ); + var [field, creatorIndex, creatorField] = fieldName.split('-'); + let lastName = event.clipboardData.getData('text').trim(); + // Handle \n\r and \n delimited entries and a single line containing a tab + var rawNameArray = lastName.split(/\r\n?|\n/); + if (rawNameArray.length > 1 || rawNameArray[0].includes('\t')) { + // Pasting multiple authors; first make sure we prevent normal paste behavior + event.preventDefault(); + + // Filter out bad names + var nameArray = rawNameArray.filter(name => name); + + // If not adding names at the end of the creator list, make new creator + // entries and then shift down existing creators. + var initNumCreators = this.item.numCreators(); + var creatorsToShift = initNumCreators - creatorIndex; + if (creatorsToShift > 0) { + // Add extra creators with dummy values + for (let i = 0; i < nameArray.length; i++) { + this.modifyCreator(i + initNumCreators, { + firstName: '', + lastName: '', + fieldMode: 0, + creatorTypeID + }); } - var fieldname = valueField.getAttribute('fieldname'); - // Use shift-enter as the save action for the larger fields - if (Zotero.ItemFields.isMultiline(fieldname) && !event.shiftKey) { - return; + + // Shift existing creators + for (let i = initNumCreators - 1; i >= creatorIndex; i--) { + let shiftedCreatorData = this.item.getCreator(i); + this.item.setCreator(nameArray.length + i, shiftedCreatorData); } - - // Shift-enter adds new creator row - if (fieldname.indexOf('creator-') == 0 && event.shiftKey) { - // Value hasn't changed - if (valueField.initialValue == valueField.value) { - Zotero.debug("Value hasn't changed"); - let row = target.closest('.meta-row'); - // If + button is disabled, just focus next creator row - if (row.querySelector(".zotero-clicky-plus").disabled) { - let moreCreators = row.nextSibling.querySelector("#more-creators-label"); - if (moreCreators) { - moreCreators.click(); - } - else { - row.nextSibling.querySelector("editable-text").focus(); - } - } - else { - var creatorFields = this.getCreatorFields(row); - this.addCreatorRow(false, creatorFields.creatorTypeID, true); - } - } - // Value has changed - else { - this._tabDirection = 1; - this._addCreatorRow = true; - this.blurOpenField(); - } - } - - return; - - case "Escape": - - // Return focus to items pane - tree = document.getElementById('item-tree-main-default'); - if (tree) { - tree.focus(); - } - - return; - - case "Tab": - this.updateLastFocused(event); - if (event.shiftKey) { - // Shift-tab from the item type - if (this._lastTabIndex === 1) { - return; - } - event.preventDefault(); - event.stopPropagation(); - this._focusNextField(this._lastTabIndex, true); + } + + let currentIndex = creatorIndex; + let newCreator = { creatorTypeID }; + // Add the creators in lastNameArray one at a time + for (let tempName of nameArray) { + // Check for tab to determine creator name format + newCreator.fieldMode = (tempName.indexOf('\t') == -1) ? 1 : 0; + if (newCreator.fieldMode == 0) { + newCreator.lastName = tempName.split('\t')[0]; + newCreator.firstName = tempName.split('\t')[1]; } else { - let focused = this._focusNextField(++this._lastTabIndex); - if (focused) { - event.preventDefault(); - event.stopPropagation(); - } + newCreator.lastName = tempName; + newCreator.firstName = ''; } + this.modifyCreator(currentIndex, newCreator); + currentIndex++; + } + // Select the last field added + this._selectField = `itembox-field-value-creator-${currentIndex}-lastName`; + this._addCreatorRow = (creatorsToShift == 0); + + if (this.saveOnEdit) { + this.item.saveTx(); + } } } @@ -1946,8 +1806,6 @@ Zotero.debug(`Hiding editor for ${textbox.getAttribute('fieldname')}`); - this._lastTabIndex = -1; - // Prevent autocomplete breakage in Firefox 3 if (textbox.mController) { textbox.mController.input = null; @@ -2081,6 +1939,30 @@ } } + + // Make sure that irrelevant creators +/- buttons are disabled + _updateCreatorButtonsStatus() { + let creatorValues = [...this.querySelectorAll(".creator-type-value")]; + let row; + for (let creatorValue of creatorValues) { + row = creatorValue.closest(".meta-row"); + let { lastName, firstName } = this.getCreatorFields(row); + let isEmpty = lastName == "" && firstName == ""; + let isNextRowCreator = row.nextSibling.querySelector(".creator-type-value"); + let isDefaultEmptyRow = isEmpty && creatorValues.length == 1; + + if (!this.editable) { + row.querySelector(".zotero-clicky-plus").hidden = true; + row.querySelector(".zotero-clicky-minus").hidden = true; + row.querySelector(".zotero-clicky-options").hidden = true; + return; + } + + row.querySelector(".zotero-clicky-plus").disabled = isEmpty || isNextRowCreator; + row.querySelector(".zotero-clicky-minus").disabled = isDefaultEmptyRow; + } + } + getCreatorFields(row) { var typeID = row.querySelector('[typeid]').getAttribute('typeid'); var [label1, label2] = row.querySelectorAll('editable-text'); @@ -2188,7 +2070,6 @@ return; } this._draggedCreator = true; - this._lastTabIndex = null; // Due to some kind of drag-drop API issue, // after creator is dropped, the hover effect often stays at // the row's old location. To workaround that, set noHover class to block all @@ -2283,9 +2164,7 @@ } focusField(fieldName) { - let field = this.querySelector(`editable-text[fieldname="${fieldName}"]`); - if (!field) return false; - return this._focusNextField(field.getAttribute('ztabindex')); + this.querySelector(`editable-text[fieldname="${fieldName}"]`)?.focus(); } getTitleField() { @@ -2301,84 +2180,6 @@ return null; } - /** - * Advance the field focus forward or backward - * - * Note: We're basically replicating the built-in tabindex functionality, - * which doesn't work well with the weird label/textbox stuff we're doing. - * (The textbox being tabbed away from is deleted before the blur() - * completes, so it doesn't know where it's supposed to go next.) - */ - _focusNextField(tabindex, back) { - var box = this._infoTable; - tabindex = parseInt(tabindex); - - // Get all fields with ztabindex attributes - var tabbableFields = box.querySelectorAll('*[ztabindex]:not([disabled=true])'); - - if (!tabbableFields.length) { - Zotero.debug("No tabbable fields found"); - return false; - } - - var next; - if (back) { - Zotero.debug('Looking for previous tabindex before ' + tabindex, 4); - for (let i = tabbableFields.length - 1; i >= 0; i--) { - let field = tabbableFields[i]; - let tabIndexHere = parseInt(field.getAttribute('ztabindex')); - if (tabIndexHere !== -1 && tabIndexHere < tabindex) { - next = tabbableFields[i]; - break; - } - } - } - else { - Zotero.debug('Looking for tabindex ' + tabindex, 4); - for (var pos = 0; pos < tabbableFields.length; pos++) { - let field = tabbableFields[pos]; - let tabIndexHere = parseInt(field.getAttribute('ztabindex')); - if (tabIndexHere !== -1 && tabIndexHere >= tabindex) { - next = tabbableFields[pos]; - break; - } - } - } - if (!next) { - Zotero.debug("Next field not found"); - return false; - } - - // Ensure the node is visible - next.style.visibility = "visible"; - next.style.display = "block"; - next.focus(); - - next.style.removeProperty("visibility"); - next.style.removeProperty("display"); - // 1) next.parentNode is always null for some reason - // 2) For some reason it's necessary to scroll to the next element when - // moving forward for the target element to be fully in view - let visElem; - if (!back && tabbableFields[pos + 1]) { - Zotero.debug("Scrolling to next field"); - visElem = tabbableFields[pos + 1]; - } - else { - visElem = next; - } - this.ensureElementIsVisible(visElem); - - return true; - } - - updateLastFocused(ev) { - let ztabindex = parseInt(ev.target.getAttribute('ztabindex')); - if (ztabindex) { - this._lastTabIndex = ztabindex; - } - } - async blurOpenField() { var activeField = this.getFocusedTextArea(); if (!activeField) { diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js index e9d9030ebc..aba77692a2 100644 --- a/chrome/content/zotero/elements/itemDetails.js +++ b/chrome/content/zotero/elements/itemDetails.js @@ -199,7 +199,7 @@ this._header = this.querySelector('#zotero-item-pane-header'); this._paneParent = this.querySelector('#zotero-view-item'); - this._container.addEventListener("keypress", this._handleKeypress); + this._container.addEventListener("keydown", this._handleKeydown); this._paneParent.addEventListener('scroll', this._handleContainerScroll); this._paneHiddenOb = new MutationObserver(this._handlePaneStatus); @@ -225,7 +225,7 @@ } destroy() { - this._container.removeEventListener("keypress", this._handleKeypress); + this._container.removeEventListener("keydown", this._handleKeydown); this._paneParent.removeEventListener('scroll', this._handleContainerScroll); this._paneHiddenOb.disconnect(); @@ -535,7 +535,7 @@ }; // Keyboard navigation within the itemPane. Also handles contextPane keyboard nav - _handleKeypress = (event) => { + _handleKeydown = (event) => { let stopEvent = () => { event.preventDefault(); event.stopPropagation(); @@ -552,21 +552,16 @@ stopEvent(); return; } - // Tab tavigation between entries and buttons within library, related and notes boxes - if (event.key == "Tab" && event.target.closest(".box")) { - let next = null; - if (event.key == "Tab" && !event.shiftKey) { - next = event.target.nextElementSibling; + // On Escape/Enter on editable-text, return focus to the item tree or reader + if (event.key == "Escape" || (event.key == "Enter" && event.target.classList.contains('input'))) { + if (isLibraryTab) { + document.getElementById('item-tree-main-default').focus(); } - if (event.key == "Tab" && event.shiftKey) { - next = event.target.parentNode.previousElementSibling?.lastChild; - } - // Force the element to be visible before focusing - if (next) { - next.style.visibility = "visible"; - next.focus(); - next.style.removeProperty("visibility"); - stopEvent(); + else { + let reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID); + if (reader) { + reader.focus(); + } } } }; diff --git a/chrome/content/zotero/elements/tagsBox.js b/chrome/content/zotero/elements/tagsBox.js index 96050557a3..a1d1042491 100644 --- a/chrome/content/zotero/elements/tagsBox.js +++ b/chrome/content/zotero/elements/tagsBox.js @@ -327,6 +327,11 @@ if (!row.parentElement) { return; } + // Do not propagate event to itemDetails that would send focus to itemTree or reader + // because a new empty row will be created and focused in saveTag + if (row.getAttribute("isNew")) { + event.stopPropagation(); + } let blurOnly = false; let focusField = false; @@ -348,13 +353,6 @@ if (focusField) { focusField.focus(); } - // Return focus to items pane - else { - var tree = document.getElementById('zotero-items-tree'); - if (tree) { - tree.focus(); - } - } } }; diff --git a/chrome/content/zotero/tabs.js b/chrome/content/zotero/tabs.js index cc76407da8..c15465ee75 100644 --- a/chrome/content/zotero/tabs.js +++ b/chrome/content/zotero/tabs.js @@ -768,31 +768,7 @@ var Zotero_Tabs = new function () { Services.focus.MOVEFOCUS_BACKWARD, 0); return; } - // If no item is selected, focus items list. - if (ZoteroPane.itemPane.mode == "message") { - document.getElementById("item-tree-main-default").focus(); - return; - } - let selected = ZoteroPane.getSelectedItems(); - // If the selected collection row is duplicates, just focus on the - // itemTree until the merge pane is keyboard accessible - // If multiple items selected, focus on itemTree as well. - let collectionRow = ZoteroPane.collectionsView.selectedTreeRow; - if (collectionRow.isDuplicates() || selected.length !== 1) { - document.getElementById("item-tree-main-default").focus(); - return; - } - // Special treatment for notes and attachments in itemPane - selected = selected[0]; - if (selected.isNote()) { - document.getElementById("zotero-note-editor").focus(); - return; - } - if (selected.isAttachment()) { - document.getElementById("attachment-note-editor").focus(); - return; - } - // For regular items, focus the last field + // 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"), diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 91a8affd3c..e78fb52777 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -2147,7 +2147,7 @@ var ZoteroPane = new function() await original.saveTx({ skipDateModifiedUpdate: true }); await duplicate.saveTx(); - document.getElementById('zotero-editpane-item-box').focusField('title'); + ZoteroPane.itemPane.querySelector("item-box").getTitleField().focus(); return duplicate; }; diff --git a/scss/abstracts/_mixins.scss b/scss/abstracts/_mixins.scss index 351530b819..902f9df71c 100644 --- a/scss/abstracts/_mixins.scss +++ b/scss/abstracts/_mixins.scss @@ -123,13 +123,6 @@ row-gap: 2px; width: inherit; - .show-on-hover { - visibility: hidden; - &.no-display { - display: none; - } - } - .meta-row { display: grid; grid-template-columns: subgrid; @@ -139,14 +132,15 @@ display: none; } - // On hover of the meta-row, reveal all hidden icons - // unless there's .noHover class which keeps everything hidden - &:not(.noHover):hover .show-on-hover, - &:focus-within .show-on-hover { - visibility: visible; - display: revert; + &:not(:hover):not(:focus-within) .show-on-hover, + &.noHover .show-on-hover { + clip-path: inset(50%); + &.no-display { + width: 0; + height: 0; + padding: 0; + } } - .meta-data { width: 0; min-width: 100%;