TabListView.sys.mjs (18173B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", 9 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 10 }); 11 12 import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs"; 13 14 function getContextMenu(window) { 15 return getChromeWindow(window).document.getElementById( 16 "SyncedTabsSidebarContext" 17 ); 18 } 19 20 function getTabsFilterContextMenu(window) { 21 return getChromeWindow(window).document.getElementById( 22 "SyncedTabsSidebarTabsFilterContext" 23 ); 24 } 25 26 /* 27 * TabListView 28 * 29 * Given a state, this object will render the corresponding DOM. 30 * It maintains no state of it's own. It listens for DOM events 31 * and triggers actions that may cause the state to change and 32 * ultimately the view to rerender. 33 */ 34 export function TabListView(window, props) { 35 this.props = props; 36 37 this._window = window; 38 this._doc = this._window.document; 39 40 this._tabsContainerTemplate = this._doc.getElementById( 41 "tabs-container-template" 42 ); 43 this._clientTemplate = this._doc.getElementById("client-template"); 44 this._emptyClientTemplate = this._doc.getElementById("empty-client-template"); 45 this._tabTemplate = this._doc.getElementById("tab-template"); 46 this.tabsFilter = this._doc.querySelector(".tabsFilter"); 47 48 this.container = this._doc.createElement("div"); 49 50 this._attachFixedListeners(); 51 52 this._setupContextMenu(); 53 } 54 55 TabListView.prototype = { 56 render(state) { 57 // Don't rerender anything; just update attributes, e.g. selection 58 if (state.canUpdateAll) { 59 this._update(state); 60 return; 61 } 62 // Rerender the tab list 63 if (state.canUpdateInput) { 64 this._updateSearchBox(state); 65 this._createList(state); 66 return; 67 } 68 // Create the world anew 69 this._create(state); 70 }, 71 72 // Create the initial DOM from templates 73 _create(state) { 74 let wrapper = this._doc.importNode( 75 this._tabsContainerTemplate.content, 76 true 77 ).firstElementChild; 78 this._clearChilden(); 79 this.container.appendChild(wrapper); 80 81 this.list = this.container.querySelector(".list"); 82 83 this._createList(state); 84 this._updateSearchBox(state); 85 86 this._attachListListeners(); 87 }, 88 89 _createList(state) { 90 this._clearChilden(this.list); 91 for (let client of state.clients) { 92 if (state.filter) { 93 this._renderFilteredClient(client); 94 } else { 95 this._renderClient(client); 96 } 97 } 98 if (this.list.firstElementChild) { 99 const firstTab = this.list.firstElementChild.querySelector( 100 ".item.tab:first-child .item-title" 101 ); 102 if (firstTab) { 103 firstTab.setAttribute("tabindex", 2); 104 } 105 } 106 }, 107 108 destroy() { 109 this._teardownContextMenu(); 110 this.container.remove(); 111 }, 112 113 _update(state) { 114 this._updateSearchBox(state); 115 for (let client of state.clients) { 116 let clientNode = this._doc.getElementById("item-" + client.id); 117 if (clientNode) { 118 this._updateClient(client, clientNode); 119 } 120 121 client.tabs.forEach((tab, index) => { 122 let tabNode = this._doc.getElementById( 123 "tab-" + client.id + "-" + index 124 ); 125 this._updateTab(tab, tabNode, index); 126 }); 127 } 128 }, 129 130 // Client rows are hidden when the list is filtered 131 _renderFilteredClient(client) { 132 client.tabs.forEach((tab, index) => { 133 let node = this._renderTab(client, tab, index); 134 this.list.appendChild(node); 135 }); 136 }, 137 138 _updateLastSyncTitle(lastModified, itemNode) { 139 let lastSync = new Date(lastModified); 140 let lastSyncTitle = getChromeWindow(this._window).gSync.formatLastSyncDate( 141 lastSync 142 ); 143 itemNode.setAttribute("title", lastSyncTitle); 144 }, 145 146 _renderClient(client) { 147 let itemNode = client.tabs.length 148 ? this._createClient(client) 149 : this._createEmptyClient(client); 150 151 itemNode.addEventListener("mouseover", () => 152 this._updateLastSyncTitle(client.lastModified, itemNode) 153 ); 154 155 this._updateClient(client, itemNode); 156 157 let tabsList = itemNode.querySelector(".item-tabs-list"); 158 client.tabs.forEach((tab, index) => { 159 let node = this._renderTab(client, tab, index); 160 tabsList.appendChild(node); 161 }); 162 163 this.list.appendChild(itemNode); 164 return itemNode; 165 }, 166 167 _renderTab(client, tab, index) { 168 let itemNode = this._createTab(tab); 169 this._updateTab(tab, itemNode, index); 170 return itemNode; 171 }, 172 173 _createClient() { 174 return this._doc.importNode(this._clientTemplate.content, true) 175 .firstElementChild; 176 }, 177 178 _createEmptyClient() { 179 return this._doc.importNode(this._emptyClientTemplate.content, true) 180 .firstElementChild; 181 }, 182 183 _createTab() { 184 return this._doc.importNode(this._tabTemplate.content, true) 185 .firstElementChild; 186 }, 187 188 _clearChilden(node) { 189 let parent = node || this.container; 190 while (parent.firstChild) { 191 parent.firstChild.remove(); 192 } 193 }, 194 195 // These listeners are attached only once, when we initialize the view 196 _attachFixedListeners() { 197 this.tabsFilter.addEventListener("input", this.onFilter.bind(this)); 198 this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this)); 199 this.tabsFilter.addEventListener("blur", this.onFilterBlur.bind(this)); 200 }, 201 202 // These listeners have to be re-created every time since we re-create the list 203 _attachListListeners() { 204 this.list.addEventListener("click", this.onClick.bind(this)); 205 this.list.addEventListener("mouseup", this.onMouseUp.bind(this)); 206 this.list.addEventListener("keydown", this.onKeyDown.bind(this)); 207 }, 208 209 _updateSearchBox(state) { 210 this.tabsFilter.value = state.filter; 211 if (state.inputFocused) { 212 this.tabsFilter.focus(); 213 } 214 }, 215 216 /** 217 * Update the element representing an item, ensuring it's in sync with the 218 * underlying data. 219 * 220 * @param {client} item - Item to use as a source. 221 * @param {Element} itemNode - Element to update. 222 */ 223 _updateClient(item, itemNode) { 224 itemNode.setAttribute("id", "item-" + item.id); 225 this._updateLastSyncTitle(item.lastModified, itemNode); 226 if (item.closed) { 227 itemNode.classList.add("closed"); 228 } else { 229 itemNode.classList.remove("closed"); 230 } 231 if (item.selected) { 232 itemNode.classList.add("selected"); 233 } else { 234 itemNode.classList.remove("selected"); 235 } 236 if (item.focused) { 237 itemNode.focus(); 238 } 239 itemNode.setAttribute("clientType", item.clientType); 240 itemNode.dataset.id = item.id; 241 itemNode.querySelector(".item-title").textContent = item.name; 242 }, 243 244 /** 245 * Update the element representing a tab, ensuring it's in sync with the 246 * underlying data. 247 * 248 * @param {tab} item - Item to use as a source. 249 * @param {Element} itemNode - Element to update. 250 */ 251 _updateTab(item, itemNode, index) { 252 itemNode.setAttribute("title", `${item.title}\n${item.url}`); 253 itemNode.setAttribute("id", "tab-" + item.client + "-" + index); 254 if (item.selected) { 255 itemNode.classList.add("selected"); 256 } else { 257 itemNode.classList.remove("selected"); 258 } 259 if (item.focused) { 260 itemNode.focus(); 261 } 262 itemNode.dataset.url = item.url; 263 264 itemNode.querySelector(".item-title").textContent = item.title; 265 266 if (item.icon) { 267 let icon = itemNode.querySelector(".item-icon-container"); 268 icon.style.backgroundImage = "url(" + item.icon + ")"; 269 } 270 }, 271 272 onMouseUp(event) { 273 if (event.which == 2) { 274 // Middle click 275 this.onClick(event); 276 } 277 }, 278 279 onClick(event) { 280 let itemNode = this._findParentItemNode(event.target); 281 if (!itemNode) { 282 return; 283 } 284 285 if (itemNode.classList.contains("tab")) { 286 let url = itemNode.dataset.url; 287 if (url) { 288 this.onOpenSelected(url, event); 289 } 290 } 291 292 // Middle click on a client 293 if (itemNode.classList.contains("client")) { 294 let where = lazy.BrowserUtils.whereToOpenLink(event); 295 if (where != "current") { 296 this._openAllClientTabs(itemNode, where); 297 } 298 } 299 300 if ( 301 event.target.classList.contains("item-twisty-container") && 302 event.which != 2 303 ) { 304 this.props.onToggleBranch(itemNode.dataset.id); 305 return; 306 } 307 308 let position = this._getSelectionPosition(itemNode); 309 this.props.onSelectRow(position); 310 }, 311 312 /** 313 * Handle a keydown event on the list box. 314 * 315 * @param {Event} event - Triggering event. 316 */ 317 onKeyDown(event) { 318 if (event.keyCode == this._window.KeyEvent.DOM_VK_DOWN) { 319 event.preventDefault(); 320 this.props.onMoveSelectionDown(); 321 } else if (event.keyCode == this._window.KeyEvent.DOM_VK_UP) { 322 event.preventDefault(); 323 this.props.onMoveSelectionUp(); 324 } else if (event.keyCode == this._window.KeyEvent.DOM_VK_RETURN) { 325 let selectedNode = this.container.querySelector(".item.selected"); 326 if (selectedNode.dataset.url) { 327 this.onOpenSelected(selectedNode.dataset.url, event); 328 } else if (selectedNode) { 329 this.props.onToggleBranch(selectedNode.dataset.id); 330 } 331 } 332 }, 333 334 onBookmarkTab() { 335 let item = this._getSelectedTabNode(); 336 if (item) { 337 let title = item.querySelector(".item-title").textContent; 338 this.props.onBookmarkTab(item.dataset.url, title); 339 } 340 }, 341 342 onCopyTabLocation() { 343 let item = this._getSelectedTabNode(); 344 if (item) { 345 this.props.onCopyTabLocation(item.dataset.url); 346 } 347 }, 348 349 onOpenSelected(url, event) { 350 let where = lazy.BrowserUtils.whereToOpenLink(event); 351 this.props.onOpenTab(url, where, {}); 352 }, 353 354 onOpenSelectedFromContextMenu(event) { 355 let item = this._getSelectedTabNode(); 356 if (item) { 357 let where = event.target.getAttribute("where"); 358 let params = { 359 private: event.target.hasAttribute("private"), 360 }; 361 this.props.onOpenTab(item.dataset.url, where, params); 362 } 363 }, 364 365 onOpenSelectedInContainerTab(event) { 366 let item = this._getSelectedTabNode(); 367 if (item) { 368 this.props.onOpenTab(item.dataset.url, "tab", { 369 userContextId: parseInt(event.target?.dataset.usercontextid), 370 }); 371 } 372 }, 373 374 onOpenAllInTabs() { 375 let item = this._getSelectedClientNode(); 376 if (item) { 377 this._openAllClientTabs(item, "tab"); 378 } 379 }, 380 381 onFilter(event) { 382 let query = event.target.value; 383 if (query) { 384 this.props.onFilter(query); 385 } else { 386 this.props.onClearFilter(); 387 } 388 }, 389 390 onFilterFocus() { 391 this.props.onFilterFocus(); 392 }, 393 onFilterBlur() { 394 this.props.onFilterBlur(); 395 }, 396 397 _getSelectedTabNode() { 398 let item = this.container.querySelector(".item.selected"); 399 if (this._isTab(item) && item.dataset.url) { 400 return item; 401 } 402 return null; 403 }, 404 405 _getSelectedClientNode() { 406 let item = this.container.querySelector(".item.selected"); 407 if (this._isClient(item)) { 408 return item; 409 } 410 return null; 411 }, 412 413 // Set up the custom context menu 414 _setupContextMenu() { 415 this._window.addEventListener("contextmenu", this, { 416 mozSystemGroup: true, 417 }); 418 for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) { 419 let menu = getMenu(this._window); 420 menu.addEventListener("popupshowing", this, true); 421 menu.addEventListener("command", this, true); 422 } 423 }, 424 425 _teardownContextMenu() { 426 // Tear down context menu 427 this._window.removeEventListener("contextmenu", this, { 428 mozSystemGroup: true, 429 }); 430 for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) { 431 let menu = getMenu(this._window); 432 menu.removeEventListener("popupshowing", this, true); 433 menu.removeEventListener("command", this, true); 434 } 435 }, 436 437 handleEvent(event) { 438 switch (event.type) { 439 case "contextmenu": 440 this.handleContextMenu(event); 441 break; 442 443 case "popupshowing": { 444 if ( 445 event.target.getAttribute("id") == 446 "SyncedTabsSidebarTabsFilterContext" 447 ) { 448 this.handleTabsFilterContextMenuShown(event); 449 } 450 break; 451 } 452 453 case "command": { 454 let menu = event.target.closest("menupopup"); 455 switch (menu.getAttribute("id")) { 456 case "SyncedTabsSidebarContext": 457 this.handleContentContextMenuCommand(event); 458 break; 459 460 case "SyncedTabsOpenSelectedInContainerTabMenu": 461 this.onOpenSelectedInContainerTab(event); 462 break; 463 464 case "SyncedTabsSidebarTabsFilterContext": 465 this.handleTabsFilterContextMenuCommand(event); 466 break; 467 } 468 break; 469 } 470 } 471 }, 472 473 handleTabsFilterContextMenuShown(event) { 474 let document = event.target.ownerDocument; 475 let focusedElement = document.commandDispatcher.focusedElement; 476 if (focusedElement != this.tabsFilter.inputField) { 477 this.tabsFilter.focus(); 478 } 479 for (let item of event.target.children) { 480 if (!item.hasAttribute("cmd")) { 481 continue; 482 } 483 let command = item.getAttribute("cmd"); 484 let controller = 485 document.commandDispatcher.getControllerForCommand(command); 486 if (controller.isCommandEnabled(command)) { 487 item.removeAttribute("disabled"); 488 } else { 489 item.setAttribute("disabled", "true"); 490 } 491 } 492 }, 493 494 handleContentContextMenuCommand(event) { 495 let id = event.target.getAttribute("id"); 496 switch (id) { 497 case "syncedTabsOpenSelected": 498 case "syncedTabsOpenSelectedInTab": 499 case "syncedTabsOpenSelectedInWindow": 500 case "syncedTabsOpenSelectedInPrivateWindow": 501 this.onOpenSelectedFromContextMenu(event); 502 break; 503 case "syncedTabsOpenAllInTabs": 504 this.onOpenAllInTabs(); 505 break; 506 case "syncedTabsBookmarkSelected": 507 this.onBookmarkTab(); 508 break; 509 case "syncedTabsCopySelected": 510 this.onCopyTabLocation(); 511 break; 512 case "syncedTabsRefresh": 513 case "syncedTabsRefreshFilter": 514 this.props.onSyncRefresh(); 515 break; 516 } 517 }, 518 519 handleTabsFilterContextMenuCommand(event) { 520 let command = event.target.getAttribute("cmd"); 521 let dispatcher = getChromeWindow(this._window).document.commandDispatcher; 522 let controller = 523 dispatcher.focusedElement.controllers.getControllerForCommand(command); 524 controller.doCommand(command); 525 }, 526 527 handleContextMenu(event) { 528 let menu; 529 530 if (event.target == this.tabsFilter) { 531 menu = getTabsFilterContextMenu(this._window); 532 } else { 533 let itemNode = this._findParentItemNode(event.target); 534 if (itemNode) { 535 let position = this._getSelectionPosition(itemNode); 536 this.props.onSelectRow(position); 537 } 538 menu = getContextMenu(this._window); 539 this.adjustContextMenu(menu); 540 } 541 542 menu.openPopupAtScreen(event.screenX, event.screenY, true, event); 543 }, 544 545 adjustContextMenu(menu) { 546 let item = this.container.querySelector(".item.selected"); 547 let showTabOptions = this._isTab(item); 548 549 let el = menu.firstElementChild; 550 551 while (el) { 552 let show = false; 553 if (showTabOptions) { 554 if (el.getAttribute("id") == "syncedTabsOpenSelectedInPrivateWindow") { 555 show = lazy.PrivateBrowsingUtils.enabled; 556 } else if ( 557 el.getAttribute("id") === "syncedTabsOpenSelectedInContainerTab" 558 ) { 559 show = 560 Services.prefs.getBoolPref("privacy.userContext.enabled", false) && 561 !lazy.PrivateBrowsingUtils.isWindowPrivate( 562 getChromeWindow(this._window) 563 ); 564 } else if ( 565 el.getAttribute("id") != "syncedTabsOpenAllInTabs" && 566 el.getAttribute("id") != "syncedTabsManageDevices" 567 ) { 568 show = true; 569 } 570 } else if (el.getAttribute("id") == "syncedTabsOpenAllInTabs") { 571 const tabs = item.querySelectorAll(".item-tabs-list > .item.tab"); 572 show = !!tabs.length; 573 } else if (el.getAttribute("id") == "syncedTabsRefresh") { 574 show = true; 575 } else if (el.getAttribute("id") == "syncedTabsManageDevices") { 576 show = true; 577 } 578 el.hidden = !show; 579 580 el = el.nextElementSibling; 581 } 582 }, 583 584 /** 585 * Find the parent item element, from a given child element. 586 * 587 * @param {Element} node - Child element. 588 * @returns {Element} Element for the item, or null if not found. 589 */ 590 _findParentItemNode(node) { 591 while ( 592 node && 593 node !== this.list && 594 node !== this._doc.documentElement && 595 !node.classList.contains("item") 596 ) { 597 node = node.parentNode; 598 } 599 600 if (node !== this.list && node !== this._doc.documentElement) { 601 return node; 602 } 603 604 return null; 605 }, 606 607 _findParentBranchNode(node) { 608 while ( 609 node && 610 !node.classList.contains("list") && 611 node !== this._doc.documentElement && 612 !node.parentNode.classList.contains("list") 613 ) { 614 node = node.parentNode; 615 } 616 617 if (node !== this.list && node !== this._doc.documentElement) { 618 return node; 619 } 620 621 return null; 622 }, 623 624 _getSelectionPosition(itemNode) { 625 let parent = this._findParentBranchNode(itemNode); 626 let parentPosition = this._indexOfNode(parent.parentNode, parent); 627 let childPosition = -1; 628 // if the node is not a client, find its position within the parent 629 if (parent !== itemNode) { 630 childPosition = this._indexOfNode(itemNode.parentNode, itemNode); 631 } 632 return [parentPosition, childPosition]; 633 }, 634 635 _indexOfNode(parent, child) { 636 return Array.prototype.indexOf.call(parent.children, child); 637 }, 638 639 _isTab(item) { 640 return item && item.classList.contains("tab"); 641 }, 642 643 _isClient(item) { 644 return item && item.classList.contains("client"); 645 }, 646 647 _openAllClientTabs(clientNode, where) { 648 const tabs = clientNode.querySelector(".item-tabs-list").children; 649 const urls = [...tabs].map(tab => tab.dataset.url); 650 this.props.onOpenTabs(urls, where); 651 }, 652 };