mirror of
https://github.com/zotero/zotero.git
synced 2026-09-14 23:21:03 +00:00
Add Go menu for better keyboard navigation
Adds a top-level Go menu to jump to common UI elements without the mouse. Each entry refreshes lazily on popup show, and items reflect what's visible for the current tab. - Tabs Menu, Locate: available in any tab type. Locate expands the context pane first in reader/note tabs. - Library, Quick Search: library tab only. Display the shortcut configured by the respective pref. - Item-pane sections (Info, Abstract, Tags, etc.): listed dynamically for library, reader, and note tabs. Selecting one opens its collapsible section and focuses its header. Sections added later (built-in or via plugins) are picked up automatically. - Item Info submenu: in library/reader tabs with a regular item visible, display an additional menu with item's fields.
This commit is contained in:
parent
7ca8242a3e
commit
c06fdf9e0a
11 changed files with 395 additions and 8 deletions
|
|
@ -61,6 +61,15 @@ var ZoteroContextPane = new function () {
|
|||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(this, 'itemDetails', {
|
||||
get: () => {
|
||||
if (_contextPaneInner.mode === 'notes') {
|
||||
return this.activeEditor?.querySelector('links-box');
|
||||
}
|
||||
return document.getElementById('zotero-context-pane-item-deck').selectedPanel;
|
||||
}
|
||||
});
|
||||
|
||||
this.focus = () => {
|
||||
return _contextPaneInner.handleFocus();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -112,6 +112,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
focusSection() {
|
||||
this.open = true;
|
||||
this._abstractField?.focus({ focusVisible: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.item) return;
|
||||
if (this._isAlreadyRendered()) return;
|
||||
|
|
|
|||
|
|
@ -2558,7 +2558,12 @@
|
|||
}
|
||||
|
||||
focusField(fieldName) {
|
||||
this.querySelector(`editable-text[fieldname="${fieldName}"]`)?.focus();
|
||||
this.open = true;
|
||||
if (fieldName === 'itemType') {
|
||||
this.querySelector('#item-type-menu').focus({ focusVisible: true });
|
||||
return;
|
||||
}
|
||||
this.querySelector(`editable-text[fieldname="${fieldName}"]`).focus();
|
||||
}
|
||||
|
||||
_saveFieldFocus() {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,17 @@
|
|||
this.setAttribute("view-type", type);
|
||||
}
|
||||
|
||||
|
||||
get itemDetails() {
|
||||
if (this.mode === 'note') {
|
||||
return this._noteEditor?.querySelector('links-box');
|
||||
}
|
||||
if (this.mode === 'item') {
|
||||
return this._itemDetails;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get collapsed() {
|
||||
return isPaneCollapsed(this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,12 @@ class ItemPaneSectionElementBase extends XULElementBase {
|
|||
}
|
||||
}
|
||||
|
||||
focusSection() {
|
||||
if (!this._section) return;
|
||||
this._section.open = true;
|
||||
this._section._head?.focus({ focusVisible: true });
|
||||
}
|
||||
|
||||
get collapsible() {
|
||||
return this._section.collapsible;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,9 +408,9 @@
|
|||
<html:div id="parent-label" class="label" hidden="true"/>
|
||||
<html:div id="parent-value" class="value zotero-clicky" hidden="true"/>
|
||||
-->
|
||||
<tags-box id="tags"/>
|
||||
<libraries-collections-box id="libraries-collections"/>
|
||||
<related-box id="related"/>
|
||||
<tags-box id="tags" data-pane="tags"/>
|
||||
<libraries-collections-box id="libraries-collections" data-pane="libraries-collections"/>
|
||||
<related-box id="related" data-pane="related"/>
|
||||
`, ['chrome://zotero/locale/zotero.dtd']);
|
||||
}
|
||||
|
||||
|
|
@ -433,6 +433,10 @@
|
|||
this.destroy();
|
||||
}
|
||||
|
||||
get item() {
|
||||
return this._item;
|
||||
}
|
||||
|
||||
set item(val) {
|
||||
this._item = val;
|
||||
this._id('related').item = this._item;
|
||||
|
|
@ -447,6 +451,15 @@
|
|||
this.refresh();
|
||||
}
|
||||
|
||||
// Mirror relevant methods from itemDetails so that Go menu generation treats LinksBox the same way
|
||||
getEnabledPanes() {
|
||||
return Array.from(this.querySelectorAll(':scope > [data-pane]:not([hidden])'));
|
||||
}
|
||||
|
||||
getEnabledPane(id) {
|
||||
return this.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]:not([hidden])`);
|
||||
}
|
||||
|
||||
set mode(val) {
|
||||
this._mode = val;
|
||||
this._id('related').editable = val == "edit";
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ const ZoteroStandalone = new function () {
|
|||
.setAttribute('key', Zotero.Keys.getKeyForCommand('copySelectedItemsToClipboard'));
|
||||
document.getElementById('key_showTabsMenu')
|
||||
.setAttribute('key', Zotero.Keys.getKeyForCommand('showTabsMenu'));
|
||||
document.getElementById('key_library')
|
||||
.setAttribute('key', Zotero.Keys.getKeyForCommand('library'));
|
||||
document.getElementById('key_quicksearch')
|
||||
.setAttribute('key', Zotero.Keys.getKeyForCommand('quicksearch'));
|
||||
// Force menu to update with shortcut key at startup -- as of fx128, this is necessary
|
||||
// to get the shortcut to reliably appear for the menu item without switching tabs
|
||||
document.getElementById('show-tabs-menu').hidden = true;
|
||||
|
|
@ -390,6 +394,8 @@ const ZoteroStandalone = new function () {
|
|||
|
||||
|
||||
this.onGoMenuOpen = function (event) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
|
||||
var keyBack = document.getElementById('key_back');
|
||||
var keyForward = document.getElementById('key_forward');
|
||||
|
||||
|
|
@ -423,10 +429,135 @@ const ZoteroStandalone = new function () {
|
|||
this.updateMenuItemEnabled('go-menuitem-forward', reader.canNavigateForward);
|
||||
}
|
||||
|
||||
this._rebuildGoMenuSections(event.target);
|
||||
|
||||
this.onUpdateCustomMenus(event, 'go');
|
||||
};
|
||||
|
||||
|
||||
|
||||
this._rebuildGoMenuSections = function (popup) {
|
||||
let beforeSep = document.getElementById('go-menu-sep-sections-before');
|
||||
let afterSep = document.getElementById('go-menu-sep-sections-after');
|
||||
|
||||
// Clear previously inserted dynamic entries
|
||||
popup.querySelectorAll('.go-menu-section-dynamic').forEach(el => el.remove());
|
||||
|
||||
let itemDetails = Zotero_Tabs.currentItemPane?.itemDetails;
|
||||
let hasSections = false;
|
||||
|
||||
for (let pane of (itemDetails?.getEnabledPanes() || [])) {
|
||||
let paneID = pane.dataset.pane;
|
||||
let section = pane.querySelector('collapsible-section');
|
||||
if (!section) continue;
|
||||
|
||||
let label = Zotero.ftl.formatValueSync(`pane-${paneID}`)
|
||||
|| section.getAttribute('label')
|
||||
|| paneID;
|
||||
|
||||
let element;
|
||||
if (paneID === 'info') {
|
||||
element = document.createXULElement('menu');
|
||||
element.setAttribute('label', label);
|
||||
let submenu = document.createXULElement('menupopup');
|
||||
element.appendChild(submenu);
|
||||
this._populateItemInfoFields(submenu, itemDetails.item);
|
||||
if (!submenu.hasChildNodes()) {
|
||||
element.setAttribute('disabled', true);
|
||||
}
|
||||
}
|
||||
else {
|
||||
element = document.createXULElement('menuitem');
|
||||
element.setAttribute('label', label);
|
||||
element.addEventListener('command', () => {
|
||||
Zotero_Tabs.currentItemPane.collapsed = false;
|
||||
itemDetails.getEnabledPane(paneID).focusSection();
|
||||
});
|
||||
}
|
||||
element.classList.add('go-menu-section-dynamic');
|
||||
popup.insertBefore(element, afterSep);
|
||||
hasSections = true;
|
||||
}
|
||||
|
||||
// Only unhide separators the current tab type allows
|
||||
let type = Zotero_Tabs.selectedType;
|
||||
let beforeApplies = beforeSep.classList.contains(`menu-type-${type}`);
|
||||
let afterApplies = afterSep.classList.contains(`menu-type-${type}`);
|
||||
beforeSep.hidden = !(hasSections && beforeApplies);
|
||||
afterSep.hidden = !(hasSections && afterApplies);
|
||||
};
|
||||
|
||||
|
||||
this._populateItemInfoFields = function (popup, item) {
|
||||
let focusItemInfoField = function (fieldName) {
|
||||
let pane = Zotero_Tabs.currentItemPane;
|
||||
if (!pane) return;
|
||||
pane.collapsed = false;
|
||||
pane.itemDetails?.querySelector('info-box')?.focusField(fieldName);
|
||||
};
|
||||
|
||||
let itemTypeMenuItem = document.createXULElement('menuitem');
|
||||
itemTypeMenuItem.setAttribute('label', Zotero.getString('zotero.items.itemType'));
|
||||
itemTypeMenuItem.addEventListener('command', () => focusItemInfoField('itemType'));
|
||||
popup.appendChild(itemTypeMenuItem);
|
||||
|
||||
let fieldIDs = Zotero.ItemFields.getItemTypeFields(item.getField('itemTypeID'));
|
||||
let titleFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(item.itemTypeID, 'title');
|
||||
for (let fieldID of fieldIDs) {
|
||||
let fieldName = Zotero.ItemFields.getName(fieldID);
|
||||
// Skip abstract - it has its own section
|
||||
if (fieldName === "abstractNote") continue;
|
||||
|
||||
let menuitem = document.createXULElement('menuitem');
|
||||
menuitem.setAttribute('label', Zotero.ItemFields.getLocalizedString(fieldID));
|
||||
menuitem.addEventListener('command', () => focusItemInfoField(fieldName));
|
||||
popup.appendChild(menuitem);
|
||||
|
||||
if (fieldID === titleFieldID) {
|
||||
let firstCreator = item.getCreators()[0];
|
||||
let creatorTypeID;
|
||||
if (firstCreator) {
|
||||
creatorTypeID = firstCreator.creatorTypeID;
|
||||
}
|
||||
else if (item.library.editable
|
||||
&& Zotero.CreatorTypes.itemTypeHasCreators(item.itemTypeID)) {
|
||||
creatorTypeID = Zotero.CreatorTypes.getPrimaryIDForType(item.itemTypeID);
|
||||
}
|
||||
if (creatorTypeID) {
|
||||
let creatorMenuItem = document.createXULElement('menuitem');
|
||||
creatorMenuItem.setAttribute('label',
|
||||
Zotero.CreatorTypes.getLocalizedString(creatorTypeID));
|
||||
creatorMenuItem.addEventListener('command', () => focusItemInfoField('creator-0-lastName'));
|
||||
popup.appendChild(creatorMenuItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
this.onGoMenuCommand = function (action) {
|
||||
let target;
|
||||
if (action === 'locate') {
|
||||
let sidenavID = "zotero-view-item-sidenav";
|
||||
// in non-library tabs the sidenav is hidden if context pane is collapsed
|
||||
if (Zotero_Tabs.hasContextPane(Zotero_Tabs.selectedType)) {
|
||||
ZoteroContextPane.collapsed = false;
|
||||
sidenavID = "zotero-context-pane-sidenav";
|
||||
}
|
||||
target = document.querySelector(`#${sidenavID} toolbarbutton[data-action="locate"]`);
|
||||
}
|
||||
else {
|
||||
let targetIDs = {
|
||||
tabs: 'zotero-tb-tabs-menu',
|
||||
library: 'collection-tree',
|
||||
'quick-search': 'zotero-tb-search-textbox',
|
||||
};
|
||||
target = document.getElementById(targetIDs[action]);
|
||||
}
|
||||
if (target) {
|
||||
target.focus({ focusVisible: true });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
this.onViewMenuOpen = function (event) {
|
||||
// PDF Reader
|
||||
var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID);
|
||||
|
|
|
|||
|
|
@ -92,6 +92,18 @@ var Zotero_Tabs = new function () {
|
|||
return this._hasContextPaneTypes.includes(type);
|
||||
};
|
||||
|
||||
Object.defineProperty(this, 'currentItemPane', {
|
||||
get: () => {
|
||||
if (this.selectedType === 'library') {
|
||||
return ZoteroPane.itemPane;
|
||||
}
|
||||
if (this.hasContextPane(this.selectedType)) {
|
||||
return ZoteroContextPane;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
this.hasNoteContext = function (type) {
|
||||
return this._hasNoteContextTypes.includes(type);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -181,6 +181,10 @@
|
|||
<key id="key_showTabsMenu"
|
||||
command="cmd_zotero_showTabsMenu"
|
||||
modifiers="accel"/>
|
||||
<key id="key_library"
|
||||
modifiers="accel shift"/>
|
||||
<key id="key_quicksearch"
|
||||
modifiers="accel shift"/>
|
||||
</keyset>
|
||||
|
||||
<keyset id="editMenuKeys">
|
||||
|
|
@ -711,13 +715,43 @@
|
|||
</menupopup>
|
||||
</menu>
|
||||
|
||||
<menu
|
||||
<menu
|
||||
id="go-menu"
|
||||
class="menu-type-reader"
|
||||
class="menu-type-reader menu-type-library menu-type-note"
|
||||
label="&goMenu.label;"
|
||||
accesskey="&goMenu.accesskey;">
|
||||
<menupopup id="menu_goPopup"
|
||||
onpopupshowing="ZoteroStandalone.onGoMenuOpen(event)">
|
||||
<menuitem
|
||||
id="go-menuitem-tabs"
|
||||
class="menu-type-library menu-type-reader menu-type-note"
|
||||
data-l10n-id="menu-go-tabs"
|
||||
oncommand="ZoteroStandalone.onGoMenuCommand('tabs')"
|
||||
/>
|
||||
<menuitem
|
||||
id="go-menuitem-library"
|
||||
class="menu-type-library"
|
||||
data-l10n-id="menu-go-library"
|
||||
key="key_library"
|
||||
oncommand="ZoteroStandalone.onGoMenuCommand('library')"
|
||||
/>
|
||||
<menuitem
|
||||
id="go-menuitem-quick-search"
|
||||
class="menu-type-library"
|
||||
data-l10n-id="menu-go-quick-search"
|
||||
key="key_quicksearch"
|
||||
oncommand="ZoteroStandalone.onGoMenuCommand('quick-search')"
|
||||
/>
|
||||
<menuitem
|
||||
id="go-menuitem-locate"
|
||||
class="menu-type-library menu-type-reader menu-type-note"
|
||||
data-l10n-id="menu-go-locate"
|
||||
oncommand="ZoteroStandalone.onGoMenuCommand('locate')"
|
||||
/>
|
||||
<menuseparator id="go-menu-sep-sections-before"
|
||||
class="menu-type-library menu-type-reader menu-type-note"/>
|
||||
<menuseparator id="go-menu-sep-sections-after"
|
||||
class="menu-type-reader"/>
|
||||
<menuitem
|
||||
id="go-menuitem-first-page"
|
||||
class="menu-type-reader pdf epub"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ menu-view-note-tab-font-size =
|
|||
menu-show-tabs-menu =
|
||||
.label = Show Tabs Menu
|
||||
|
||||
menu-go-tabs =
|
||||
.label = Tabs Menu
|
||||
menu-go-library =
|
||||
.label = Library
|
||||
menu-go-quick-search =
|
||||
.label = Quick Search
|
||||
menu-go-locate =
|
||||
.label = Locate
|
||||
|
||||
menu-edit-copy-annotation =
|
||||
.label = { $count ->
|
||||
[one] Copy Annotation
|
||||
|
|
|
|||
|
|
@ -1944,4 +1944,156 @@ describe("ZoteroPane", function () {
|
|||
assert.includeMembers(topLevelCollections, [collectionChild]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Go menu", function () {
|
||||
let goPopup;
|
||||
|
||||
before(function () {
|
||||
goPopup = doc.getElementById('menu_goPopup');
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
// Switch back to library tab if a previous test opened a reader/note tab
|
||||
if (win.Zotero_Tabs.selectedID !== 'zotero-pane') {
|
||||
let promise = waitForNotifierEvent("select", "tab");
|
||||
win.Zotero_Tabs.select('zotero-pane');
|
||||
await promise;
|
||||
}
|
||||
win.Zotero_Tabs.closeAll();
|
||||
await selectLibrary(win);
|
||||
// Clear lingering item selection from a previous test
|
||||
if (zp.itemsView.selection.count > 0) {
|
||||
let promise = zp.itemsView.waitForSelect();
|
||||
zp.itemsView.selection.clearSelection();
|
||||
await promise;
|
||||
}
|
||||
});
|
||||
|
||||
function openGoMenu() {
|
||||
goPopup.dispatchEvent(new MouseEvent('popupshowing'));
|
||||
}
|
||||
|
||||
function visibleStaticItemIds() {
|
||||
return [...goPopup.children]
|
||||
.filter(el => !el.classList.contains('go-menu-section-dynamic')
|
||||
&& !el.hidden
|
||||
&& ['menuitem', 'menu'].includes(el.tagName.toLowerCase()))
|
||||
.map(el => el.id);
|
||||
}
|
||||
|
||||
function dynamicSectionLabels() {
|
||||
return [...goPopup.querySelectorAll('.go-menu-section-dynamic')]
|
||||
.map(el => el.getAttribute('label'));
|
||||
}
|
||||
|
||||
it("should show only Tabs Menu, Library, Quick Search, and Locate in library tab with no selection", function () {
|
||||
openGoMenu();
|
||||
assert.sameMembers(visibleStaticItemIds(), [
|
||||
'go-menuitem-tabs',
|
||||
'go-menuitem-library',
|
||||
'go-menuitem-quick-search',
|
||||
'go-menuitem-locate'
|
||||
]);
|
||||
assert.lengthOf(dynamicSectionLabels(), 0);
|
||||
});
|
||||
|
||||
it("should list item-pane sections when an item is selected in library tab", async function () {
|
||||
let item = await createDataObject('item', { itemType: 'book' });
|
||||
await select(win, item);
|
||||
openGoMenu();
|
||||
let labels = dynamicSectionLabels();
|
||||
assert.include(labels, 'Info');
|
||||
assert.include(labels, 'Abstract');
|
||||
assert.include(labels, 'Attachments');
|
||||
assert.include(labels, 'Notes');
|
||||
assert.include(labels, 'Libraries and Collections');
|
||||
assert.include(labels, 'Tags');
|
||||
assert.include(labels, 'Related');
|
||||
|
||||
// Selecting a section should focus its collapsible-section header
|
||||
let tagsMenuItem = [...goPopup.querySelectorAll('.go-menu-section-dynamic')]
|
||||
.find(el => el.getAttribute('label') === 'Tags');
|
||||
let focusPromise = waitForDOMEvent(win.ZoteroPane.itemPane.itemDetails, 'focusin');
|
||||
tagsMenuItem.dispatchEvent(new MouseEvent('command'));
|
||||
await focusPromise;
|
||||
assert.equal(doc.activeElement.textContent, "0 Tags");
|
||||
assert.isTrue(doc.activeElement.classList.contains("head"));
|
||||
});
|
||||
|
||||
it("should render the Item Info entry as a submenu of item fields", async function () {
|
||||
let item = await createDataObject('item', { itemType: 'book' });
|
||||
await select(win, item);
|
||||
openGoMenu();
|
||||
let info = [...goPopup.querySelectorAll('.go-menu-section-dynamic')]
|
||||
.find(el => el.getAttribute('label') === 'Info');
|
||||
assert.ok(info, "Info entry should exist");
|
||||
assert.equal(info.tagName.toLowerCase(), 'menu');
|
||||
let submenu = info.querySelector('menupopup');
|
||||
let fieldLabels = [...submenu.children].map(el => el.getAttribute('label'));
|
||||
assert.include(fieldLabels, "Item Type");
|
||||
assert.include(fieldLabels, "Author");
|
||||
let fieldIDs = Zotero.ItemFields.getItemTypeFields(item.itemTypeID);
|
||||
for (let fieldID of fieldIDs) {
|
||||
let fieldName = Zotero.ItemFields.getName(fieldID);
|
||||
if (fieldName === "abstractNote") continue;
|
||||
let label = Zotero.ItemFields.getLocalizedString(fieldID);
|
||||
assert.include(fieldLabels, label, `Item Info submenu should include "${label}"`);
|
||||
}
|
||||
|
||||
// Selecting Title should focus the title editable-text in the info-box
|
||||
let titleFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(item.itemTypeID, 'title');
|
||||
let titleLabel = Zotero.ItemFields.getLocalizedString(titleFieldID);
|
||||
let titleMenuItem = [...submenu.children].find(el => el.getAttribute('label') === titleLabel);
|
||||
let infoBox = win.ZoteroPane.itemPane.itemDetails.querySelector('info-box');
|
||||
let focusPromise = waitForDOMEvent(infoBox, 'focusin');
|
||||
titleMenuItem.dispatchEvent(new MouseEvent('command'));
|
||||
await focusPromise;
|
||||
assert.equal(doc.activeElement.closest('editable-text').id, 'itembox-field-value-title');
|
||||
});
|
||||
|
||||
it("should list Tags, Related, and Libraries and Collections when a note is selected in library tab", async function () {
|
||||
let note = new Zotero.Item('note');
|
||||
note.setNote('test');
|
||||
await note.saveTx();
|
||||
await select(win, note);
|
||||
openGoMenu();
|
||||
let labels = dynamicSectionLabels();
|
||||
assert.include(labels, 'Tags');
|
||||
assert.include(labels, 'Related');
|
||||
assert.include(labels, 'Libraries and Collections');
|
||||
assert.notInclude(labels, 'Info');
|
||||
assert.notInclude(labels, 'Abstract');
|
||||
});
|
||||
|
||||
it("should list attachment sections for a standalone PDF in a reader tab", async function () {
|
||||
let attachment = await importPDFAttachment();
|
||||
let tabPromise = waitForNotifierEvent("select", "tab");
|
||||
let reader = await Zotero.Reader.open(attachment.itemID);
|
||||
await reader._initPromise;
|
||||
await tabPromise;
|
||||
openGoMenu();
|
||||
let labels = dynamicSectionLabels();
|
||||
assert.include(labels, 'Attachment Info');
|
||||
assert.include(labels, 'Libraries and Collections');
|
||||
assert.include(labels, 'Tags');
|
||||
assert.include(labels, 'Related');
|
||||
});
|
||||
|
||||
it("should list Note Info, Libraries and Collections, Tags, and Related for a note tab", async function () {
|
||||
let note = new Zotero.Item('note');
|
||||
note.setNote('test note');
|
||||
await note.saveTx();
|
||||
await Zotero.Notes.open(note.id);
|
||||
await waitForCallback(
|
||||
() => win.Zotero_Tabs.currentItemPane?.itemDetails?.item?.id === note.id,
|
||||
50, 5
|
||||
);
|
||||
openGoMenu();
|
||||
let labels = dynamicSectionLabels();
|
||||
assert.include(labels, 'Note Info');
|
||||
assert.include(labels, 'Libraries and Collections');
|
||||
assert.include(labels, 'Tags');
|
||||
assert.include(labels, 'Related');
|
||||
});
|
||||
});
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue