zotero/chrome/content/zotero/elements/editableText.js
Abe Jellinek b15fb36f1b editable-text: Don't put 'null'/'undefined' in the field
In case something deleted dataset.initialValue after focus and before we
received this keypress.

Addresses #3725. Might still want to rework that listener, but this
change made sense regardless, since the same kind of race condition
could be triggered elsewhere.
2024-02-21 16:07:02 -05:00

365 lines
10 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";
{
class EditableText extends XULElementBase {
_input;
_textDirection = null;
_ignoredWindowInactiveBlur = false;
static observedAttributes = [
'multiline',
'readonly',
'placeholder',
'aria-label',
'aria-labelledby',
'value',
'nowrap',
'autocomplete',
'min-lines',
'max-lines'
];
get noWrap() {
return this.hasAttribute('nowrap');
}
set noWrap(noWrap) {
this.toggleAttribute('nowrap', noWrap);
}
get minLines() {
return this.getAttribute('min-lines') || 0;
}
get maxLines() {
return this.getAttribute('max-lines') || 0;
}
get multiline() {
return this.hasAttribute('multiline');
}
set multiline(multiline) {
this.toggleAttribute('multiline', multiline);
}
get readOnly() {
return this.hasAttribute('readonly');
}
set readOnly(readOnly) {
this.toggleAttribute('readonly', readOnly);
}
get placeholder() {
return this.getAttribute('placeholder') || '';
}
set placeholder(placeholder) {
this.setAttribute('placeholder', placeholder || '');
}
get ariaLabel() {
return this.getAttribute('aria-label') || '';
}
get ariaLabelledBy() {
return this.getAttribute('aria-labelledby') || '';
}
set ariaLabel(ariaLabel) {
this.setAttribute('aria-label', ariaLabel);
}
get value() {
return this.getAttribute('value') || '';
}
set value(value) {
this.setAttribute('value', value || '');
this.resetTextDirection();
}
get initialValue() {
return this._input?.dataset.initialValue || '';
}
set initialValue(initialValue) {
this._input.dataset.initialValue = initialValue || '';
}
get autocomplete() {
let val = this.getAttribute('autocomplete');
try {
let props = JSON.parse(val);
if (typeof props === 'object') {
return props;
}
}
catch (e) {
// Ignore
}
return null;
}
set autocomplete(val) {
if (val) {
this.setAttribute('autocomplete', JSON.stringify(val));
}
else {
this.removeAttribute('autocomplete');
}
}
get ref() {
return this._input;
}
resetTextDirection() {
this._textDirection = null;
if (this._input) {
this._input.dir = null;
}
}
sizeToContent = () => {
// Add a temp span, fetch its width with current paddings and set max-width based on that
let span = document.createElement("span");
span.innerText = this.value || this.placeholder;
this.append(span);
let size = span.getBoundingClientRect();
this.style['max-width'] = `calc(${size.width}px)`;
this.querySelector("span").remove();
};
attributeChangedCallback() {
this.render();
}
init() {
this.render();
}
render() {
let autocompleteParams = this.autocomplete;
let autocompleteEnabled = !this.multiline && !!autocompleteParams;
if (!this._input
|| (this._input.constructor.name === 'AutocompleteInput') !== autocompleteEnabled
|| this._input.tagName !== (this.noWrap ? 'input' : 'textarea')) {
let input;
if (autocompleteEnabled) {
input = document.createElement('input', { is: 'autocomplete-input' });
input.type = 'autocomplete';
}
else {
input = this.noWrap ? document.createElement('input') : document.createElement('textarea');
input.rows = 1;
}
input.classList.add('input');
let handleInput = () => {
if (!this.multiline) {
this._input.value = this._input.value.replace(/\n/g, ' ');
}
this.setAttribute('value', this._input.value);
};
let handleChange = () => {
this.setAttribute('value', this._input.value);
};
input.addEventListener('mousedown', () => {
this.setAttribute("mousedown", true);
});
input.addEventListener('input', handleInput);
input.addEventListener('change', handleChange);
input.addEventListener('focus', () => {
// If the last blur was ignored because it was caused by the window becoming inactive,
// ignore this focus event as well, so we don't reset initialValue
if (this._ignoredWindowInactiveBlur) {
this._ignoredWindowInactiveBlur = false;
return;
}
this.dispatchEvent(new CustomEvent('focus'));
this.classList.add("focused");
// Select all text if focused via keyboard
if (!this.getAttribute("mousedown")) {
this._input.setSelectionRange(0, this._input.value.length, "backward");
}
this._input.dataset.initialValue = this._input.value;
});
input.addEventListener('blur', () => {
// Ignore this blur if it was caused by the window becoming inactive (see above)
if (Services.focus.activeWindow !== window) {
this._ignoredWindowInactiveBlur = true;
return;
}
this._ignoredWindowInactiveBlur = false;
this.dispatchEvent(new CustomEvent('blur'));
this.classList.remove("focused");
this._input.scrollLeft = 0;
this._input.setSelectionRange(0, 0);
this.removeAttribute("mousedown");
delete this._input.dataset.initialValue;
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
if (this.multiline === event.shiftKey) {
event.preventDefault();
this.dispatchEvent(new CustomEvent('escape_enter'));
this._input.blur();
}
}
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();
}
});
input.addEventListener('mousedown', (event) => {
// Prevent a right-click from focusing the input when unfocused
if (event.button === 2 && document.activeElement !== this._input) {
event.preventDefault();
}
});
let focused = false;
let selectionStart = this._input?.selectionStart;
let selectionEnd = this._input?.selectionEnd;
let selectionDirection = this._input?.selectionDirection;
if (this._input && document.activeElement === this._input) {
focused = true;
input.dataset.initialValue = this._input?.dataset.initialValue;
}
if (this._input) {
this._input.replaceWith(input);
}
else {
this.append(input);
}
this._input = input;
if (focused) {
this._input.focus();
}
if (selectionStart !== undefined && selectionEnd !== undefined) {
this._input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
}
}
this._input.readOnly = this.readOnly;
this._input.placeholder = this.placeholder;
if (this._input.tagName == "textarea") {
// Reset to initial state
this.style.removeProperty("--min-visible-lines");
this.style.removeProperty("--max-visible-lines");
// Set how tall the textarea can/must be
if (this.minLines > 0) {
this.style.setProperty("--min-visible-lines", this.minLines);
}
if (this.maxLines > 0) {
this.style.setProperty("--max-visible-lines", this.maxLines);
}
}
if (this.ariaLabel.length) {
this._input.setAttribute('aria-label', this.ariaLabel);
}
if (this.ariaLabelledBy.length) {
this._input.setAttribute('aria-labelledby', this.ariaLabelledBy);
}
this._input.value = this.value;
// The actual input node can disappear if the component is moved
if (this.childElementCount == 0) {
this.replaceChildren(this._input);
}
if (autocompleteEnabled) {
this._input.setAttribute('autocomplete', 'on');
this._input.setAttribute('autocompletepopup', autocompleteParams.popup || '');
this._input.setAttribute('autocompletesearch', autocompleteParams.search || '');
delete autocompleteParams.popup;
delete autocompleteParams.search;
Object.assign(this._input, autocompleteParams);
}
// Set text direction automatically if user has enabled bidi utilities
if ((!this._input.dir || this._input.dir === 'auto') && Zotero.Prefs.get('bidi.browser.ui', true)) {
if (!this._textDirection) {
this._textDirection = window.windowUtils.getDirectionFromText(this._input.value) === Ci.nsIDOMWindowUtils.DIRECTION_RTL
? 'rtl'
: 'ltr';
}
this._input.dir = this._textDirection;
}
}
focus(options) {
this._input?.focus(options);
}
blur() {
this._input?.blur();
}
}
customElements.define("editable-text", EditableText);
document.addEventListener('contextmenu', (event) => {
if (event.defaultPrevented
|| !event.target.closest('editable-text')
|| document.activeElement && event.target.contains(document.activeElement)) {
return;
}
event.preventDefault();
let editableText = event.target.closest('editable-text');
let menupopup = document.getElementById('zotero-editable-text-menu');
if (!menupopup) {
menupopup = document.createXULElement('menupopup');
menupopup.id = 'zotero-editable-text-menu';
let popupset = document.querySelector('popupset');
if (!popupset) {
popupset = document.createXULElement('popupset');
document.documentElement.append(popupset);
}
popupset.append(menupopup);
}
menupopup.addEventListener('popupshowing', () => {
Zotero.Utilities.Internal.updateEditContextMenu(menupopup, editableText);
}, { once: true });
menupopup.openPopupAtScreen(event.screenX + 1, event.screenY + 1, true);
});
}