/* # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 'use strict'; import * as PlaceHolderParser from '/extlib/placeholder-parser.js'; import RichConfirm from '/extlib/RichConfirm.js'; import { log as internalLogger, configs, notify, wait, sha1sum, sanitizeForHTMLText, isLinux, isRTL, } from './common.js'; import * as ApiTabs from './api-tabs.js'; import * as TreeBehavior from './tree-behavior.js'; import * as Constants from './constants.js'; import * as ContextualIdentities from './contextual-identities.js'; import * as Dialog from './dialog.js'; import * as Permissions from './permissions.js'; import * as UserOperationBlocker from './user-operation-blocker.js'; import { Tab } from '/common/TreeItem.js'; function log(...args) { internalLogger('common/bookmarks', ...args); } let mCreatingCount = 0; export async function getItemById(id) { if (!id) return null; try { const items = await browser.bookmarks.get(id).catch(ApiTabs.createErrorHandler()); if (items.length > 0) return items[0]; } catch(_error) { } return null; } if (Constants.IS_BACKGROUND) { // Dialog loaded in a popup cannot call privileged APIs, so the background script proxies such operations. browser.runtime.onMessage.addListener((message, sender) => { if (!message || typeof message != 'object') return; switch (message.type) { case 'ws:get-bookmark-item-by-id': return getItemById(message.id); case 'ws:get-bookmark-child-items': return browser.bookmarks.getChildren(message.id || 'root________').catch(ApiTabs.createErrorHandler()); case 'ws:get-bookmark-ancestor-ids': return (async () => { const ancestorIds = []; let item; let lastId = message.id; do { item = await getItemById(lastId); if (!item) break; ancestorIds.push(lastId = item.parentId); } while (lastId != 'root________'); return ancestorIds; })(); case 'ws:create-new-bookmark-folder': return (async () => { const folder = await browser.bookmarks.create({ type: 'folder', title: browser.i18n.getMessage('bookmarkDialog_newFolder_defaultTitle'), parentId: message.parentId, ...(typeof message.index == 'number' ? { index: message.index } : {}), }).catch(ApiTabs.createErrorHandler()); return folder; })(); case 'ws:update-bookmark-folder': return browser.bookmarks.update(message.id, { title: message.title, }).catch(ApiTabs.createErrorHandler()); case 'ws:resize-bookmark-dialog-by': return (async () => { const win = await browser.windows.get(sender.tab.windowId); return browser.windows.update(win.id, { width: win.width + (message.width || 0), height: win.height + (message.height || 0), }); })(); } }); } // The base URL of style declarations embedded into a popup become an unprivileged URL, // and it is different from the URL of this file and the base URL of this addon. // Thus we need to load images with their complete URL. export const FOLDER_CHOOSER_STYLE = ` .parentIdChooserMiniContainer, .parentIdChooserFullContainer { --icon-size: 16px; } .parentIdChooserMiniContainer { display: flex; flex-direction: row; } .parentIdChooserMini { display: flex; flex-grow: 1; margin-inline-end: 0.2em; max-width: calc(100% - 2em /* width of the showAllFolders button */ - 0.2em); } .showAllFolders { display: flex; flex-grow: 0; width: 2em; } .showAllFolders::before { -moz-context-properties: fill; background: currentColor; content: ""; display: inline-block; fill: currentColor; height: var(--icon-size); line-height: 1; mask: url("${browser.runtime.getURL('/sidebar/styles/icons/ArrowheadDown.svg')}") no-repeat center / 60%; max-height: var(--icon-size); max-width: var(--icon-size); transform-origin: 50% 50%; width: var(--icon-size); } .showAllFolders.expanded::before { transform: rotatez(180deg); } .parentIdChooserFullContainer { flex-direction: column; flex-grow: 1; flex-shrink: 1; } .parentIdChooserFullContainer:not(.expanded) { display: none; } .parentIdChooserFullContainer ul { list-style: none; margin-block: 0; margin-inline: 0; padding-block: 0; padding-inline: 0; } .parentIdChooserFullContainer ul.parentIdChooserFull { max-height: 0; overflow: visible; } .parentIdChooserFullContainer li:not(.expanded) > ul { display: none; } .parentIdChooserFullTreeContainer { border: 1px solid; margin-block: 0.5em; margin-inline: 0; min-height: 10em; display: flex; flex-direction: column; flex-grow: 1; flex-shrink: 1; overflow-y: auto; } .parentIdChooserFull li { list-style: none; margin-block: 0; margin-inline: 0; padding-block: 0; padding-inline: 0; } .parentIdChooserFull li > label { padding-block: 0.25em; padding-inline: 0.25em; white-space: nowrap; display: flex; user-select: none; } .parentIdChooserFull li > label:hover { background: rgba(0, 0, 0, 0.15); } .parentIdChooserFull .twisty { height: 1em; width: 1em; } .parentIdChooserFull li.noChild .twisty { visibility: hidden; } .parentIdChooserFull li > label > .twisty { order: 1; } .parentIdChooserFull li > label > .twisty::before { -moz-context-properties: fill; background: currentColor; content: ""; display: inline-block; height: 1em; line-height: 1; mask: url("${browser.runtime.getURL('/sidebar/styles/icons/ArrowheadDown.svg')}") no-repeat center / 60%; max-height: 1em; max-width: 1em; transform-origin: 50% 50%; transform: rotatez(-90deg); width: 1em;; } .rtl .parentIdChooserFull li > label > .twisty::before { transform: rotatez(90deg); } .parentIdChooserFull li.expanded > label > .twisty::before { transform: rotatez(0deg); } .parentIdChooserFull li.focused > label { color: highlightText; background: highlight; outline: 1px dotted; } .parentIdChooserFull li.chosen > label > .twisty::before { background: highlightText; } .parentIdChooserFull li > label::before { -moz-context-properties: fill; background: currentColor; content: ""; display: inline-block; height: var(--icon-size); line-height: 1; mask: url("${browser.runtime.getURL('/resources/icons/folder-16.svg')}") no-repeat center / 60%; max-height: var(--icon-size); max-width: var(--icon-size); order: 2; width: var(--icon-size); } .parentIdChooserFull li > label > * { order: 3; } .parentIdChooserFull li > label > .label-text { overflow: hidden; text-overflow: ellipsis } li.editing > label > .label-text { display: none; } li.editing > label > input[type="text"] { display: flex; flex-grow: 1; } `; const DIALOG_STYLE = ` .itemContainer { align-items: stretch; display: flex; flex-direction: column; margin-block: 0.2em; margin-inline: 0; text-align: start; } .itemContainer.last { flex-grow: 1; flex-shrink: 1; } .itemContainer > label { display: flex; margin-block-end: 0.2em; white-space: nowrap; } .itemContainer > input[type="text"] { display: flex; } .itemContainer.dialog > input[type="text"] { min-width: 30em; } ${FOLDER_CHOOSER_STYLE} `; export async function bookmarkTab(tab, { parentId, showDialog } = {}) { try { if (!(await Permissions.isGranted(Permissions.BOOKMARKS))) throw new Error('not permitted'); } catch(_e) { notify({ title: browser.i18n.getMessage('bookmark_notification_notPermitted_title'), message: browser.i18n.getMessage(`bookmark_notification_notPermitted_message${isLinux() ? '_linux' : ''}`), url: `moz-extension://${location.host}/options/options.html#bookmarksPermissionSection` }); return null; } const parent = ( (await getItemById(parentId || configs.defaultBookmarkParentId)) || (await getItemById(configs.$default.defaultBookmarkParentId)) ); let title = tab.title; let url = tab.url; if (!parentId) parentId = parent?.id; if (showDialog) { const windowId = tab.windowId; const inline = location.pathname.startsWith('/sidebar/'); const inlineClass = inline ? 'inline' : 'dialog'; const BASE_ID = `dialog-${Date.now()}-${parseInt(Math.random() * 65000)}:`; const dialogParams = { content: `
    `.trim(), async onShown(container, { initFolderChooser, parentId, inline, isRTL }) { if (container.classList.contains('simulation')) return; container.classList.add('bookmark-dialog'); const [defaultItem, rootItems] = await Promise.all([ browser.runtime.sendMessage({ type: 'ws:get-bookmark-item-by-id', id: parentId }), browser.runtime.sendMessage({ type: 'ws:get-bookmark-child-items' }) ]); initFolderChooser({ defaultItem, rootItems, container, inline, isRTL, }); container.querySelector('[name="title"]').select(); }, inject: { initFolderChooser, parentId, inline, isRTL: isRTL(), }, buttons: [ browser.i18n.getMessage('bookmarkDialog_accept'), browser.i18n.getMessage('bookmarkDialog_cancel') ] }; let result; if (inline) { try { UserOperationBlocker.blockIn(windowId, { throbber: false }); result = await RichConfirm.show(dialogParams); } catch(_error) { result = { buttonIndex: -1 }; } finally { UserOperationBlocker.unblockIn(windowId, { throbber: false }); } } else { result = await Dialog.show(await browser.windows.get(windowId), { ...dialogParams, modal: true, type: 'dialog', url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), title: browser.i18n.getMessage('bookmarkDialog_dialogTitle_single') }); } if (result.buttonIndex != 0) return null; title = result.values[`${BASE_ID}title`]; url = result.values[`${BASE_ID}url`]; parentId = result.values[`${BASE_ID}parentId`]; } mCreatingCount++; const item = await browser.bookmarks.create({ parentId, title, url }).catch(ApiTabs.createErrorHandler()); wait(150).then(() => { mCreatingCount--; }); return item; } export async function bookmarkTabs(tabs, { parentId, index, showDialog, title } = {}) { try { if (!(await Permissions.isGranted(Permissions.BOOKMARKS))) throw new Error('not permitted'); } catch(_e) { notify({ title: browser.i18n.getMessage('bookmark_notification_notPermitted_title'), message: browser.i18n.getMessage('bookmark_notification_notPermitted_message'), url: `moz-extension://${location.host}/options/options.html#bookmarksPermissionSection` }); return null; } const now = new Date(); const year = String(now.getFullYear()); if (!title) title = PlaceHolderParser.process(configs.bookmarkTreeFolderName, (name, _rawArgs, ...args) => { switch (name.toLowerCase()) { case 'any': for (const arg of args) { if (!!arg) return arg; } return ''; case 'title': return tabs[0].title; case 'group': return tabs[0].isGroupTab ? tabs[0].title : ''; case 'url': return tabs[0].url; case 'short_year': case 'shortyear': return year.slice(-2); case 'full_year': case 'fullyear': case 'year': return year; case 'month': return String(now.getMonth() + 1).padStart(2, '0'); case 'date': return String(now.getDate()).padStart(2, '0'); case 'hour': case 'hours': return String(now.getHours()).padStart(2, '0'); case 'min': case 'minute': case 'minutes': return String(now.getMinutes()).padStart(2, '0'); case 'sec': case 'second': case 'seconds': return String(now.getSeconds()).padStart(2, '0'); case 'msec': case 'millisecond': case 'milliseconds': return String(now.getSeconds()).padStart(3, '0'); } }); const folderParams = { type: 'folder', title }; let parent; if (parentId) { parent = await getItemById(parentId); if (index !== undefined) folderParams.index = index; } else { parent = await getItemById(configs.defaultBookmarkParentId); } if (!parent) parent = await getItemById(configs.$default.defaultBookmarkParentId); if (parent) folderParams.parentId = parent.id; if (showDialog) { const windowId = tabs[0].windowId; const inline = location.pathname.startsWith('/sidebar/'); const inlineClass = inline ? 'inline' : 'dialog'; const BASE_ID = `dialog-${Date.now()}-${parseInt(Math.random() * 65000)}:`; const dialogParams = { content: `
      `.trim(), async onShown(container, { initFolderChooser, parentId, inline, isRTL }) { if (container.classList.contains('simulation')) return; container.classList.add('bookmark-dialog'); const [defaultItem, rootItems] = await Promise.all([ browser.runtime.sendMessage({ type: 'ws:get-bookmark-item-by-id', id: parentId }), browser.runtime.sendMessage({ type: 'ws:get-bookmark-child-items' }) ]); initFolderChooser({ defaultItem, rootItems, container, inline, isRTL, }); container.querySelector('[name="title"]').select(); }, inject: { initFolderChooser, parentId: folderParams.parentId, inline, isRTL: isRTL(), }, buttons: [ browser.i18n.getMessage('bookmarkDialog_accept'), browser.i18n.getMessage('bookmarkDialog_cancel') ] }; let result; if (inline) { try { UserOperationBlocker.blockIn(windowId, { throbber: false }); result = await RichConfirm.show(dialogParams); } catch(_error) { result = { buttonIndex: -1 }; } finally { UserOperationBlocker.unblockIn(windowId, { throbber: false }); } } else { result = await Dialog.show(await browser.windows.get(windowId), { ...dialogParams, modal: true, type: 'dialog', url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), title: browser.i18n.getMessage('bookmarkDialog_dialogTitle_multiple') }); } if (result.buttonIndex != 0) return null; folderParams.title = result.values[`${BASE_ID}title`]; folderParams.parentId = result.values[`${BASE_ID}parentId`]; } const toBeCreatedCount = tabs.length + 1; mCreatingCount += toBeCreatedCount; const titles = getTitlesWithTreeStructure(tabs); const folder = await browser.bookmarks.create(folderParams).catch(ApiTabs.createErrorHandler()); for (let i = 0, maxi = tabs.length; i < maxi; i++) { await browser.bookmarks.create({ parentId: folder.id, index: i, title: titles[i], url: tabs[i].url }).catch(ApiTabs.createErrorSuppressor()); } wait(150).then(() => { mCreatingCount -= toBeCreatedCount; }); return folder; } function getTitlesWithTreeStructure(tabs) { const minLevel = Math.min(...tabs.map(tab => parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || '0'))); const titles = []; for (let i = 0, maxi = tabs.length; i < maxi; i++) { const tab = tabs[i]; const title = tab.title; const level = parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || '0') - minLevel; let prefix = ''; for (let j = 0; j < level; j++) { prefix += '>'; } if (prefix) titles.push(`${prefix} ${title}`); else titles.push(title.replace(/^>+ /, '')); // if the page title has marker-like prefix, we need to remove it. } return titles; } // This large method have to contain everything required to simulate the folder // chooser of the bookmark creation dialog. // Bookmark creation dialog is loaded into a popup window and we use this large // method to inject the behavior of the folder chooser. export async function initFolderChooser({ rootItems, defaultItem, defaultValue, container, inline, isRTL } = {}) { const miniList = container.querySelector('select.parentIdChooserMini'); const fullList = container.querySelector('ul.parentIdChooserFull'); const fullListFocusibleContainer = container.querySelector('.parentIdChooserFullTreeContainer'); const fullContainer = container.querySelector('.parentIdChooserFullContainer'); const expandeFullListButton = container.querySelector('.showAllFolders'); const newFolderButton = container.querySelector('.newFolder'); const BASE_ID = `folderChooser-${Date.now()}-${parseInt(Math.random() * 65000)}:`; const ensureItemVisible = item => { const itemRect = item.querySelector('label').getBoundingClientRect(); const containerRect = fullListFocusibleContainer.getBoundingClientRect(); if (itemRect.top < containerRect.top) { fullListFocusibleContainer.scrollBy(0, itemRect.top - containerRect.top - (itemRect.height / 2)); } else if (itemRect.bottom > containerRect.bottom) { fullListFocusibleContainer.scrollBy(0, itemRect.bottom - containerRect.bottom + (itemRect.height / 2)); } }; const cancelEvent = event => { event.stopImmediatePropagation(); event.preventDefault(); }; //========================================================================== // Initialize mini chooser //========================================================================== for (const rootItem of rootItems) { const item = miniList.appendChild(document.createElement('option')); item.textContent = rootItem.title; item.value = rootItem.id; } miniList.appendChild(document.createElement('hr')); const expanderOption = miniList.appendChild(document.createElement('option')); expanderOption.textContent = browser.i18n.getMessage('bookmarkDialog_showAllFolders_label'); expanderOption.setAttribute('value', `${BASE_ID}expandChooser`); miniList.appendChild(document.createElement('hr')); const lastChosenOption = miniList.appendChild(document.createElement('option')); let lastChosenItem = defaultItem || defaultValue && await getItemById(defaultValue) || null; const getLastChosenItem = () => { return lastChosenItem || miniList.firstChild.$item || null; }; const updateLastChosenOption = () => { if (lastChosenItem) { lastChosenOption.value = lastChosenItem.id; lastChosenOption.textContent = lastChosenItem.title; lastChosenOption.style.display = ''; } else { lastChosenOption.style.display = 'none'; } miniList.value = getLastChosenItem().id; }; updateLastChosenOption(); let expanded = false; let fullChooserHeight = 0; const toggleFullChooser = async () => { expanded = !expanded; fullContainer.classList.toggle('expanded', expanded); expandeFullListButton.classList.toggle('expanded', expanded); if (!inline) { const fullContainerStyle = window.getComputedStyle(fullContainer, null); fullChooserHeight = Math.max( fullChooserHeight, Math.ceil(fullContainer.offsetHeight + parseFloat(fullContainerStyle.getPropertyValue('margin-block-start')) + parseFloat(fullContainerStyle.getPropertyValue('margin-block-end'))), 150 ); await browser.runtime.sendMessage({ type: 'ws:resize-bookmark-dialog-by', width: 0, height: expanded ? fullChooserHeight : -fullChooserHeight, }); } if (lastChosenItem) { const item = fullList.querySelector(`li[data-id="${lastChosenItem.id}"]`); if (item) ensureItemVisible(item); } }; //========================================================================== // Initialize expander //========================================================================== const getElementTarget = event => { return event.target.nodeType == Node.ELEMENT_NODE ? event.target : event.target.parentNode;; }; //========================================================================== // Initialize full chooser //========================================================================== fullList.level = 0; const exitAllEditings = () => { for (const item of fullList.querySelectorAll('li.editing')) { item.$exitTitleEdit(); } }; const getTargetItem = event => { const elementTarget = getElementTarget(event); return elementTarget?.closest('li'); }; const focusToItem = item => { if (!item) return; exitAllEditings(); for (const oldFocused of fullListFocusibleContainer.querySelectorAll('.focused')) { if (oldFocused == item) continue; oldFocused.classList.remove('focused'); } item.classList.add('focused'); lastChosenItem = item.$item; ensureItemVisible(item); updateLastChosenOption(); }; const toggleItemExpanded = item => { if (!item) return; item.classList.toggle('expanded'); if (item.classList.contains('expanded')) item.$completeFolderItem(); focusToItem(item); }; const expandOrDigIn = (event, focusedItem) => { if (!focusedItem.classList.contains('expanded')) { focusedItem.classList.add('expanded'); focusedItem.$completeFolderItem(); } else { const firstChild = focusedItem.querySelector('li'); if (firstChild) focusToItem(firstChild); } }; const collapseOrDigOut = (event, focusedItem) => { if (focusedItem.classList.contains('expanded')) { focusedItem.classList.remove('expanded'); } else { const nearestAncestor = focusedItem.parentNode.closest('li'); if (nearestAncestor) focusToItem(nearestAncestor); } }; const createNewSubFolder = async () => { const folder = await browser.runtime.sendMessage({ type: 'ws:create-new-bookmark-folder', parentId: getLastChosenItem().id, }); const parentItem = fullList.querySelector(`li[data-id="${folder.parentId}"]`); if (!parentItem) return; parentItem.$invalidate(); parentItem.classList.add('expanded'); await parentItem.$completeFolderItem(); const folderItem = parentItem.querySelector(`li[data-id="${folder.id}"]`); if (!folderItem) return; focusToItem(folderItem); folderItem.$enterTitleEdit(); }; const generateFolderItem = (folder, level) => { const item = document.createElement('li'); item.$item = folder; item.setAttribute('data-id', folder.id); const title = folder.title || browser.i18n.getMessage('bookmarkFolderChooser_blank'); const label = item.appendChild(document.createElement('label')); label.setAttribute('style', `padding-inline-start: calc(1.25em * ${level} + 0.25em);`); label.setAttribute('title', title); const twisty = label.appendChild(document.createElement('span')); twisty.setAttribute('class', 'twisty'); const text = label.appendChild(document.createElement('div')); text.setAttribute('class', 'label-text'); text.textContent = title; return item; }; const buildItems = async (items, container) => { const createdItems = []; for (const item of items) { if (item.type == 'bookmark' && /^place:parent=([^&]+)$/.test(item.url)) { // alias for special folders const realItem = await browser.runtime.sendMessage({ type: 'ws:get-bookmark-item-by-id', id: RegExp.$1 }); item.id = realItem.id; item.type = realItem.type; item.title = realItem.title; } if (item.type != 'folder') continue; if (container.querySelector(`li[data-id="${item.id}"]`)) continue; const folderItem = generateFolderItem(item, container.level); container.insertBefore(folderItem, 'index' in item ? container.childNodes[item.index] : null); createdItems.push(folderItem); folderItem.$completeFolderItem = async () => { if (!item.$fetched) { item.$fetched = true; item.children = (await browser.runtime.sendMessage({ type: 'ws:get-bookmark-child-items', id: item.id })).filter(item => item?.type == 'folder'); } folderItem.classList.toggle('noChild', !item.children || item.children.length == 0); if (item.children && item.children.length > 0) { let subFolderContainer = folderItem.querySelector('ul');; if (!subFolderContainer) { subFolderContainer = folderItem.appendChild(document.createElement('ul')); subFolderContainer.level = container.level + 1; } await buildItems(item.children, subFolderContainer); } return folderItem; }; folderItem.$invalidate = () => { item.$fetched = false; }; let titleField; folderItem.$enterTitleEdit = async () => { exitAllEditings(); if (!titleField) { const label = folderItem.querySelector('label'); folderItem.classList.add('editing'); titleField = label.appendChild(document.createElement('input')); titleField.setAttribute('type', 'text'); label.appendChild(titleField); titleField.value = item.title || browser.i18n.getMessage('bookmarkFolderChooser_blank'); } titleField.focus(); titleField.select(); }; folderItem.$exitTitleEdit = async () => { if (!titleField) return; browser.runtime.sendMessage({ type: 'ws:update-bookmark-folder', id: item.id, title: titleField.value, }); item.title = folderItem.querySelector('.label-text').textContent = titleField.value; folderItem.querySelector('label').setAttribute('title', titleField.value); if (lastChosenItem?.id == item.id) lastChosenItem.title = item.title; titleField.parentNode.removeChild(titleField); titleField = null; folderItem.classList.remove('editing'); updateLastChosenOption(); }; } return createdItems; }; const topLevelItems = await buildItems(rootItems, fullList); // Expand deeply nested tree until the chosen folder let itemToBeFocused = topLevelItems.length > 0 && topLevelItems[0]; if (lastChosenItem) { const ancestorIds = await browser.runtime.sendMessage({ type: 'ws:get-bookmark-ancestor-ids', id: lastChosenItem.id, }); for (const id of [...ancestorIds.reverse(), lastChosenItem.id]) { if (id == 'root________') continue; const item = fullList.querySelector(`li[data-id="${id}"]`); if (!item) break; itemToBeFocused = item; item.classList.add('expanded'); await item.$completeFolderItem(); } } if (itemToBeFocused) itemToBeFocused.classList.add('focused'); //========================================================================== // UI events handling //========================================================================== container.addEventListener('focus', event => { if (!getElementTarget(event)?.closest('input[type="text"], .parentIdChooserFullTreeContainer')) exitAllEditings(); }, { capture: true }); container.addEventListener('blur', event => { if (getElementTarget(event)?.closest('input[type="text"]')) { const editingItem = fullList.querySelector('li.editing'); if (editingItem) editingItem.$exitTitleEdit(); } }, { capture: true }); miniList.addEventListener('change', () => { if (miniList.value == `${BASE_ID}expandChooser`) { if (!fullContainer.classList.contains('expanded')) toggleFullChooser(); miniList.value = getLastChosenItem().id; return; } const fullListItem = fullList.querySelector(`li[data-id="${miniList.value}"]`); if (fullListItem) focusToItem(fullListItem); }); expandeFullListButton.addEventListener('click', event => { if (event.button != 0) return; toggleFullChooser(); }); expandeFullListButton.addEventListener('keydown', event => { const elementTarget = getElementTarget(event); if (elementTarget != expandeFullListButton) return; switch (event.key) { case 'Enter': cancelEvent(event); case 'Space': toggleFullChooser(); break; default: break; } }, { capture: true }); fullListFocusibleContainer.addEventListener('dblclick', event => { if (event.button != 0) return; if (getElementTarget(event)?.closest('.twisty')) return; const item = getTargetItem(event); if (item) item.$enterTitleEdit(); }); fullListFocusibleContainer.addEventListener('click', event => { if (event.button != 0) return; const target = getElementTarget(event); if (target?.closest('.twisty')) { toggleItemExpanded(getTargetItem(event)); } else if (!target?.closest('input[type="text"]')) { focusToItem(getTargetItem(event)); } }); fullListFocusibleContainer.addEventListener('keydown', event => { if (getElementTarget(event)?.closest('input[type="text"]') && event.key != 'Enter') return; const focusibleItems = [...fullList.querySelectorAll('li:not(li:not(.expanded) li)')]; const focusedItem = fullList.querySelector('li.focused'); const index = focusedItem ? focusibleItems.indexOf(focusedItem) : -1; switch (event.key) { case 'Enter': cancelEvent(event); if (focusedItem?.matches('.editing')) focusedItem.$exitTitleEdit(); toggleItemExpanded(focusedItem); break; case 'ArrowUp': { cancelEvent(event); const toBeFocused = focusibleItems[(index == 0 ? focusibleItems.length : index) - 1]; focusToItem(toBeFocused); }; break; case 'ArrowDown': { cancelEvent(event); const toBeFocused = focusibleItems[index == focusibleItems.length - 1 ? 0 : index + 1]; focusToItem(toBeFocused); }; break; case 'ArrowRight': cancelEvent(event); if (isRTL) collapseOrDigOut(event, focusedItem); else expandOrDigIn(event, focusedItem); break; case 'ArrowLeft': cancelEvent(event); if (isRTL) expandOrDigIn(event, focusedItem); else collapseOrDigOut(event, focusedItem); break; case 'PageUp': { cancelEvent(event); const toBeFocusedIndex = Math.min(focusibleItems.length - 1, Math.max(0, index - Math.floor(fullListFocusibleContainer.offsetHeight / focusedItem.offsetHeight) + 1)); const toBeFocused = focusibleItems[toBeFocusedIndex]; focusToItem(toBeFocused); }; break; case 'PageDown': { cancelEvent(event); const toBeFocusedIndex = Math.min(focusibleItems.length - 1, Math.max(0, index + Math.floor(fullListFocusibleContainer.offsetHeight / focusedItem.offsetHeight) - 1)); const toBeFocused = focusibleItems[toBeFocusedIndex]; focusToItem(toBeFocused); }; break; case 'Home': cancelEvent(event); focusToItem(focusibleItems[0]); break; case 'End': cancelEvent(event); focusToItem(focusibleItems[focusibleItems.length - 1]); break; } }, { capture: true }); newFolderButton.addEventListener('click', event => { if (event.button != 0) return; createNewSubFolder(); }); newFolderButton.addEventListener('keydown', event => { const elementTarget = getElementTarget(event); if (elementTarget != newFolderButton) return; switch (event.key) { case 'Enter': cancelEvent(event); case 'Space': createNewSubFolder(); break; default: break; } }, { capture: true }); } let mCreatedBookmarks = []; let mIsTracking = false; async function onBookmarksCreated(id, bookmark) { if (!mIsTracking) return; log('onBookmarksCreated ', { id, bookmark }); if (mCreatingCount > 0) return; mCreatedBookmarks.push(bookmark); reserveToGroupCreatedBookmarks(); } function reserveToGroupCreatedBookmarks() { if (reserveToGroupCreatedBookmarks.reserved) clearTimeout(reserveToGroupCreatedBookmarks.reserved); reserveToGroupCreatedBookmarks.reserved = setTimeout(() => { reserveToGroupCreatedBookmarks.reserved = null; tryGroupCreatedBookmarks(); }, 250); } reserveToGroupCreatedBookmarks.reserved = null; reserveToGroupCreatedBookmarks.retryCount = 0; async function tryGroupCreatedBookmarks() { log('tryGroupCreatedBookmarks ', mCreatedBookmarks); if (!configs.autoCreateFolderForBookmarksFromTree) { log(' => autoCreateFolderForBookmarksFromTree is false'); return; } const lastDraggedTabs = configs.lastDraggedTabs; if (lastDraggedTabs && lastDraggedTabs.tabIds.length > mCreatedBookmarks.length) { if (reserveToGroupCreatedBookmarks.retryCount++ < 10) { return reserveToGroupCreatedBookmarks(); } else { reserveToGroupCreatedBookmarks.retryCount = 0; mCreatedBookmarks = []; configs.lastDraggedTabs = null; log(' => timeout'); return; } } reserveToGroupCreatedBookmarks.retryCount = 0; const bookmarks = mCreatedBookmarks; mCreatedBookmarks = []; if (lastDraggedTabs) { // accept only bookmarks from dragged tabs const digest = await sha1sum(bookmarks.map(tab => tab.url).join('\n')); configs.lastDraggedTabs = null; if (digest != lastDraggedTabs.urlsDigest) { log(' => digest mismatched ', { digest, last: lastDraggedTabs.urlsDigest }); return; } } if (bookmarks.length < 2) { log(' => ignore single bookmark'); return; } { // Do nothing if multiple bookmarks are created under // multiple parent folders by sync. const parentIds = new Set(); for (const bookmark of bookmarks) { parentIds.add(bookmark.parentId); } log('parentIds: ', parentIds); if (parentIds.size > 1) { log(' => ignore bookmarks created under multiple folders'); return; } } const tabs = lastDraggedTabs ? lastDraggedTabs.tabIds.map(id => Tab.get(id)) : (await Promise.all(bookmarks.map(async bookmark => { const tabs = await browser.tabs.query({ url: bookmark.url }); if (tabs.length == 0) return null; const tab = tabs.find(tab => tab.highlighted) || tabs[0]; return Tab.get(tab); }))).filter(tab => !!tab); log('tabs: ', tabs); if (tabs.length != bookmarks.length) { log(' => ignore bookmarks created from non-tab sources'); return; } const treeStructure = TreeBehavior.getTreeStructureFromTabs(tabs); log('treeStructure: ', treeStructure); const topLevelTabsCount = treeStructure.filter(item => item.parent < 0).length; if (topLevelTabsCount == treeStructure.length) { log(' => no need to group bookmarks from dragged flat tabs'); return; } let titles = getTitlesWithTreeStructure(tabs); if (tabs[0].$TST.isGroupTab && titles.filter(title => !/^>/.test(title)).length == 1) { log('delete needless bookmark for a group tab'); browser.bookmarks.remove(bookmarks[0].id); tabs.shift(); bookmarks.shift(); titles = getTitlesWithTreeStructure(tabs); } log('titles: ', titles); log('save tree structure to bookmarks'); for (let i = 0, maxi = bookmarks.length; i < maxi; i++) { const title = titles[i]; if (title == tabs[i].title) continue; browser.bookmarks.update(bookmarks[i].id, { title }); } log('ready to group bookmarks under a folder'); const parentId = bookmarks[0].parentId; { // Do nothing if all bookmarks are created under a new // blank folder. const allChildren = await browser.bookmarks.getChildren(parentId); log('allChildren.length vs bookmarks.length: ', allChildren.length, bookmarks.length); if (allChildren.length == bookmarks.length) { log(' => no need to create folder for bookmarks under a new blank folder'); return; } } log('create a folder for grouping'); mCreatingCount++; const folder = await browser.bookmarks.create({ type: 'folder', title: bookmarks[0].title, index: bookmarks[0].index, parentId }).catch(ApiTabs.createErrorHandler()); wait(150).then(() => { mCreatingCount--; }); log('move into a folder'); let movedCount = 0; for (const bookmark of bookmarks) { await browser.bookmarks.move(bookmark.id, { parentId: folder.id, index: movedCount++ }); } } if (Constants.IS_BACKGROUND && browser.bookmarks && browser.bookmarks.onCreated) { // already granted browser.bookmarks.onCreated.addListener(onBookmarksCreated); mIsTracking = true; } export async function startTracking() { if (!mIsTracking || !Constants.IS_BACKGROUND) return; mIsTracking = true; const granted = await Permissions.isGranted(Permissions.BOOKMARKS); if (granted && !browser.bookmarks.onCreated.hasListener(onBookmarksCreated)) browser.bookmarks.onCreated.addListener(onBookmarksCreated); } export const BOOKMARK_TITLE_DESCENDANT_MATCHER = /^(>+) /; export async function getTreeStructureFromBookmarkFolder(folderOrId) { const items = folderOrId.children || await browser.bookmarks.getChildren(folderOrId.id || folderOrId); return getTreeStructureFromBookmarks(items.filter(item => item.type == 'bookmark')); } export function getTreeStructureFromBookmarks(items) { const lastItemIndicesWithLevel = new Map(); let lastMaxLevel = 0; return items.reduce((result, item, index) => { const { cookieStoreId, url } = ContextualIdentities.getIdFromBookmark(item); if (cookieStoreId) { item.cookieStoreId = cookieStoreId; if (url) item.url = url; } let level = 0; if (lastItemIndicesWithLevel.size > 0 && item.title.match(BOOKMARK_TITLE_DESCENDANT_MATCHER)) { level = RegExp.$1.length; if (level - lastMaxLevel > 1) { level = lastMaxLevel + 1; } else { while (lastMaxLevel > level) { lastItemIndicesWithLevel.delete(lastMaxLevel--); } } lastItemIndicesWithLevel.set(level, index); lastMaxLevel = level; result.push(lastItemIndicesWithLevel.get(level - 1) - lastItemIndicesWithLevel.get(0)); item.title = item.title.replace(BOOKMARK_TITLE_DESCENDANT_MATCHER, '') } else { result.push(-1); lastItemIndicesWithLevel.clear(); lastItemIndicesWithLevel.set(0, index); } return result; }, []); }