Rewild Your Web
18
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1373 lines 39 kB view raw
1// SPDX-License-Identifier: AGPL-3.0-or-later 2 3import { 4 LitElement, 5 html, 6} from "beaver://shared/third_party/lit/lit-all.min.js"; 7import "./webview_menu.js"; 8import "./url_bar_overlay.js"; 9import "./context_menu.js"; 10import "./select_control.js"; 11import "./content_blocker_panel.js"; 12import "./color_picker.js"; 13 14export class WebView extends LitElement { 15 constructor(src, title, attrs = {}) { 16 super(); 17 18 this.src = src; 19 this.title = title; 20 this.favicon = ""; 21 this.canGoBack = false; 22 this.canGoForward = false; 23 this.themeColor = WebView.defaultThemeColor; 24 this.active = false; 25 26 this.iframe = undefined; 27 this.attrs = attrs; 28 this.webviewId = null; // Set by LayoutManager 29 this.menuOpen = false; 30 this.urlBarOpen = false; 31 this.currentUrl = src || ""; 32 33 // Cached screenshot for overview mode 34 this.screenshotUrl = null; 35 36 // Context menu state 37 this.contextMenu = null; 38 39 // Select control state 40 this.selectControl = null; 41 42 // Color picker state 43 this.colorPicker = null; 44 45 // Load status for progress indicator 46 this.loadStatus = "idle"; 47 48 // Inline task provider state 49 this.inlineProvider = null; 50 51 // Content blocker state 52 this.blockerOrigin = ""; 53 this.blockerEnabled = true; 54 this.blockerCount = 0; 55 this.blockerAllowed = 0; 56 this.blockerBaseline = 0; 57 this.blockerPanelOpen = false; 58 this._needsBaselineReset = true; 59 } 60 61 get blockerBadgeCount() { 62 return Math.max(0, this.blockerCount - this.blockerBaseline); 63 } 64 65 handleMenuAction(e) { 66 const action = e.detail.action; 67 switch (action) { 68 case "split-horizontal": 69 this.splitHorizontal(); 70 break; 71 case "split-vertical": 72 this.splitVertical(); 73 break; 74 case "reduce-size": 75 this.resizePanel(-10); 76 break; 77 case "increase-size": 78 this.resizePanel(10); 79 break; 80 case "zoom-in": 81 this.zoomIn(); 82 break; 83 case "zoom-out": 84 this.zoomOut(); 85 break; 86 case "zoom-reset": 87 this.zoomReset(); 88 break; 89 } 90 } 91 92 handleMenuClosed() { 93 this.menuOpen = false; 94 } 95 96 resizePanel(delta) { 97 this.dispatchEvent( 98 new CustomEvent("webview-resize-panel", { 99 bubbles: true, 100 detail: { webviewId: this.webviewId, delta }, 101 }), 102 ); 103 } 104 105 zoomIn() { 106 this.ensureIframe(); 107 if (this.iframe) { 108 const currentZoom = this.iframe.getPageZoom(); 109 console.log("Current zoom:", currentZoom); 110 this.iframe.setPageZoom(currentZoom + 0.1); 111 } 112 } 113 114 zoomOut() { 115 this.ensureIframe(); 116 if (this.iframe) { 117 const currentZoom = this.iframe.getPageZoom(); 118 console.log("Current zoom:", currentZoom); 119 this.iframe.setPageZoom(currentZoom - 0.1); 120 } 121 } 122 123 zoomReset() { 124 this.ensureIframe(); 125 if (this.iframe) { 126 this.iframe.setPageZoom(1.0); 127 } 128 } 129 130 connectedCallback() { 131 super.connectedCallback(); 132 } 133 134 disconnectedCallback() { 135 super.disconnectedCallback(); 136 } 137 138 static defaultThemeColor = "gray"; 139 140 static properties = { 141 src: {}, 142 title: { state: true }, 143 favicon: { state: true }, 144 canGoBack: { state: true }, 145 canGoForward: { state: true }, 146 themeColor: { state: true }, 147 active: { state: true }, 148 menuOpen: { state: true }, 149 menuPosition: { state: true }, 150 urlBarOpen: { state: true }, 151 currentUrl: { state: true }, 152 contextMenu: { state: true }, 153 selectControl: { state: true }, 154 colorPicker: { state: true }, 155 loadStatus: { state: true }, 156 inlineProvider: { state: true }, 157 blockerOrigin: { state: true }, 158 blockerEnabled: { state: true }, 159 blockerCount: { state: true }, 160 blockerAllowed: { state: true }, 161 blockerPanelOpen: { state: true }, 162 blockerPanelX: { state: true }, 163 blockerPanelY: { state: true }, 164 }; 165 166 ensureIframe() { 167 if (!this.iframe) { 168 this.iframe = this.shadowRoot.querySelector("iframe"); 169 // Update the screenshot when resizing the web-view 170 const resizeObserver = new ResizeObserver((entries) => { 171 this.captureScreenshot(); 172 }); 173 resizeObserver.observe(this); 174 } 175 } 176 177 // Get the content iframe element (for screenshot capture, etc.) 178 getContentIframe() { 179 this.ensureIframe(); 180 return this.iframe; 181 } 182 183 ontitlechange(event) { 184 if (event.detail) { 185 console.log(`ontitlechange: ${event.detail}`); 186 this.title = event.detail; 187 } 188 } 189 190 onfaviconchange(event) { 191 const blob = event.detail; 192 if (blob) { 193 // Revoke old URL to free memory 194 if (this.favicon && this.favicon.startsWith("blob:")) { 195 URL.revokeObjectURL(this.favicon); 196 } 197 this.favicon = URL.createObjectURL(blob); 198 199 // Notify parent (LayoutManager) about favicon change 200 this.dispatchEvent( 201 new CustomEvent("webview-favicon-change", { 202 bubbles: true, 203 detail: { webviewId: this.webviewId, favicon: this.favicon }, 204 }), 205 ); 206 } 207 } 208 209 onthemecolorchange(event) { 210 this.themeColor = event.detail; 211 } 212 213 onloadstatuschange(event) { 214 console.log("[WebView] Load status changed:", event.detail); 215 this.loadStatus = event.detail; 216 217 if (event.detail === "started") { 218 this._needsBaselineReset = true; 219 } 220 221 // Apply pending spatial navigation after load completes 222 if (event.detail === "complete") { 223 this._applySpatialNavigation(); 224 225 // Auto-reset to idle after complete animation finishes 226 setTimeout(() => { 227 this.loadStatus = "idle"; 228 }, 500); 229 } 230 } 231 232 oncontrolshow(event) { 233 const detail = event.detail; 234 console.log("[EmbedderControl] SHOW event received:", detail); 235 236 if (detail.controlType === "select" && detail.selectParameters?.options) { 237 const params = detail.selectParameters; 238 239 // Show the select control 240 this.selectControl = { 241 controlId: detail.controlId, 242 options: params.options, 243 selectedIndex: params.selectedIndex, 244 x: detail.position?.x || 0, 245 y: detail.position?.y || 0, 246 }; 247 } else if (detail.controlType === "color" && detail.colorParameters) { 248 const params = detail.colorParameters; 249 250 // Show the color picker 251 this.colorPicker = { 252 controlId: detail.controlId, 253 currentColor: params.currentColor, 254 x: detail.position?.x || 0, 255 y: detail.position?.y || 0, 256 }; 257 } else if ( 258 detail.controlType === "contextmenu" && 259 detail.contextMenuParameters?.items 260 ) { 261 this.buildContextMenu(detail); 262 } else if ( 263 detail.controlType === "permission" && 264 detail.permissionParameters 265 ) { 266 const params = detail.permissionParameters; 267 268 // Show the permission prompt 269 this.currentPermission = { 270 controlId: detail.controlId, 271 feature: params.feature, 272 featureName: params.featureName, 273 }; 274 this.requestUpdate(); 275 } else if ( 276 detail.controlType === "inputmethod" && 277 detail.inputMethodParameters 278 ) { 279 const params = detail.inputMethodParameters; 280 281 // Bubble up to parent system window for virtual keyboard 282 this.dispatchEvent( 283 new CustomEvent("webview-inputmethod-show", { 284 bubbles: true, 285 composed: true, 286 detail: { 287 controlId: detail.controlId, 288 inputType: params.inputType || "text", 289 currentValue: params.currentValue || "", 290 placeholder: params.placeholder || "", 291 position: detail.position, 292 }, 293 }), 294 ); 295 } 296 } 297 298 oncontrolhide(event) { 299 console.log("[EmbedderControl] HIDE event received:", event.detail); 300 301 // Close context menu if it's the one being hidden 302 if ( 303 this.contextMenu && 304 this.contextMenu.controlId === event.detail.controlId 305 ) { 306 this.contextMenu = null; 307 } 308 309 // Close select control if it's the one being hidden 310 if ( 311 this.selectControl && 312 this.selectControl.controlId === event.detail.controlId 313 ) { 314 this.selectControl = null; 315 } 316 317 // Close color picker if it's the one being hidden 318 if ( 319 this.colorPicker && 320 this.colorPicker.controlId === event.detail.controlId 321 ) { 322 this.colorPicker = null; 323 } 324 325 // Close permission prompt if it's the one being hidden 326 if ( 327 this.currentPermission && 328 this.currentPermission.controlId === event.detail.controlId 329 ) { 330 this.currentPermission = null; 331 this.requestUpdate(); 332 } 333 334 // Bubble up inputmethod hide event to parent system window 335 // We always send the hide event as it's handled at the system level 336 this.dispatchEvent( 337 new CustomEvent("webview-inputmethod-hide", { 338 bubbles: true, 339 composed: true, 340 detail: { controlId: event.detail.controlId }, 341 }), 342 ); 343 } 344 345 ondialogshow(event) { 346 const detail = event.detail; 347 console.log("[EmbedderDialog] SHOW event received:", detail); 348 const dialogType = detail.dialogType; 349 const controlId = detail.controlId; 350 const message = detail.message; 351 const defaultValue = detail.defaultValue; 352 353 // Store the current dialog info for rendering 354 this.currentDialog = { 355 type: dialogType, 356 controlId, 357 message, 358 defaultValue: defaultValue || "", 359 }; 360 this.requestUpdate(); 361 } 362 363 onnotificationshow(event) { 364 const detail = event.detail; 365 console.log("[Notification] SHOW event received:", detail); 366 367 // Dispatch the notification to the parent (index.js) for global handling 368 // Include the webviewId so the notification center can focus the source webview 369 this.dispatchEvent( 370 new CustomEvent("webview-notification", { 371 bubbles: true, 372 composed: true, 373 detail: { 374 webviewId: this.webviewId, 375 title: detail.title, 376 body: detail.body, 377 tag: detail.tag, 378 iconUrl: detail.iconUrl, 379 }, 380 }), 381 ); 382 } 383 384 onmediasessionevent(event) { 385 const detail = event.detail; 386 console.log(`[MediaSession] ${detail.eventType}:`, detail); 387 this.dispatchEvent( 388 new CustomEvent("webview-mediasession", { 389 bubbles: true, 390 composed: true, 391 detail: { 392 webviewId: this.webviewId, 393 ...detail, 394 }, 395 }), 396 ); 397 } 398 399 handleDialogConfirm(inputValue = null) { 400 this.ensureIframe(); 401 const dialog = this.currentDialog; 402 if (!dialog) { 403 return; 404 } 405 406 console.log("[EmbedderDialog] User confirmed dialog:", dialog.type); 407 408 switch (dialog.type) { 409 case "alert": 410 this.iframe.respondToAlert(dialog.controlId); 411 break; 412 case "confirm": 413 this.iframe.respondToConfirm(dialog.controlId, true); 414 break; 415 case "prompt": 416 this.iframe.respondToPrompt(dialog.controlId, inputValue); 417 break; 418 } 419 420 this.currentDialog = null; 421 this.requestUpdate(); 422 } 423 424 handleDialogCancel() { 425 this.ensureIframe(); 426 const dialog = this.currentDialog; 427 if (!dialog) { 428 return; 429 } 430 431 console.log("[EmbedderDialog] User cancelled dialog:", dialog.type); 432 433 switch (dialog.type) { 434 case "alert": 435 // Alert only has OK, but handle cancel just in case 436 this.iframe.respondToAlert(dialog.controlId); 437 break; 438 case "confirm": 439 this.iframe.respondToConfirm(dialog.controlId, false); 440 break; 441 case "prompt": 442 this.iframe.respondToPrompt(dialog.controlId, null); 443 break; 444 } 445 446 this.currentDialog = null; 447 this.requestUpdate(); 448 } 449 450 handlePermissionAllow() { 451 this.ensureIframe(); 452 const permission = this.currentPermission; 453 if (!permission) { 454 return; 455 } 456 457 console.log( 458 "[EmbedderPermission] User allowed permission:", 459 permission.feature, 460 ); 461 this.iframe.respondToPermissionPrompt(permission.controlId, true); 462 this.currentPermission = null; 463 this.requestUpdate(); 464 } 465 466 handlePermissionDeny() { 467 this.ensureIframe(); 468 const permission = this.currentPermission; 469 if (!permission) { 470 return; 471 } 472 473 console.log( 474 "[EmbedderPermission] User denied permission:", 475 permission.feature, 476 ); 477 this.iframe.respondToPermissionPrompt(permission.controlId, false); 478 this.currentPermission = null; 479 this.requestUpdate(); 480 } 481 482 async buildContextMenu(detail) { 483 const params = detail.contextMenuParameters; 484 485 // Map action IDs to icons (matching title bar icons) 486 const actionIcons = { 487 GoBack: "arrow-left", 488 GoForward: "arrow-right", 489 Reload: "rotate-ccw", 490 }; 491 492 // Enrich items with icons 493 const items = params.items.map((item) => ({ 494 ...item, 495 icon: actionIcons[item.id] || item.icon, 496 })); 497 498 // Add P2P "Open/Send in <peer>" items for connected paired peers. 499 try { 500 const peers = await navigator.embedder.pairing.peers(); 501 const connected = peers.filter((p) => p.status === "paired-connected"); 502 if (connected.length > 0) { 503 const peerItems = (idPrefix, label, url) => 504 connected.map((peer) => ({ 505 id: `${idPrefix}:${peer.id}:${url}`, 506 label: `${label} ${peer.displayName}`, 507 icon: "send", 508 disabled: false, 509 })); 510 511 // Insert "Open Link in <peer>" after "OpenLinkInNewWebView" 512 if (params.linkUrl) { 513 const idx = items.findIndex( 514 (i) => i.id === "OpenLinkInNewWebView", 515 ); 516 if (idx !== -1) { 517 items.splice( 518 idx + 1, 519 0, 520 ...peerItems("p2p_open_link", "Open Link in", params.linkUrl), 521 ); 522 } 523 } 524 525 // Insert "Open Image in <peer>" after "OpenImageInNewView" 526 if (params.imageUrl) { 527 const idx = items.findIndex( 528 (i) => i.id === "OpenImageInNewView", 529 ); 530 if (idx !== -1) { 531 items.splice( 532 idx + 1, 533 0, 534 ...peerItems( 535 "p2p_open_image", 536 "Open Image in", 537 params.imageUrl, 538 ), 539 ); 540 } 541 } 542 543 // "Open this view in <peer>" at the end (for current page) 544 items.push( 545 ...peerItems("p2p_open_in", "Open in", this.currentUrl), 546 ); 547 } 548 } catch { 549 // Pairing service may not be running — skip the items. 550 } 551 552 // Add "Authorize ATProto" if the user is logged in and the current 553 // page's origin isn't already a privileged local page. 554 try { 555 await navigator.atproto.current(); 556 const origin = new URL(this.currentUrl).origin; 557 if (!origin.startsWith("beaver://")) { 558 items.push({ 559 id: `atproto_authorize:${origin}`, 560 label: "Authorize ATProto access", 561 icon: "at-sign", 562 disabled: false, 563 }); 564 } 565 } catch { 566 // Not logged in — skip. 567 } 568 569 // In mobile mode, show the radial menu instead of the regular context menu 570 if (document.body.classList.contains("mobile-mode")) { 571 // Extract navigation state from context menu items 572 const navState = { 573 canGoBack: params.items.some( 574 (item) => item.id === "GoBack" && !item.disabled, 575 ), 576 canGoForward: params.items.some( 577 (item) => item.id === "GoForward" && !item.disabled, 578 ), 579 }; 580 581 // Filter items to remove actions that are part of radial menu 582 const filteredItems = items.filter( 583 (item) => 584 item.id !== "GoBack" && 585 item.id !== "GoForward" && 586 item.id !== "Reload", 587 ); 588 589 // Store pending context menu for later 590 this.pendingContextMenu = { 591 controlId: detail.controlId, 592 items: filteredItems, 593 x: detail.position?.x || 0, 594 y: detail.position?.y || 0, 595 }; 596 597 // Dispatch event to show radial menu at the touch position 598 this.dispatchEvent( 599 new CustomEvent("webview-show-radial-menu", { 600 bubbles: true, 601 composed: true, 602 detail: { 603 x: detail.position?.x || 0, 604 y: detail.position?.y || 0, 605 canGoBack: navState.canGoBack, 606 canGoForward: navState.canGoForward, 607 contextMenu: this.pendingContextMenu, 608 }, 609 }), 610 ); 611 612 // Don't respond yet - radial menu will handle it 613 return; 614 } 615 616 // Show the context menu 617 this.contextMenu = { 618 controlId: detail.controlId, 619 items, 620 x: detail.position?.x || 0, 621 y: detail.position?.y || 0, 622 }; 623 } 624 625 async handleContextMenuAction(e) { 626 const { action, controlId } = e.detail; 627 console.log( 628 "[ContextMenu] Action selected:", 629 action, 630 "Control ID:", 631 controlId, 632 ); 633 634 this.ensureIframe(); 635 636 // Handle P2P "Open in <peer>" actions locally. 637 if ( 638 action.startsWith("p2p_open_in:") || 639 action.startsWith("p2p_open_link:") || 640 action.startsWith("p2p_open_image:") 641 ) { 642 this.iframe.respondToContextMenu(controlId, null); 643 this.contextMenu = null; 644 // Format: "p2p_<type>:<peerId>:<url>" 645 const firstColon = action.indexOf(":"); 646 const secondColon = action.indexOf(":", firstColon + 1); 647 const peerId = action.slice(firstColon + 1, secondColon); 648 const url = action.slice(secondColon + 1); 649 this.dispatchEvent( 650 new CustomEvent("p2p-open-in", { 651 bubbles: true, 652 composed: true, 653 detail: { peerId, url }, 654 }), 655 ); 656 return; 657 } 658 659 // Handle "Authorize ATProto" action. 660 if (action.startsWith("atproto_authorize:")) { 661 this.iframe.respondToContextMenu(controlId, null); 662 this.contextMenu = null; 663 const origin = action.slice("atproto_authorize:".length); 664 try { 665 await navigator.atproto.authorizeOrigin(origin); 666 console.log(`[ATProto] Authorized origin: ${origin}`); 667 } catch (e) { 668 console.error(`[ATProto] Failed to authorize origin:`, e); 669 } 670 return; 671 } 672 673 // Send the action back to the embedded webview for handling 674 // The embedded webview will process the action (GoBack, Copy, Paste, etc.) 675 this.iframe.respondToContextMenu(controlId, action); 676 this.contextMenu = null; 677 } 678 679 handleContextMenuCancel(e) { 680 const { controlId } = e.detail; 681 console.log("[ContextMenu] Menu cancelled, Control ID:", controlId); 682 683 this.ensureIframe(); 684 // Send null action to indicate cancellation 685 this.iframe.respondToContextMenu(controlId, null); 686 this.contextMenu = null; 687 } 688 689 handleContextMenuClosed() { 690 this.contextMenu = null; 691 } 692 693 // Show the pending context menu (called from radial menu's context-menu action) 694 showPendingContextMenu() { 695 if (this.pendingContextMenu) { 696 this.contextMenu = this.pendingContextMenu; 697 this.pendingContextMenu = null; 698 } 699 } 700 701 // Show an inline task provider overlay within this webview. 702 showInlineProvider(url, title) { 703 this.inlineProvider = { url, title }; 704 } 705 706 // Close the inline task provider overlay. 707 closeInlineProvider() { 708 this.inlineProvider = null; 709 } 710 711 // Dismiss the pending context menu without showing it 712 dismissPendingContextMenu() { 713 if (this.pendingContextMenu) { 714 this.ensureIframe(); 715 this.iframe.respondToContextMenu(this.pendingContextMenu.controlId, null); 716 this.pendingContextMenu = null; 717 } 718 } 719 720 handleSelectOption(e) { 721 const { controlId, index } = e.detail; 722 console.log( 723 "[SelectControl] Option selected, index:", 724 index, 725 "Control ID:", 726 controlId, 727 ); 728 729 this.ensureIframe(); 730 this.iframe.respondToSelectControl(controlId, index); 731 this.selectControl = null; 732 } 733 734 handleSelectCancel(e) { 735 const { controlId } = e.detail; 736 console.log("[SelectControl] Selection cancelled, Control ID:", controlId); 737 738 this.ensureIframe(); 739 // Send -1 to indicate cancellation (no selection) 740 this.iframe.respondToSelectControl(controlId, -1); 741 this.selectControl = null; 742 } 743 744 handleSelectClosed() { 745 this.selectControl = null; 746 } 747 748 handleColorConfirm(e) { 749 const { controlId, color } = e.detail; 750 console.log( 751 "[ColorPicker] Color confirmed:", 752 color, 753 "Control ID:", 754 controlId, 755 ); 756 757 this.ensureIframe(); 758 this.iframe.respondToColorPicker(controlId, color); 759 this.colorPicker = null; 760 } 761 762 handleColorCancel(e) { 763 const { controlId } = e.detail; 764 console.log("[ColorPicker] Color cancelled, Control ID:", controlId); 765 766 this.ensureIframe(); 767 // Send null to indicate cancellation 768 this.iframe.respondToColorPicker(controlId, null); 769 this.colorPicker = null; 770 } 771 772 handleColorClosed() { 773 this.colorPicker = null; 774 } 775 776 renderDialog() { 777 if (!this.currentDialog) { 778 return ""; 779 } 780 781 const dialog = this.currentDialog; 782 783 if (dialog.type === "alert") { 784 return html` 785 <div class="dialog-overlay" @click=${(e) => e.stopPropagation()}> 786 <div class="dialog-box"> 787 <div class="dialog-message">${dialog.message}</div> 788 <div class="dialog-buttons"> 789 <button 790 class="dialog-button primary" 791 @click=${() => this.handleDialogConfirm()} 792 > 793 OK 794 </button> 795 </div> 796 </div> 797 </div> 798 `; 799 } else if (dialog.type === "confirm") { 800 return html` 801 <div class="dialog-overlay" @click=${(e) => e.stopPropagation()}> 802 <div class="dialog-box"> 803 <div class="dialog-message">${dialog.message}</div> 804 <div class="dialog-buttons"> 805 <button 806 class="dialog-button" 807 @click=${() => this.handleDialogCancel()} 808 > 809 Cancel 810 </button> 811 <button 812 class="dialog-button primary" 813 @click=${() => this.handleDialogConfirm()} 814 > 815 OK 816 </button> 817 </div> 818 </div> 819 </div> 820 `; 821 } else if (dialog.type === "prompt") { 822 return html` 823 <div class="dialog-overlay" @click=${(e) => e.stopPropagation()}> 824 <div class="dialog-box"> 825 <div class="dialog-message">${dialog.message}</div> 826 <input 827 type="text" 828 class="dialog-input" 829 .value=${dialog.defaultValue} 830 @keydown=${(e) => { 831 if (e.key === "Enter") { 832 this.handleDialogConfirm(e.target.value); 833 } else if (e.key === "Escape") { 834 this.handleDialogCancel(); 835 } 836 }} 837 id="dialog-prompt-input" 838 /> 839 <div class="dialog-buttons"> 840 <button 841 class="dialog-button" 842 @click=${() => this.handleDialogCancel()} 843 > 844 Cancel 845 </button> 846 <button 847 class="dialog-button primary" 848 @click=${() => { 849 const input = this.shadowRoot.querySelector( 850 "#dialog-prompt-input", 851 ); 852 this.handleDialogConfirm(input?.value || ""); 853 }} 854 > 855 OK 856 </button> 857 </div> 858 </div> 859 </div> 860 `; 861 } 862 863 return ""; 864 } 865 866 renderColorPicker() { 867 if (!this.colorPicker) { 868 return ""; 869 } 870 871 return html`<color-picker 872 ?open=${this.colorPicker !== null} 873 .currentColor=${this.colorPicker?.currentColor || "#000000"} 874 .x=${this.colorPicker?.x || 0} 875 .y=${this.colorPicker?.y || 0} 876 .controlId=${this.colorPicker?.controlId || ""} 877 @color-confirm=${this.handleColorConfirm} 878 @color-cancel=${this.handleColorCancel} 879 > 880 </color-picker>`; 881 } 882 883 renderInlineProvider() { 884 if (!this.inlineProvider) { 885 return ""; 886 } 887 888 return html` 889 <div class="inline-provider-overlay" @click=${() => this.closeInlineProvider()}> 890 <div class="inline-provider-dialog" @click=${(e) => e.stopPropagation()}> 891 <div class="inline-provider-header"> 892 <span class="inline-provider-title">${this.inlineProvider.title}</span> 893 <button class="inline-provider-close" @click=${() => this.closeInlineProvider()}> 894 &times; 895 </button> 896 </div> 897 <iframe 898 embed 899 src="${this.inlineProvider.url}" 900 @embedclosed=${() => this.closeInlineProvider()} 901 ></iframe> 902 </div> 903 </div> 904 `; 905 } 906 907 renderPermissionPrompt() { 908 if (!this.currentPermission) { 909 return ""; 910 } 911 912 const permission = this.currentPermission; 913 914 // Permission-specific icons 915 const permissionIcons = { 916 geolocation: "map-pin", 917 camera: "camera", 918 microphone: "mic", 919 notifications: "bell", 920 bluetooth: "bluetooth", 921 }; 922 923 const icon = permissionIcons[permission.feature] || "shield"; 924 925 return html` 926 <div class="dialog-overlay" @click=${(e) => e.stopPropagation()}> 927 <div class="dialog-box permission-prompt"> 928 <div class="permission-icon"> 929 <lucide-icon name="${icon}"></lucide-icon> 930 </div> 931 <div class="permission-title">Permission Request</div> 932 <div class="dialog-message"> 933 This site wants to use your 934 <strong>${permission.featureName}</strong>. 935 </div> 936 <div class="dialog-buttons"> 937 <button 938 class="dialog-button" 939 @click=${() => this.handlePermissionDeny()} 940 > 941 Block 942 </button> 943 <button 944 class="dialog-button primary" 945 @click=${() => this.handlePermissionAllow()} 946 > 947 Allow 948 </button> 949 </div> 950 </div> 951 </div> 952 `; 953 } 954 955 renderUrlBarOverlay() { 956 if (!this.urlBarOpen) { 957 return ""; 958 } 959 960 return html`<url-bar-overlay 961 ?open=${this.urlBarOpen} 962 .url=${this.currentUrl} 963 .onNavigate=${(url) => this.handleNavigate(url)} 964 .onSelectWebView=${(windowId, webviewId) => 965 this.handleSelectWebView(windowId, webviewId)} 966 @close=${this.closeUrlBar} 967 ></url-bar-overlay>`; 968 } 969 970 onurlchange(event) { 971 this.ensureIframe(); 972 this.canGoBack = this.iframe.canGoBack(); 973 this.canGoForward = this.iframe.canGoForward(); 974 this.currentUrl = event.detail; 975 this.title = event.detail; 976 977 // Dispatch navigation state change event for action bar updates 978 this.dispatchEvent( 979 new CustomEvent("navigation-state-changed", { 980 bubbles: true, 981 composed: true, 982 detail: { 983 webviewId: this.webviewId, 984 canGoBack: this.canGoBack, 985 canGoForward: this.canGoForward, 986 url: this.currentUrl, 987 }, 988 }), 989 ); 990 991 // Capture screenshot for overview mode after a short delay 992 // to allow the page to render 993 this.captureScreenshot(); 994 995 this.updateBlockerStatus(); 996 } 997 998 async updateBlockerStatus() { 999 if (!this.currentUrl || !this.currentUrl.startsWith("http")) { 1000 this.blockerOrigin = ""; 1001 return; 1002 } 1003 try { 1004 const origin = new URL(this.currentUrl).origin; 1005 this.blockerOrigin = origin; 1006 const state = 1007 await navigator.embedder.contentBlocker.getOriginState(origin); 1008 this.blockerEnabled = state.enabled; 1009 this.blockerCount = state.blockedCount; 1010 this.blockerAllowed = state.allowedCount; 1011 if (this._needsBaselineReset) { 1012 this._needsBaselineReset = false; 1013 this.blockerBaseline = state.blockedCount; 1014 } 1015 } catch { 1016 this.blockerOrigin = ""; 1017 } 1018 } 1019 1020 handleBlockerClick(e) { 1021 e.stopPropagation(); 1022 if (!this.blockerPanelOpen) { 1023 const buttonRect = e.currentTarget.getBoundingClientRect(); 1024 const hostRect = this.getBoundingClientRect(); 1025 this.blockerPanelX = buttonRect.right - hostRect.left; 1026 this.blockerPanelY = buttonRect.bottom - hostRect.top; 1027 } 1028 this.blockerPanelOpen = !this.blockerPanelOpen; 1029 } 1030 1031 handleBlockerPanelDismiss() { 1032 this.blockerPanelOpen = false; 1033 this.updateBlockerStatus(); 1034 } 1035 1036 handleBlockerToggled(e) { 1037 this.blockerEnabled = e.detail.enabled; 1038 } 1039 1040 updated(_changedProperties) { 1041 if (this.active) { 1042 if (this.rafHandle) { 1043 cancelAnimationFrame(this.rafHandle); 1044 } 1045 this.rafHandle = requestAnimationFrame(() => 1046 this.captureScreenshot(true), 1047 ); 1048 } 1049 } 1050 1051 // Capture a screenshot and cache it for overview mode 1052 captureScreenshot(immediate = false) { 1053 // Debounce: clear any pending capture 1054 if (this.screenshotTimeout) { 1055 clearTimeout(this.screenshotTimeout); 1056 } 1057 1058 let doCapture = () => { 1059 this.getContentIframe() 1060 .takeScreenshot() 1061 .then((blob) => { 1062 if (blob) { 1063 // Revoke old URL to free memory 1064 if (this.screenshotUrl) { 1065 URL.revokeObjectURL(this.screenshotUrl); 1066 } 1067 this.screenshotUrl = URL.createObjectURL(blob); 1068 } 1069 }) 1070 .catch(() => { 1071 // Ignore screenshot errors (e.g., for off-screen webviews) 1072 }); 1073 }; 1074 1075 if (immediate) { 1076 doCapture(); 1077 return; 1078 } 1079 1080 // Delay capture to allow page to render 1081 this.screenshotTimeout = setTimeout(() => { 1082 doCapture(); 1083 }, 400); 1084 } 1085 1086 onfocus() { 1087 this.dispatchEvent( 1088 new CustomEvent("webview-focus", { 1089 bubbles: true, 1090 detail: { webviewId: this.webviewId }, 1091 }), 1092 ); 1093 } 1094 1095 // Programmatically focus this webview's iframe. 1096 focusWebview() { 1097 this.ensureIframe(); 1098 if (this.iframe) { 1099 try { 1100 this.iframe.forceFocus(); 1101 } catch (e) { 1102 console.warn("[WebView] forceFocus not available", e); 1103 } 1104 } 1105 } 1106 1107 // Enable or disable spatial navigation (arrow key focus movement) in this webview. 1108 // Waits for the page to load before sending the message, since the child 1109 // document doesn't exist until the pipeline is created. 1110 enableSpatialNavigation(enabled = true) { 1111 this._pendingSpatialNavigation = enabled; 1112 // Don't apply immediately — wait for onloadstatuschange("complete") 1113 // which means the child document exists and can receive the message. 1114 // loadStatus "idle" means the page hasn't started loading yet. 1115 if (this.loadStatus === "complete") { 1116 this._applySpatialNavigation(); 1117 } 1118 } 1119 1120 _applySpatialNavigation() { 1121 if (this._pendingSpatialNavigation === undefined) return; 1122 this.ensureIframe(); 1123 if (this.iframe) { 1124 try { 1125 this.iframe.useSpatialNavigation(this._pendingSpatialNavigation); 1126 } catch (e) { 1127 console.warn("[WebView] useSpatialNavigation not available", e); 1128 } 1129 } 1130 // Clear — the platform stores this on the browsing context 1131 // and propagates to new documents automatically. 1132 this._pendingSpatialNavigation = undefined; 1133 } 1134 1135 doReload() { 1136 this.ensureIframe(); 1137 this.iframe.reload(); 1138 } 1139 1140 goBack() { 1141 this.ensureIframe(); 1142 this.themeColor = WebView.defaultThemeColor; 1143 this.iframe.goBack(); 1144 } 1145 1146 goForward() { 1147 this.ensureIframe(); 1148 this.themeColor = WebView.defaultThemeColor; 1149 this.iframe.goForward(); 1150 } 1151 1152 close() { 1153 this.dispatchEvent( 1154 new CustomEvent("webview-close", { 1155 bubbles: true, 1156 detail: { webviewId: this.webviewId }, 1157 }), 1158 ); 1159 } 1160 1161 split(direction) { 1162 this.dispatchEvent( 1163 new CustomEvent("webview-split", { 1164 bubbles: true, 1165 detail: { webviewId: this.webviewId, direction }, 1166 }), 1167 ); 1168 } 1169 1170 splitHorizontal() { 1171 this.split("horizontal"); 1172 this.menuOpen = false; 1173 } 1174 1175 splitVertical() { 1176 this.split("vertical"); 1177 this.menuOpen = false; 1178 } 1179 1180 toggleMenu(e) { 1181 e.stopPropagation(); 1182 if (!this.menuOpen) { 1183 // Get the position of the menu button relative to the web-view 1184 const buttonRect = e.target.getBoundingClientRect(); 1185 const hostRect = this.getBoundingClientRect(); 1186 this.menuPosition = { 1187 x: buttonRect.right - hostRect.left, 1188 y: buttonRect.bottom - hostRect.top, 1189 }; 1190 } 1191 this.menuOpen = !this.menuOpen; 1192 } 1193 1194 closeMenu() { 1195 this.menuOpen = false; 1196 } 1197 1198 openUrlBar(e) { 1199 e.stopPropagation(); 1200 if (this.active) { 1201 this.urlBarOpen = true; 1202 } 1203 } 1204 1205 closeUrlBar() { 1206 this.urlBarOpen = false; 1207 } 1208 1209 handleNavigate(url) { 1210 this.ensureIframe(); 1211 this.iframe.load(url); 1212 this.urlBarOpen = false; 1213 } 1214 1215 handleSelectWebView(windowId, webviewId) { 1216 // Use BroadcastChannel to tell the target window to select the webview 1217 const channel = new BroadcastChannel("servo-search"); 1218 channel.postMessage({ 1219 type: "selectWebView", 1220 id: Date.now(), 1221 targetWindowId: windowId, 1222 webviewId: webviewId, 1223 }); 1224 channel.close(); 1225 this.urlBarOpen = false; 1226 } 1227 1228 render() { 1229 // Only render adopt-* attributes if they have values 1230 const adoptAttrs = {}; 1231 if (this.attrs["adopt-webview-id"]) { 1232 adoptAttrs["adopt-webview-id"] = this.attrs["adopt-webview-id"]; 1233 } 1234 if (this.attrs["adopt-browsing-context-id"]) { 1235 adoptAttrs["adopt-browsing-context-id"] = 1236 this.attrs["adopt-browsing-context-id"]; 1237 } 1238 if (this.attrs["adopt-pipeline-id"]) { 1239 adoptAttrs["adopt-pipeline-id"] = this.attrs["adopt-pipeline-id"]; 1240 } 1241 this.attrs = {}; 1242 1243 return html` 1244 <link rel="stylesheet" href="/web_view.css" /> 1245 <div 1246 class="wrapper ${this.active ? "active" : ""}" 1247 @click=${this.onfocus} 1248 > 1249 <div 1250 class="bar ${this.active ? "" : "hidden"}" 1251 style="background-color: ${this 1252 .themeColor}; color: contrast-color(${this.themeColor});" 1253 > 1254 <div class="load-progress load-${this.loadStatus}"></div> 1255 <img src="${this.favicon}" class="icon" /> 1256 <lucide-icon 1257 name="arrow-left" 1258 class="icon enabled-${this.canGoBack}" 1259 color="contrast-color(${this.themeColor})" 1260 @click="${this.goBack}" 1261 ></lucide-icon> 1262 <lucide-icon 1263 name="arrow-right" 1264 class="icon enabled-${this.canGoForward}" 1265 color="contrast-color(${this.themeColor})" 1266 @click="${this.goForward}" 1267 ></lucide-icon> 1268 <lucide-icon 1269 name="rotate-ccw" 1270 class="icon" 1271 color="contrast-color(${this.themeColor})" 1272 @click="${this.doReload}" 1273 ></lucide-icon> 1274 <span class="title" @click=${this.openUrlBar}>${this.title}</span> 1275 ${this.blockerOrigin 1276 ? html` 1277 <div 1278 class="blocker-icon ${this.blockerEnabled ? "" : "disabled"}" 1279 @click=${this.handleBlockerClick} 1280 > 1281 <lucide-icon name="shield"></lucide-icon> 1282 ${this.blockerBadgeCount > 0 1283 ? html`<span class="blocker-badge">${this.blockerBadgeCount}</span>` 1284 : ""} 1285 </div> 1286 ` 1287 : ""} 1288 <div class="menu-container"> 1289 <lucide-icon 1290 @click=${this.toggleMenu} 1291 name="ellipsis-vertical" 1292 class="icon" 1293 color="contrast-color(${this.themeColor})" 1294 ></lucide-icon> 1295 </div> 1296 <lucide-icon 1297 @click=${this.close} 1298 name="x" 1299 class="icon" 1300 color="contrast-color(${this.themeColor})" 1301 ></lucide-icon> 1302 </div> 1303 <webview-menu 1304 ?open=${this.menuOpen} 1305 .x=${this.menuPosition?.x || 0} 1306 .y=${this.menuPosition?.y || 0} 1307 @menu-action=${this.handleMenuAction} 1308 @menu-closed=${this.handleMenuClosed} 1309 ></webview-menu> 1310 <context-menu 1311 ?open=${this.contextMenu !== null} 1312 .items=${this.contextMenu?.items || []} 1313 .x=${this.contextMenu?.x || 0} 1314 .y=${this.contextMenu?.y || 0} 1315 .controlId=${this.contextMenu?.controlId || ""} 1316 @menu-action=${this.handleContextMenuAction} 1317 @menu-cancel=${this.handleContextMenuCancel} 1318 @menu-closed=${this.handleContextMenuClosed} 1319 ></context-menu> 1320 <content-blocker-panel 1321 ?open=${this.blockerPanelOpen} 1322 .origin=${this.blockerOrigin} 1323 .enabled=${this.blockerEnabled} 1324 .blockedCount=${this.blockerCount} 1325 .allowedCount=${this.blockerAllowed} 1326 .x=${this.blockerPanelX || 0} 1327 .y=${this.blockerPanelY || 0} 1328 @panel-dismiss=${this.handleBlockerPanelDismiss} 1329 @blocker-toggled=${this.handleBlockerToggled} 1330 ></content-blocker-panel> 1331 ${this.renderUrlBarOverlay()} 1332 <div class="iframe-container"> 1333 <iframe 1334 embed 1335 adopt-webview-id=${adoptAttrs["adopt-webview-id"]} 1336 adopt-browsing-context-id=${adoptAttrs["adopt-browsing-context-id"]} 1337 adopt-pipeline-id=${adoptAttrs["adopt-pipeline-id"]} 1338 src="${this.src}" 1339 @embedtitlechange=${this.ontitlechange} 1340 @embedfaviconchange=${this.onfaviconchange} 1341 @embedthemecolorchange=${this.onthemecolorchange} 1342 @embedurlchange=${this.onurlchange} 1343 @embedinputreceived=${this.onfocus} 1344 @embedcontrolshow=${this.oncontrolshow} 1345 @embedcontrolhide=${this.oncontrolhide} 1346 @embeddialogshow=${this.ondialogshow} 1347 @embednotificationshow=${this.onnotificationshow} 1348 @embedloadstatuschange=${this.onloadstatuschange} 1349 @embedmediasessionevent=${this.onmediasessionevent} 1350 @embedclosed=${this.close} 1351 ></iframe> 1352 ${this.renderDialog()} ${this.renderPermissionPrompt()} 1353 <select-control 1354 ?open=${this.selectControl !== null} 1355 .options=${this.selectControl?.options || []} 1356 .selectedIndex=${this.selectControl?.selectedIndex ?? -1} 1357 .x=${this.selectControl?.x || 0} 1358 .y=${this.selectControl?.y || 0} 1359 .controlId=${this.selectControl?.controlId || ""} 1360 @select-option=${this.handleSelectOption} 1361 @select-cancel=${this.handleSelectCancel} 1362 @menu-closed=${this.handleSelectClosed} 1363 ></select-control> 1364 1365 ${this.renderColorPicker()} 1366 ${this.renderInlineProvider()} 1367 </div> 1368 </div> 1369 `; 1370 } 1371} 1372 1373customElements.define("web-view", WebView);