forked from
me.webbeef.org/browser.html
Rewild Your Web
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 ×
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);