1622 lines
60 KiB
JavaScript
1622 lines
60 KiB
JavaScript
/*
|
|
license: The MIT License, Copyright (c) 2018-2025 YUKI "Piro" Hiroshi
|
|
original:
|
|
https://github.com/piroor/webextensions-lib-rich-confirm
|
|
*/
|
|
'use strict';
|
|
|
|
(function defineRichConfirm(uniqueKey) {
|
|
class RichConfirm {
|
|
constructor(params) {
|
|
this.params = params;
|
|
if (!this.params.buttons)
|
|
this.params.buttons = ['OK'];
|
|
this.onClick = this.onClick.bind(this);
|
|
this.onKeyDown = this.onKeyDown.bind(this);
|
|
this.onKeyUp = this.onKeyUp.bind(this);
|
|
this.onContextMenu = this.onContextMenu.bind(this);
|
|
this.onUnload = this.onUnload.bind(this);
|
|
}
|
|
get commonClass() {
|
|
return `rich-confirm-${this.uniqueKey}`;
|
|
}
|
|
get dialog() {
|
|
return this.ui.querySelector('.rich-confirm-dialog');
|
|
}
|
|
get content() {
|
|
return this.ui.querySelector('.rich-confirm-content');
|
|
}
|
|
get buttonsContainer() {
|
|
return this.ui.querySelector('.rich-confirm-buttons');
|
|
}
|
|
get checkContainer() {
|
|
return this.ui.querySelector('.rich-confirm-check-label');
|
|
}
|
|
get checkCheckbox() {
|
|
return this.ui.querySelector('.rich-confirm-check-checkbox');
|
|
}
|
|
get checkMessage() {
|
|
return this.ui.querySelector('.rich-confirm-check-message');
|
|
}
|
|
|
|
get focusTargets() {
|
|
return Array.from(this.ui.querySelectorAll('input:not([type="hidden"]), textarea, select, button, *[tabindex]:not([tabindex^="-"])')).filter(node => node.offsetWidth > 0);
|
|
}
|
|
|
|
RTL_LANGUAGES = new Set([
|
|
'ar',
|
|
'he',
|
|
'fa',
|
|
'ur',
|
|
'ps',
|
|
'sd',
|
|
'ckb',
|
|
'prs',
|
|
'rhg',
|
|
]);
|
|
get isRTL() {
|
|
const lang = (
|
|
navigator.language ||
|
|
navigator.userLanguage ||
|
|
//(new Intl.DateTimeFormat()).resolvedOptions().locale ||
|
|
''
|
|
).split('-')[0];
|
|
return this.RTL_LANGUAGES.has(lang);
|
|
}
|
|
|
|
buildUI() {
|
|
if (this.ui)
|
|
return;
|
|
this.style = document.createElement('style');
|
|
this.style.setAttribute('type', 'text/css');
|
|
const common = `.${this.commonClass}`;
|
|
this.style.textContent = `
|
|
/* color scheme */
|
|
${common}.rich-confirm,
|
|
:root${common} {
|
|
/* https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/themes/shared/in-content/common.inc.css */
|
|
--in-content-page-color: var(--grey-90);
|
|
--in-content-page-background: var(--grey-10);
|
|
--in-content-text-color: var(--in-content-page-color);
|
|
--in-content-deemphasized-text: var(--grey-60);
|
|
--in-content-selected-text: #fff;
|
|
--in-content-box-background: #fff;
|
|
--in-content-box-background-hover: var(--grey-20);
|
|
--in-content-box-background-active: var(--grey-30);
|
|
--in-content-box-border-color: var(--grey-90-a30);
|
|
--in-content-box-border-color-mixed: rgb(calc((249 * 0.7) + (12 * 0.3)), calc((249 * 0.7) + (12 * 0.3)), calc((250 * 0.7) + (13 * 0.3)));
|
|
--in-content-box-info-background: var(--grey-20);
|
|
--in-content-item-hover: rgba(69, 161, 255, 0.2); /* blue 40 a20 */
|
|
--in-content-item-hover-mixed: rgb(calc((249 * 0.8) + (69 * 0.2)), calc((249 * 0.8) + (161 * 0.2)), calc((250 * 0.8) + (255 * 0.2)));
|
|
--in-content-item-selected: var(--blue-50);
|
|
--in-content-border-highlight: var(--blue-50);
|
|
--in-content-border-focus: var(--blue-50);
|
|
--in-content-border-hover: var(--grey-90-a50);
|
|
--in-content-border-hover-mixed: rgb(calc((249 * 0.5) + (12 * 0.5)), calc((249 * 0.5) + (12 * 0.5)), calc((250 * 0.5) + (13 * 0.5)));
|
|
--in-content-border-active: var(--blue-50);
|
|
--in-content-border-active-shadow: var(--blue-50-a30);
|
|
--in-content-border-invalid: var(--red-50);
|
|
--in-content-border-invalid-shadow: var(--red-50-a30);
|
|
--in-content-border-color: var(--grey-30);
|
|
--in-content-border-color-mixed: #d7d7db;
|
|
--in-content-category-outline-focus: 1px dotted var(--blue-50);
|
|
--in-content-category-text-selected: var(--blue-50);
|
|
--in-content-category-text-selected-active: var(--blue-60);
|
|
--in-content-category-background-hover: rgba(12,12,13,0.1);
|
|
--in-content-category-background-active: rgba(12,12,13,0.15);
|
|
--in-content-category-background-selected-hover: rgba(12,12,13,0.15);
|
|
--in-content-category-background-selected-active: rgba(12,12,13,0.2);
|
|
--in-content-tab-color: #424f5a;
|
|
--in-content-link-color: var(--blue-60);
|
|
--in-content-link-color-hover: var(--blue-70);
|
|
--in-content-link-color-active: var(--blue-80);
|
|
--in-content-link-color-visited: var(--blue-60);
|
|
--in-content-button-background: var(--grey-90-a10);
|
|
--in-content-button-background-mixed: rgb(calc((249 * 0.9) + (12 * 0.1)), calc((249 * 0.9) + (12 * 0.1)), calc((250 * 0.9) + (13 * 0.1)));
|
|
--in-content-button-background-hover: var(--grey-90-a20);
|
|
--in-content-button-background-hover-mixed: rgb(calc((249 * 0.8) + (12 * 0.2)), calc((249 * 0.8) + (12 * 0.2)), calc((250 * 0.8) + (13 * 0.2)));
|
|
--in-content-button-background-active: var(--grey-90-a30);
|
|
--in-content-button-background-active-mixed: rgb(calc((249 * 0.7) + (12 * 0.3)), calc((249 * 0.7) + (12 * 0.3)), calc((250 * 0.7) + (13 * 0.3)));
|
|
|
|
--blue-40: #45a1ff;
|
|
--blue-40-a10: rgb(69, 161, 255, 0.1);
|
|
--blue-50: #0a84ff;
|
|
--blue-50-a30: rgba(10, 132, 255, 0.3);
|
|
--blue-60: #0060df;
|
|
--blue-70: #003eaa;
|
|
--blue-80: #002275;
|
|
--grey-10: #f9f9fa;
|
|
--grey-10-a015: rgba(249, 249, 250, 0.015);
|
|
--grey-10-a20: rgba(249, 249, 250, 0.2);
|
|
--grey-20: #ededf0;
|
|
--grey-30: #d7d7db;
|
|
--grey-40: #b1b1b3;
|
|
--grey-60: #4a4a4f;
|
|
--grey-90: #0c0c0d;
|
|
--grey-90-a10: rgba(12, 12, 13, 0.1);
|
|
--grey-90-a20: rgba(12, 12, 13, 0.2);
|
|
--grey-90-a30: rgba(12, 12, 13, 0.3);
|
|
--grey-90-a50: rgba(12, 12, 13, 0.5);
|
|
--grey-90-a60: rgba(12, 12, 13, 0.6);
|
|
--green-50: #30e60b;
|
|
--green-60: #12bc00;
|
|
--green-70: #058b00;
|
|
--green-80: #006504;
|
|
--green-90: #003706;
|
|
--orange-50: #ff9400;
|
|
--purple-70: #6200a4;
|
|
--red-50: #ff0039;
|
|
--red-50-a30: rgba(255, 0, 57, 0.3);
|
|
--red-60: #d70022;
|
|
--red-70: #a4000f;
|
|
--red-80: #5a0002;
|
|
--red-90: #3e0200;
|
|
--yellow-10: #ffff98;
|
|
--yellow-50: #ffe900;
|
|
--yellow-60: #d7b600;
|
|
--yellow-60-a30: rgba(215, 182, 0, 0.3);
|
|
--yellow-70: #a47f00;
|
|
--yellow-80: #715100;
|
|
--yellow-90: #3e2800;
|
|
|
|
/* https://hg.mozilla.org/mozilla-central/raw-file/tip/browser/themes/addons/dark/manifest.json */
|
|
--dark-frame: hsl(240, 5%, 5%);
|
|
--dark-icons: rgb(249, 249, 250, 0.7);
|
|
--dark-ntp-background: #2A2A2E;
|
|
--dark-ntp-text: rgb(249, 249, 250);
|
|
--dark-popup: #4a4a4f;
|
|
--dark-popup-border: #27272b;
|
|
--dark-popup-text: rgb(249, 249, 250);
|
|
--dark-sidebar: #38383D;
|
|
--dark-sidebar-text: rgb(249, 249, 250);
|
|
--dark-sidebar-border: rgba(255, 255, 255, 0.1);
|
|
--dark-tab-background-text: rgb(249, 249, 250);
|
|
--dark-tab-line: #0a84ff;
|
|
--dark-toolbar: hsl(240, 1%, 20%);
|
|
--dark-toolbar-bottom-separator: hsl(240, 5%, 5%);
|
|
--dark-toolbar-field: rgb(71, 71, 73);
|
|
--dark-toolbar-field-border: rgba(249, 249, 250, 0.2);
|
|
--dark-toolbar-field-separator: #5F6670;
|
|
--dark-toolbar-field-text: rgb(249, 249, 250);
|
|
|
|
/* https://searchfox.org/mozilla-central/rev/35873cfc312a6285f54aa5e4ec2d4ab911157522/browser/themes/shared/tabs.inc.css#24 */
|
|
--tab-loading-fill: #0A84FF;
|
|
|
|
|
|
--bg-color: var(--grey-10);
|
|
--text-color: var(--grey-90);
|
|
}
|
|
|
|
${common}.rich-confirm.rtl {
|
|
direction: rtl;
|
|
}
|
|
|
|
${common}.rich-confirm :link {
|
|
color: var(--in-content-link-color);
|
|
}
|
|
${common}.rich-confirm :visited {
|
|
color: var(--in-content-link-color-visited);
|
|
}
|
|
|
|
${common}.rich-confirm :link:hover,
|
|
${common}.rich-confirm :visited:hover {
|
|
color: var(--in-content-link-color-hover);
|
|
}
|
|
|
|
${common}.rich-confirm :link:active,
|
|
${common}.rich-confirm :visited:active {
|
|
color: var(--in-content-link-color-active);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
${common}.rich-confirm,
|
|
:root${common} {
|
|
/* https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/themes/shared/in-content/common.inc.css */
|
|
--in-content-page-background: #2A2A2E /* rgb(42, 42, 46) */;
|
|
--in-content-page-color: rgb(249, 249, 250);
|
|
--in-content-text-color: var(--in-content-page-color);
|
|
--in-content-deemphasized-text: var(--grey-40);
|
|
--in-content-box-background: #202023;
|
|
--in-content-box-background-hover: /* rgba(249,249,250,0.15) */ rgb(calc((42 * 0.85) + (249 * 0.15)), calc((42 * 0.85) + (249 * 0.15)), calc((46 * 0.85) + (250 * 0.15)));
|
|
--in-content-box-background-active: /*rgba(249,249,250,0.2) */ rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2)));
|
|
--in-content-box-background-odd: rgba(249,249,250,0.05);
|
|
--in-content-box-info-background: rgba(249,249,250,0.15);
|
|
|
|
--in-content-border-color: rgba(249,249,250,0.2);
|
|
--in-content-border-color-mixed: rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2)));
|
|
--in-content-border-hover: rgba(249,249,250,0.3);
|
|
--in-content-border-hover-mixed: rgb(calc((42 * 0.7) + (249 * 0.3)), calc((42 * 0.7) + (249 * 0.3)), calc((46 * 0.7) + (250 * 0.3)));
|
|
--in-content-box-border-color: rgba(249,249,250,0.2);
|
|
--in-content-box-border-color-mixed: rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2)));
|
|
|
|
--in-content-button-background: rgba(249,249,250,0.1);
|
|
--in-content-button-background-mixed: rgb(calc((42 * 0.9) + (249 * 0.1)), calc((42 * 0.9) + (249 * 0.1)), calc((46 * 0.9) + (250 * 0.1)));
|
|
--in-content-button-background-hover: rgba(249,249,250,0.15);
|
|
--in-content-button-background-hover-mixed: rgb(calc((42 * 0.85) + (249 * 0.15)), calc((42 * 0.85) + (249 * 0.15)), calc((46 * 0.85) + (250 * 0.15)));
|
|
--in-content-button-background-active: rgba(249,249,250,0.2);
|
|
--in-content-button-background-active-mixed: rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2)));
|
|
|
|
--in-content-link-color: var(--blue-40);
|
|
--in-content-link-color-hover: var(--blue-50);
|
|
--in-content-link-color-active: var(--blue-60);
|
|
|
|
--bg-color: var(--in-content-page-background);
|
|
--text-color: var(--in-content-text-color);
|
|
}
|
|
|
|
${common}.rich-confirm textarea,
|
|
${common}.rich-confirm input {
|
|
background: var(--in-content-box-background);
|
|
border: thin solid var(--in-content-box-border-color-mixed);
|
|
color: var(--in-content-text-color);
|
|
}
|
|
${common}.rich-confirm textarea:hover,
|
|
${common}.rich-confirm input:hover {
|
|
border-color: var(--in-content-border-hover-mixed);
|
|
}
|
|
${common}.rich-confirm textarea:focus,
|
|
${common}.rich-confirm input:focus {
|
|
border-color: var(--in-content-border-focus);
|
|
box-shadow: 0 0 0 1px var(--in-content-border-active),
|
|
0 0 0 4px var(--in-content-border-active-shadow);
|
|
}
|
|
|
|
${common}.rich-confirm fieldset,
|
|
${common}.rich-confirm hr {
|
|
border: thin solid var(--in-content-box-border-color-mixed);
|
|
}
|
|
|
|
${common}.rich-confirm hr {
|
|
border-width: thin 0 0 0;
|
|
}
|
|
|
|
${common}.rich-confirm button,
|
|
${common}.rich-confirm select {
|
|
background: var(--in-content-button-background-mixed);
|
|
border: 0 none transparent;
|
|
color: var(--in-content-text-color);
|
|
margin: 4px;
|
|
}
|
|
${common}.rich-confirm button:hover,
|
|
${common}.rich-confirm select:hover {
|
|
background: var(--in-content-button-background-hover-mixed);
|
|
}
|
|
${common}.rich-confirm button:focus,
|
|
${common}.rich-confirm select:focus {
|
|
background: var(--in-content-button-background-active-mixed);
|
|
box-shadow: 0 0 0 1px var(--in-content-border-active),
|
|
0 0 0 4px var(--in-content-border-active-shadow);
|
|
}
|
|
${common}.rich-confirm option {
|
|
background: var(--bg-color);
|
|
color: var(--text-color);
|
|
}
|
|
${common}.rich-confirm option:active,
|
|
${common}.rich-confirm option:focus {
|
|
background: var(--in-content-item-selected);
|
|
}
|
|
${common}.rich-confirm option:hover {
|
|
background: var(--in-content-item-hover-mixed);
|
|
}
|
|
}
|
|
|
|
${common}.rich-confirm,
|
|
${common}.rich-confirm-row {
|
|
align-items: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
}
|
|
${common}.rich-confirm:not(.simulation),
|
|
${common}.rich-confirm-row:not(.simulation) {
|
|
align-items: stretch;
|
|
bottom: 0;
|
|
left: 0;
|
|
position: fixed;
|
|
right: 0;
|
|
top: 0;
|
|
}
|
|
|
|
${common}.rich-confirm {
|
|
left:0;
|
|
pointer-events: none;
|
|
z-index: 999997;
|
|
}
|
|
${common}.rich-confirm.popup-window,
|
|
:root${common}.popup-window body {
|
|
background: var(--bg-color);
|
|
}
|
|
${common}.rich-confirm:not(.popup-window) {
|
|
background: rgba(0, 0, 0, 0.45);
|
|
opacity: 0;
|
|
transition: opacity 250ms ease-out;
|
|
}
|
|
|
|
${common}.rich-confirm.show {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
${common}.rich-confirm-row {
|
|
z-index: 999998;
|
|
}
|
|
|
|
${common}.rich-confirm-dialog {
|
|
color: var(--text-color);
|
|
font: message-box;
|
|
overflow: hidden;
|
|
padding: 1em;
|
|
z-index: 999999;
|
|
}
|
|
/* Don't apply "auto" immediately because it can produce needless scrollbar even if all contents are visible without scrolling. */
|
|
${common}.rich-confirm.shown .rich-confirm-dialog {
|
|
overflow: auto;
|
|
}
|
|
${common}.rich-confirm.shown .rich-confirm-dialog.popup-window:not(.simulation) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-grow: 1;
|
|
}
|
|
${common}.rich-confirm-dialog:not(.popup-window) {
|
|
background: var(--bg-color);
|
|
box-shadow: 0.1em 0.1em 0.5em rgba(0, 0, 0, 0.65);
|
|
margin: 0.5em;
|
|
max-height: 90%;
|
|
max-width: 90%;
|
|
}
|
|
|
|
${common}.rich-confirm-content {
|
|
white-space: pre-wrap;
|
|
}
|
|
${common}.rich-confirm-content.popup-window:not(.simulation) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-grow: 1;
|
|
flex-shrink: 1;
|
|
}
|
|
|
|
${common}.rich-confirm-buttons {
|
|
align-items: center;
|
|
display: flex;
|
|
flex-direction: row;
|
|
margin: 0.5em 0 0;
|
|
}
|
|
|
|
@media (min-width: 40em) {
|
|
${common}.rich-confirm-buttons.type-dialog button,
|
|
${common}.rich-confirm-buttons.type-common-dialog button {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
${common}.rich-confirm-buttons.type-dialog {
|
|
justify-content: flex-end;
|
|
}
|
|
${common}.rich-confirm-buttons.type-dialog button + button {
|
|
margin-inline-start: 1em;
|
|
}
|
|
|
|
${common}.rich-confirm-buttons.type-common-dialog {
|
|
justify-content: center;
|
|
}
|
|
${common}.rich-confirm-buttons.type-common-dialog button + button {
|
|
margin-inline-start: 1em;
|
|
}
|
|
|
|
${common}.rich-confirm-buttons.type-dialog.mac,
|
|
${common}.rich-confirm-buttons.type-dialog.linux,
|
|
${common}.rich-confirm-buttons.type-common-dialog.mac,
|
|
${common}.rich-confirm-buttons.type-common-dialog.linux {
|
|
justify-content: flex-start;
|
|
flex-direction: row-reverse;
|
|
}
|
|
${common}.rich-confirm-buttons.type-dialog.mac button + button,
|
|
${common}.rich-confirm-buttons.type-dialog.linux button + button,
|
|
${common}.rich-confirm-buttons.type-common-dialog.mac button + button,
|
|
${common}.rich-confirm-buttons.type-common-dialog.linux button + button {
|
|
margin-inline-end: 1em;
|
|
}
|
|
}
|
|
|
|
/* popup type dialog always have horizontal buttons */
|
|
${common}.rich-confirm-buttons.popup-window.type-dialog button,
|
|
${common}.rich-confirm-buttons.popup-window.type-common-dialog button {
|
|
white-space: nowrap;
|
|
}
|
|
${common}.rich-confirm-buttons.popup-window.type-dialog {
|
|
justify-content: flex-end;
|
|
}
|
|
${common}.rich-confirm-buttons.popup-window.type-dialog button + button {
|
|
margin-inline-start: 1em;
|
|
}
|
|
${common}.rich-confirm-buttons.popup-window.type-common-dialog {
|
|
justify-content: center;
|
|
}
|
|
${common}.rich-confirm-buttons.popup-window.type-common-dialog button + button {
|
|
margin-inline-start: 1em;
|
|
}
|
|
${common}.rich-confirm-buttons.popup-window.type-dialog.mac,
|
|
${common}.rich-confirm-buttons.popup-window.type-dialog.linux,
|
|
${common}.rich-confirm-buttons.popup-window.type-common-dialog.mac,
|
|
${common}.rich-confirm-buttons.popup-window.type-common-dialog.linux {
|
|
justify-content: flex-start;
|
|
flex-direction: row-reverse;
|
|
}
|
|
${common}.rich-confirm-buttons.popup-window.type-dialog.mac button + button,
|
|
${common}.rich-confirm-buttons.popup-window.type-dialog.linux button + button,
|
|
${common}.rich-confirm-buttons.popup-window.type-common-dialog.mac button + button,
|
|
${common}.rich-confirm-buttons.popup-window.type-common-dialog.linux button + button {
|
|
margin-inline-end: 1em;
|
|
}
|
|
|
|
${common}.rich-confirm-buttons:not(.type-dialog):not(.type-common-dialog) {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
@media (max-width: 40em) {
|
|
${common}.rich-confirm-buttons:not(.popup-window):not(.type-dialog).type-common-dialog {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
${common}.rich-confirm button {
|
|
-moz-appearance: button;
|
|
font: message-box;
|
|
text-align: center;
|
|
}
|
|
|
|
${common}.rich-confirm-buttons:not(.type-dialog):not(.type-common-dialog) button {
|
|
display: block;
|
|
margin-bottom: 0.2em;
|
|
padding: 0.4em;
|
|
width: 100%;
|
|
}
|
|
@media (max-width: 40em) {
|
|
${common}.rich-confirm-buttons:not(.popup-window):not(.type-dialog).type-common-dialog button {
|
|
display: block;
|
|
margin-bottom: 0.2em;
|
|
padding: 0.4em;
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
${common}.rich-confirm-check-label {
|
|
display: flex;
|
|
flex-direction: row;
|
|
margin-top: 0.5em;
|
|
}
|
|
|
|
${common}.rich-confirm-check-label.hidden {
|
|
display: none;
|
|
}
|
|
|
|
${common}.rich-confirm .accesskey {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
|
|
${common}.rich-confirm.simulation {
|
|
max-height: 0 !important;
|
|
max-width: 0 !important;
|
|
overflow: hidden !important;
|
|
visibility: hidden !important;
|
|
}
|
|
${common}.rich-confirm-row.simulation {
|
|
position: static !important;
|
|
}
|
|
${common}.rich-confirm-dialog.simulation {
|
|
border: 1px solid;
|
|
}
|
|
`;
|
|
document.head.appendChild(this.style);
|
|
|
|
const range = document.createRange();
|
|
range.selectNodeContents(document.body);
|
|
range.collapse(false);
|
|
const commonClass = [
|
|
this.commonClass,
|
|
this.params.popup ? 'popup-window' : '',
|
|
this.params.simulation ? 'simulation' : '',
|
|
this.params.type ? `type-${this.params.type}` : '',
|
|
/win/i.test(navigator.platform) ? 'windows' :
|
|
/mac/i.test(navigator.platform) ? 'mac' :
|
|
/linux/i.test(navigator.platform) ? 'linux' :
|
|
'',
|
|
this.isRTL ? 'rtl' : '',
|
|
].join(' ');
|
|
const uniqueId = `created-at-${Date.now()}-${Math.floor(Math.random() * Math.pow(2, 24))}`;
|
|
const fragment = range.createContextualFragment(`
|
|
<div class="rich-confirm ${commonClass} ${uniqueId}">
|
|
<div class="rich-confirm-row ${commonClass}">
|
|
<div class="rich-confirm-dialog ${commonClass}" role="dialog">
|
|
<div class="rich-confirm-content ${commonClass}"></div>
|
|
<label class="rich-confirm-check-label ${commonClass}">
|
|
<input type="checkbox"
|
|
class="rich-confirm-check-checkbox ${commonClass}">
|
|
<span class="rich-confirm-check-message ${commonClass}"></span>
|
|
</label>
|
|
<div class="rich-confirm-buttons ${commonClass}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
range.insertNode(fragment);
|
|
range.detach();
|
|
this.ui = document.querySelector(`.rich-confirm.${this.commonClass}.${uniqueId}`);
|
|
}
|
|
|
|
getNextFocusedNodeByAccesskey(key) {
|
|
for (const attribute of ['accesskey', 'data-access-key', 'data-sub-access-key']) {
|
|
const current = this.dialog.querySelector(':focus');
|
|
const condition = `[${attribute}="${key.toLowerCase()}"]`;
|
|
const nextNode = this.getNextNode(current, condition);
|
|
if (nextNode)
|
|
return nextNode;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getNextNode(base, condition = '') {
|
|
const matchedNodes = [...this.dialog.querySelectorAll(condition)];
|
|
const currentIndex = matchedNodes.indexOf(base);
|
|
const nextNode = currentIndex == -1 || currentIndex == matchedNodes.index - 1 ?
|
|
matchedNodes[0] : matchedNodes[currentIndex + 1];
|
|
if (nextNode && window.getComputedStyle(nextNode, null).display == 'none')
|
|
return this.getNextNode(nextNode, condition);
|
|
return nextNode;
|
|
}
|
|
|
|
updateAccessKey(element) {
|
|
const ACCESS_KEY_MATCHER = /(&([^\s]))/i;
|
|
const label = element.textContent || (/^(button|submit|reset)$/i.test(element.type) && element.value) || '';
|
|
const matchedKey = element.accessKey ?
|
|
label.match(new RegExp(`((${element.accessKey}))`, 'i')) :
|
|
label.match(ACCESS_KEY_MATCHER);
|
|
const accessKey = element.accessKey || (matchedKey && matchedKey[2]);
|
|
if (accessKey) {
|
|
element.accessKey = element.dataset.accessKey = accessKey.toLowerCase();
|
|
if (matchedKey &&
|
|
!/^(input|textarea)$/i.test(element.localName)) {
|
|
const textNode = this.evaluateXPath(
|
|
`child::node()[contains(self::text(), "${matchedKey[1]}")]`,
|
|
element,
|
|
XPathResult.FIRST_ORDERED_NODE_TYPE
|
|
).singleNodeValue;
|
|
if (textNode) {
|
|
const range = document.createRange();
|
|
const startPosition = textNode.nodeValue.indexOf(matchedKey[1]);
|
|
range.setStart(textNode, startPosition);
|
|
range.setEnd(textNode, startPosition + matchedKey[1].length);
|
|
range.deleteContents();
|
|
const accessKeyNode = document.createElement('span');
|
|
accessKeyNode.classList.add('accesskey');
|
|
accessKeyNode.textContent = matchedKey[2];
|
|
range.insertNode(accessKeyNode);
|
|
range.detach();
|
|
}
|
|
}
|
|
}
|
|
else if (/^([^\s])/i.test(label))
|
|
element.dataset.subAccessKey = RegExp.$1.toLowerCase();
|
|
else
|
|
element.dataset.accessKey = element.dataset.subAccessKey = null;
|
|
}
|
|
|
|
evaluateXPath(expression, context, type) {
|
|
if (!type)
|
|
type = XPathResult.ORDERED_NODE_SNAPSHOT_TYPE;
|
|
try {
|
|
return (context.ownerDocument || context).evaluate(
|
|
expression,
|
|
(context || document),
|
|
null,
|
|
type,
|
|
null
|
|
);
|
|
}
|
|
catch(_e) {
|
|
return {
|
|
singleNodeValue: null,
|
|
snapshotLength: 0,
|
|
snapshotItem: function() {
|
|
return null
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
async show({ onShown, onDialogOpened } = {}) {
|
|
this.buildUI();
|
|
await new Promise((resolve, _reject) => setTimeout(resolve, 0));
|
|
|
|
const range = document.createRange();
|
|
|
|
if (this.params.content) {
|
|
range.selectNodeContents(this.content);
|
|
range.collapse(false);
|
|
const fragment = range.createContextualFragment(this.params.content);
|
|
range.insertNode(fragment);
|
|
for (const element of this.content.querySelectorAll('[accesskey]')) {
|
|
this.updateAccessKey(element);
|
|
}
|
|
}
|
|
else if (this.params.message) {
|
|
this.content.textContent = this.params.message;
|
|
}
|
|
|
|
if (this.params.checkMessage) {
|
|
this.checkMessage.textContent = this.params.checkMessage;
|
|
this.checkCheckbox.checked = !!this.params.checked;
|
|
this.checkContainer.classList.remove('hidden');
|
|
}
|
|
else {
|
|
this.checkContainer.classList.add('hidden');
|
|
}
|
|
|
|
range.selectNodeContents(this.buttonsContainer);
|
|
range.deleteContents();
|
|
const buttons = document.createDocumentFragment();
|
|
for (const label of this.params.buttons) {
|
|
const button = document.createElement('button');
|
|
button.textContent = label;
|
|
button.setAttribute('title', label);
|
|
buttons.appendChild(button);
|
|
this.updateAccessKey(button);
|
|
}
|
|
range.insertNode(buttons);
|
|
|
|
this.ui.addEventListener('click', this.onClick);
|
|
window.addEventListener('keydown', this.onKeyDown, true);
|
|
window.addEventListener('keyup', this.onKeyUp, true);
|
|
window.addEventListener('contextmenu', this.onContextMenu, true);
|
|
window.addEventListener('pagehide', this.onUnload);
|
|
window.addEventListener('beforeunload', this.onUnload);
|
|
|
|
const targets = this.focusTargets.filter(target => target != this.checkCheckbox);
|
|
targets[0].focus();
|
|
|
|
range.detach();
|
|
|
|
if (typeof this.params.onShown == 'function') {
|
|
try {
|
|
await this.params.onShown(this.content, this.params.inject || {});
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
else if (Array.isArray(this.params.onShown)) {
|
|
for (const onShownPart of this.params.onShown) {
|
|
if (typeof onShownPart != 'function')
|
|
continue;
|
|
try {
|
|
await onShownPart(this.content, this.params.inject || {});
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.ui.querySelector('.rich-confirm-dialog').setAttribute('aria-modal', !!this.params.modal);
|
|
this.ui.classList.add('show');
|
|
|
|
if (typeof onShown == 'function') {
|
|
try {
|
|
await onShown(this.content, this.params.inject || {});
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
else if (Array.isArray(onShown)) {
|
|
for (const onShownPart of onShown) {
|
|
if (typeof onShownPart != 'function')
|
|
continue;
|
|
try {
|
|
await onShownPart(this.content, this.params.inject || {});
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof onDialogOpened == 'function') {
|
|
try {
|
|
await onDialogOpened({
|
|
close: () => {
|
|
this.hide();
|
|
},
|
|
});
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (!this.ui ||
|
|
!this.ui.classList)
|
|
return;
|
|
// Apply overflow:auto after all contents are correctly rendered.
|
|
this.ui.classList.add('shown');
|
|
}, 10);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this._resolve = resolve;
|
|
this._rejecte = reject;
|
|
});
|
|
}
|
|
|
|
async hide() {
|
|
this.ui.classList.remove('show');
|
|
if (typeof this.params.onHidden == 'function') {
|
|
try {
|
|
this.params.onHidden(this.content, this.params.inject || {});
|
|
}
|
|
catch(_error) {
|
|
}
|
|
}
|
|
this.ui.removeEventListener('click', this.onClick);
|
|
window.removeEventListener('keydown', this.onKeyDown, true);
|
|
window.removeEventListener('keyup', this.onKeyUp, true);
|
|
window.removeEventListener('contextmenu', this.onContextMenu, true);
|
|
window.removeEventListener('pagehide', this.onUnload);
|
|
window.removeEventListener('beforeunload', this.onUnload);
|
|
delete this._resolve;
|
|
delete this._rejecte;
|
|
const ui = this.ui;
|
|
const style = this.style;
|
|
delete this.ui;
|
|
delete this.style;
|
|
return new Promise((resolve, _reject) => {
|
|
window.setTimeout(() => {
|
|
// remove elements after animation is finished
|
|
ui.parentNode.removeChild(ui);
|
|
style.parentNode.removeChild(style);
|
|
}, 1000);
|
|
resolve();
|
|
});
|
|
}
|
|
|
|
dismiss() {
|
|
const resolve = this._resolve;
|
|
const result = {
|
|
buttonIndex: -1,
|
|
checked: !!this.params.checkMessage && this.checkCheckbox.checked
|
|
};
|
|
return this.hide().then(() => resolve(result));
|
|
}
|
|
|
|
onClick(event) {
|
|
let target = event.target;
|
|
if (target.nodeType == Node.TEXT_NODE)
|
|
target = target.parentNode;
|
|
|
|
if (target.closest(`.rich-confirm-content.${this.commonClass}`) &&
|
|
target.closest('input, textarea, select, button'))
|
|
return;
|
|
|
|
if (event.button != 0) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const button = target.closest('button');
|
|
if (button) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
const buttonIndex = Array.from(this.buttonsContainer.childNodes).indexOf(button);
|
|
const values = {};
|
|
for (const field of this.content.querySelectorAll('[id], [name]')) {
|
|
let value = null;
|
|
if (field.matches('input[type="checkbox"]')) {
|
|
value = field.checked;
|
|
}
|
|
else if (field.matches('input[type="radio"]')) {
|
|
if (field.checked)
|
|
value = field.value;
|
|
}
|
|
else if ('value' in field.dataset) {
|
|
value = field.dataset.value;
|
|
}
|
|
else {
|
|
value = field.value;
|
|
}
|
|
values[field.id || field.name] = value;
|
|
}
|
|
const resolve = this._resolve;
|
|
const result = {
|
|
buttonIndex,
|
|
values,
|
|
checked: !!this.params.checkMessage && this.checkCheckbox.checked
|
|
};
|
|
this.hide().then(() => resolve(result));
|
|
return;
|
|
}
|
|
|
|
if (!this.params.popup &&
|
|
!target.closest(`.rich-confirm-dialog.${this.commonClass}`)) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.dismiss();
|
|
}
|
|
}
|
|
|
|
onKeyDown(event) {
|
|
let target = event.target;
|
|
if (target.nodeType == Node.TEXT_NODE)
|
|
target = target.parentNode;
|
|
const onContent = target.closest(`.rich-confirm-content.${this.commonClass}`);
|
|
|
|
switch (event.key) {
|
|
case 'ArrowUp':
|
|
case 'PageUp':
|
|
if (onContent)
|
|
break;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.advanceFocus(-1);
|
|
break;
|
|
|
|
case 'ArrowLeft':
|
|
if (onContent)
|
|
break;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.advanceFocus(this.isRTL ? 1 : -1);
|
|
break;
|
|
|
|
case 'ArrowDown':
|
|
case 'PageDown':
|
|
if (onContent)
|
|
break;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.advanceFocus(1);
|
|
break;
|
|
|
|
case 'ArrowRight':
|
|
if (onContent)
|
|
break;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.advanceFocus(this.isRTL ? -1 : 1);
|
|
break;
|
|
|
|
case 'Home':
|
|
if (onContent)
|
|
break;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.focusTargets[0].focus();
|
|
break;
|
|
|
|
case 'End':
|
|
if (onContent)
|
|
break;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
const targets = this.focusTargets;
|
|
targets[targets.length-1].focus();
|
|
break;
|
|
|
|
case 'Tab':
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.advanceFocus(event.shiftKey ? -1 : 1);
|
|
break;
|
|
|
|
case 'Escape':
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.dismiss();
|
|
break;
|
|
|
|
case 'Enter':
|
|
if (onContent &&
|
|
!target.closest('textarea') &&
|
|
!target.closest('[data-no-accept-by-enter="true"]')) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
this.buttonsContainer.firstChild.click();
|
|
}
|
|
break;
|
|
|
|
default: {
|
|
const currentFocused = this.dialog.querySelector(':focus');
|
|
const needAccelKey = (
|
|
currentFocused &&
|
|
(currentFocused.localName.toLowerCase() == 'textarea' ||
|
|
(currentFocused.localName.toLowerCase() == 'input' &&
|
|
/^(date|datetime|datetime-local|email|file|month|number|password|search|tel|text|time|url|week)$/i.test(currentFocused.type)))
|
|
);
|
|
if ((!needAccelKey || event.altKey) &&
|
|
!event.ctrlKey &&
|
|
!event.shiftKey &&
|
|
!event.metaKey &&
|
|
event.key.length == 1) {
|
|
const node = this.getNextFocusedNodeByAccesskey(event.key);
|
|
if (node && typeof node.focus == 'function') {
|
|
node.focus();
|
|
const nextNode = this.getNextFocusedNodeByAccesskey(event.key);
|
|
if ((!nextNode || nextNode == node) &&
|
|
typeof node.click == 'function')
|
|
node.click();
|
|
}
|
|
}
|
|
}; return;
|
|
}
|
|
}
|
|
|
|
onKeyUp(event) {
|
|
switch (event.key) {
|
|
case 'ArrowUp':
|
|
case 'ArrowLeft':
|
|
case 'PageUp':
|
|
case 'ArrowDown':
|
|
case 'ArrowRight':
|
|
case 'PageDown':
|
|
case 'Home':
|
|
case 'End':
|
|
case 'Tab':
|
|
case 'Escape':
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
onContextMenu(event) {
|
|
let target = event.target;
|
|
if (target.nodeType == Node.TEXT_NODE)
|
|
target = target.parentNode;
|
|
const onContent = target.closest(`.rich-confirm-content.${this.commonClass}`);
|
|
if (!onContent || !target.closest('input, textarea')) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.stopImmediatePropagation();
|
|
}
|
|
}
|
|
|
|
onUnload() {
|
|
this.dismiss();
|
|
}
|
|
|
|
advanceFocus(direction) {
|
|
const focusedItem = this.ui.querySelector(':focus');
|
|
console.log('focusedItem ', focusedItem);
|
|
const targets = this.focusTargets;
|
|
console.log('focusTargets ', targets);
|
|
const index = focusedItem ? targets.indexOf(focusedItem) : -1;
|
|
if (direction < 0) { // backward
|
|
const nextFocused = index < 0 ? targets[targets.length-1] :
|
|
targets[index == 0 ? targets.length-1 : index-1];
|
|
nextFocused.focus();
|
|
}
|
|
else { // forward
|
|
const nextFocused = index < 0 ? targets[0] :
|
|
targets[index == targets.length-1 ? 0 : index+1];
|
|
nextFocused.focus();
|
|
}
|
|
}
|
|
|
|
static async show(params) {
|
|
const confirm = new this(params);
|
|
return confirm.show();
|
|
}
|
|
|
|
static async showInTab(tabId, params) {
|
|
if (!params) {
|
|
params = tabId;
|
|
tabId = (await browser.tabs.getCurrent()).id;
|
|
}
|
|
let onMessage;
|
|
const oneTimeKey = `popup-${this.uniqueKey}-${Date.now()}-${parseInt(Math.random() * Math.pow(2, 16))}`;
|
|
const promisedResult = new Promise((resolve, _reject) => {
|
|
onMessage = (message, _sender) => {
|
|
if (message?.oneTimeKey != oneTimeKey)
|
|
return;
|
|
|
|
switch (message.type) {
|
|
case 'rich-confirm-dialog-shown':
|
|
if (typeof params.onReady == 'function') {
|
|
try {
|
|
params.onReady({
|
|
width: message.dialogWidth,
|
|
height: message.dialogHeight
|
|
});
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'rich-confirm-dialog-complete':
|
|
resolve(message.result);
|
|
break;
|
|
}
|
|
};
|
|
browser.runtime.onMessage.addListener(onMessage);
|
|
});
|
|
try {
|
|
if (typeof browser.tabs.executeScript == 'function') // Manifest V2
|
|
await browser.tabs.executeScript(tabId, {
|
|
code: `
|
|
if (!window.RichConfirm)
|
|
(${defineRichConfirm.toString()})(${this.uniqueKey});
|
|
`,
|
|
matchAboutBlank: true,
|
|
runAt: 'document_start'
|
|
});
|
|
else // Manifest V3
|
|
await browser.scripting.executeScript({
|
|
target: { tabId },
|
|
func: defineRichConfirm,
|
|
args: [this.uniqueKey],
|
|
});
|
|
const transferableParams = { ...params };
|
|
const injectTransferable = [];
|
|
const inject = params.inject || {};
|
|
delete transferableParams.inject;
|
|
for (const key in params.inject) {
|
|
const value = inject[key];
|
|
const transferable = (
|
|
value &&
|
|
typeof value == 'function' &&
|
|
typeof value.toString == 'function'
|
|
) ? value.toString() : JSON.stringify(value);
|
|
injectTransferable.push(`${JSON.stringify(key)} : ${transferable}`);
|
|
}
|
|
const stringifyOnShown = onShown => {
|
|
if (Array.isArray(onShown))
|
|
return `[${onShown.map(stringifyOnShown).join(',')}]`;
|
|
return typeof onShown == 'function' ?
|
|
onShown.toString()
|
|
.replace(/^\s*(async\s+)?function/, '$1')
|
|
.replace(/^\s*(async\s+)?/, '$1 function ')
|
|
.replace(/^\s*(async\s+)?function ((?:\([^=\)]*\)|[^\(\)=]+)\s*=>\s*\{)/, '$1 $2') :
|
|
'() => {}';
|
|
};
|
|
const originalOnShown = stringifyOnShown(params.onShown);
|
|
delete transferableParams.onShown;
|
|
|
|
const run = async function run(uniqueKey, oneTimeKey, originalOnShown, transferableParams, inject) {
|
|
delete window.RichConfirm.result;
|
|
const confirm = new RichConfirm({
|
|
...transferableParams,
|
|
inject: inject || {},
|
|
async onShown(content, inject) {
|
|
if (!Array.isArray(originalOnShown))
|
|
originalOnShown = [originalOnShown];
|
|
for (const originalOnShownPart of originalOnShown) {
|
|
try {
|
|
if (typeof originalOnShownPart == 'function')
|
|
await originalOnShownPart(content, inject);
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
const result = await confirm.show({
|
|
onShown(content, _injected) {
|
|
const dialog = content.parentNode;
|
|
const rect = dialog.getBoundingClientRect();
|
|
const style = window.getComputedStyle(dialog, null);
|
|
// End padding is not included in the scrillable size,
|
|
// so we manually add them.
|
|
const inlineEndPadding = dialog.scrollLeftMax > 0 && parseFloat(style.getPropertyValue('padding-inline-end')) || 0;
|
|
const bottomPadding = dialog.scrollTopMax > 0 && parseFloat(style.getPropertyValue('padding-bottom')) || 0;
|
|
browser.runtime.sendMessage({
|
|
type: 'rich-confirm-dialog-shown',
|
|
uniqueKey,
|
|
oneTimeKey,
|
|
dialogWidth: rect.width + dialog.scrollLeftMax + inlineEndPadding,
|
|
dialogHeight: rect.height + dialog.scrollTopMax + bottomPadding
|
|
});
|
|
},
|
|
});
|
|
browser.runtime.sendMessage({
|
|
type: 'rich-confirm-dialog-complete',
|
|
uniqueKey,
|
|
oneTimeKey,
|
|
result
|
|
});
|
|
};
|
|
if (typeof browser.tabs.executeScript == 'function') // Manifest V2
|
|
browser.tabs.executeScript(tabId, {
|
|
code: `
|
|
(${run.toString()})(
|
|
${this.uniqueKey},
|
|
${JSON.stringify(oneTimeKey)},
|
|
(${originalOnShown.toString()}),
|
|
${JSON.stringify(transferableParams)},
|
|
{${injectTransferable.join(',')}}
|
|
);
|
|
`,
|
|
matchAboutBlank: true,
|
|
runAt: 'document_start'
|
|
});
|
|
else // Manifest V3
|
|
browser.scripting.executeScript({
|
|
target: { tabId },
|
|
func: run,
|
|
args: [this.uniqueKey, oneTimeKey, originalOnShown, transferableParams, inject],
|
|
});
|
|
// Don't return the promise directly here, instead await it
|
|
// because the "finally" block must be processed after
|
|
// the promise is resolved.
|
|
const result = await promisedResult;
|
|
return result;
|
|
}
|
|
catch(error) {
|
|
console.error(error, error.stack);
|
|
return {
|
|
buttonIndex: -1
|
|
};
|
|
}
|
|
finally {
|
|
if (browser.runtime.onMessage.hasListener(onMessage))
|
|
browser.runtime.onMessage.removeListener(onMessage);
|
|
}
|
|
}
|
|
|
|
static async showInPopup(ownerWinId, params) {
|
|
let ownerWin;
|
|
if (!params) {
|
|
params = ownerWinId;
|
|
ownerWin = await browser.windows.getLastFocused({});
|
|
}
|
|
else {
|
|
try {
|
|
ownerWin = await browser.windows.get(ownerWinId).catch(_error => null);
|
|
}
|
|
catch(_error) {
|
|
}
|
|
if (!ownerWin) {
|
|
ownerWin = await browser.windows.getLastFocused({});
|
|
}
|
|
}
|
|
|
|
const type = this.DIALOG_READY_NOTIFICATION_TYPE;
|
|
const tryRepositionDialogToCenterOfOwner = this._tryRepositionDialogToCenterOfOwner;
|
|
browser.runtime.onMessage.addListener(function onMessage(message, sender) {
|
|
switch (message.type) {
|
|
case type:
|
|
browser.runtime.onMessage.removeListener(onMessage);
|
|
tryRepositionDialogToCenterOfOwner({
|
|
...message,
|
|
dialogWindowId: sender.tab.windowId,
|
|
});
|
|
break;
|
|
}
|
|
});
|
|
|
|
return this._showInPopupInternal(ownerWin, {
|
|
...params,
|
|
inject: {
|
|
...(params.inject || {}),
|
|
__RichConfirm__reportScreenMessageType: type,
|
|
__RichConfirm__ownerWindowId: ownerWin.id,
|
|
},
|
|
onShown: [
|
|
...(!params.onShown ? [] : Array.isArray(params.onShown) ? params.onShown : [params.onShown]),
|
|
(container, { __RichConfirm__reportScreenMessageType, __RichConfirm__ownerWindowId }) => {
|
|
setTimeout(() => {
|
|
// We cannot move this window by this callback function, thus I just send
|
|
// a request to update window position.
|
|
browser.runtime.sendMessage({
|
|
type: __RichConfirm__reportScreenMessageType,
|
|
ownerWindowId: __RichConfirm__ownerWindowId,
|
|
availLeft: screen.availLeft,
|
|
availTop: screen.availTop,
|
|
availWidth: screen.availWidth,
|
|
availHeight: screen.availHeight,
|
|
});
|
|
}, 0);
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
static async _tryRepositionDialogToCenterOfOwner({ dialogWindowId, ownerWindowId, availLeft, availTop, availWidth, availHeight }) {
|
|
const [dialogWin, ownerWin] = await Promise.all([
|
|
browser.windows.get(dialogWindowId),
|
|
browser.windows.get(ownerWindowId),
|
|
]);
|
|
const placedOnOwner = (
|
|
dialogWin.left + dialogWin.width - (dialogWin.width / 2) < ownerWin.left &&
|
|
dialogWin.top + dialogWin.height - (dialogWin.height / 2) < ownerWin.top &&
|
|
dialogWin.left + (dialogWin.width / 2) < ownerWin.left + ownerWin.width &&
|
|
dialogWin.top + (dialogWin.height / 2) < ownerWin.top + ownerWin.height
|
|
);
|
|
const placedInsideViewArea = (
|
|
dialogWin.left >= availLeft &&
|
|
dialogWin.top >= availTop &&
|
|
dialogWin.left + dialogWin.width <= availLeft + availWidth &&
|
|
dialogWin.top + dialogWin.height <= availTop + availHeight
|
|
);
|
|
if (placedOnOwner && placedInsideViewArea)
|
|
return;
|
|
|
|
const top = ownerWin.top + Math.round((ownerWin.height - dialogWin.height) / 2);
|
|
const left = ownerWin.left + Math.round((ownerWin.width - dialogWin.width) / 2);
|
|
return browser.windows.update(dialogWin.id, {
|
|
left: Math.min(availLeft + availWidth - dialogWin.width, Math.max(availLeft, left)),
|
|
top: Math.min(availTop + availHeight - dialogWin.height, Math.max(availTop, top)),
|
|
});
|
|
}
|
|
|
|
// Workaround for a problem on an overload situation.
|
|
// When the system is in overload, the promise returned by browser.windows.create()
|
|
// won't be resolved forever (until the window is closed).
|
|
// So, we detect the opened window without the promise in different way
|
|
// based on its unique URL.
|
|
static async _safeCreateWindow(params) {
|
|
const existingWindowIds = new Set((await browser.windows.getAll()).map(win => win.id));
|
|
// We must not add any extra query or hash for "about:blank", because it is very special URL.
|
|
// Extension with <all_urls> permission can inject arbitrary script to an "about:blank" page,
|
|
// but injection will fail for URIs like "about:blank#..." with missing host permission.
|
|
// Moreover, dialog window with "about:blank" is used to avoid closed windows restoration.
|
|
const uniqueKeyParam = params.url == 'about:blank' ? null : `popup-id-for-${uniqueKey}=${parseInt(Math.random() * Math.pow(2, 16))}`;
|
|
const dialogUrl = !uniqueKeyParam ? params.url : params.url.replace(/[?#]|$/, matched => {
|
|
if (!matched)
|
|
return `#${uniqueKeyParam}`;
|
|
if (matched == '?')
|
|
return `?${uniqueKeyParam}&`;
|
|
return `?#{uniqueKeyParam}#`;
|
|
});
|
|
let win;
|
|
const promisedWin = browser.windows.create({
|
|
...params,
|
|
url: dialogUrl,
|
|
}).then(resolvedWin => {
|
|
// The returned promise won't be resolved until the opened window become fucused.
|
|
console.log('RichConfirm._safeCreateWindow: promised window is resolved');
|
|
win = resolvedWin;
|
|
});
|
|
while (!win) {
|
|
await Promise.race([
|
|
new Promise(async (resolve, _reject) => {
|
|
if (win)
|
|
return resolve();
|
|
const windows = await browser.windows.getAll({ populate: true });
|
|
if (win)
|
|
return resolve();
|
|
for (const window of windows) {
|
|
if (existingWindowIds.has(window.id) ||
|
|
(uniqueKeyParam ? !window.tabs[0].url.includes(uniqueKeyParam) : window.tabs[0].url != dialogUrl))
|
|
continue;
|
|
|
|
console.log('RichConfirm._safeCreateWindow: new window is detected');
|
|
win = window;
|
|
resolve();
|
|
return;
|
|
}
|
|
setTimeout(resolve, 150);
|
|
}),
|
|
promisedWin,
|
|
]);
|
|
}
|
|
win.dialogUrl = dialogUrl;
|
|
return win;
|
|
}
|
|
|
|
static async _showInPopupInternal(ownerWin, params) {
|
|
const minWidth = Math.max(ownerWin.width, Math.ceil(screen.availWidth / 3));
|
|
const minHeight = Math.max(ownerWin.height, Math.ceil(screen.availHeight / 3));
|
|
|
|
const simulation = new this({
|
|
...params,
|
|
popup: true,
|
|
simulation: true
|
|
});
|
|
simulation.buildUI();
|
|
const simulatedContainer = simulation.ui.querySelector('.rich-confirm-row');
|
|
simulatedContainer.style.minWidth = `${minWidth}px`;
|
|
simulatedContainer.style.minHeight = `${minHeight}px`;
|
|
await new Promise((resolve, _reject) => {
|
|
simulation.show({
|
|
onShown() {
|
|
setTimeout(() => {
|
|
resolve();
|
|
}, 0);
|
|
}
|
|
});
|
|
});
|
|
const simulatedDialog = simulation.ui.querySelector('.rich-confirm-dialog');
|
|
const simulatedRect = simulatedDialog.getBoundingClientRect();
|
|
|
|
// Safe guard for scrollbar due to unexpected line breaks
|
|
const safetyFactor = 1.05;
|
|
const simulatedSize = {
|
|
width: Math.ceil(simulatedRect.width * safetyFactor),
|
|
height: Math.ceil(simulatedRect.height * safetyFactor)
|
|
};
|
|
simulation.hide();
|
|
// This dimension is not accurate because we must think about
|
|
// the size of the window frame, but currently I don't know how to
|
|
// calculate it here...
|
|
|
|
simulatedSize.top = ownerWin.top + Math.floor((ownerWin.height - simulatedSize.height) / 2);
|
|
simulatedSize.left = ownerWin.left + Math.floor((ownerWin.width - simulatedSize.width) / 2);
|
|
|
|
const url = params.url || 'about:blank';
|
|
const fullUrl = /^about:/.test(url) || /^\w+:\/\//.test(url) ?
|
|
url :
|
|
`moz-extension://${location.host}/${url.replace(/^\//, '')}`;
|
|
const win = await this._safeCreateWindow({
|
|
url: fullUrl,
|
|
type: 'popup',
|
|
...simulatedSize
|
|
});
|
|
const dialogUrl = win.dialogUrl || fullUrl;
|
|
// Due to a Firefox's bug we cannot open popup type window
|
|
// at specified position.
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1271047
|
|
// Thus we need to move the window immediately after it is opened.
|
|
if (win.left + win.width - (win.width / 2) <= ownerWin.left ||
|
|
win.top + win.height - (win.height / 2) <= ownerWin.top ||
|
|
win.left + (win.width / 2) >= ownerWin.left + ownerWin.width ||
|
|
win.top + (win.height / 2) >= ownerWin.top + ownerWin.height) {
|
|
// But, such a move will produce an annoying flash.
|
|
// So, I grudgingly accept the position of the dialog placed
|
|
// if the popup (partially or fully) covers the owner window.
|
|
browser.windows.update(win.id, {
|
|
top: simulatedSize.top,
|
|
left: simulatedSize.left
|
|
});
|
|
}
|
|
const activeTab = win.tabs.find(tab => tab.active);
|
|
|
|
const onFocusChanged = async windowId => {
|
|
if (!params.modal ||
|
|
windowId != ownerWin.id) {
|
|
return;
|
|
}
|
|
console.log(`focus of the window ${ownerWin.id} which is the owner of a modal dialog ${win.id} is changed`);
|
|
const [updatedWin, updatedOwnerWin] = await Promise.all([
|
|
browser.windows.get(win.id),
|
|
browser.windows.get(ownerWin.id),
|
|
]);
|
|
if (updatedOwnerWin?.state == 'minimized') {
|
|
console.log(' => but the owner window is minimized');
|
|
if (updatedWin.state != 'minimized') {
|
|
console.log(' => minimize the modal dialog also');
|
|
browser.windows.update(win.id, { state: 'minimized' });
|
|
}
|
|
return;
|
|
}
|
|
browser.windows.update(win.id, { focused: true });
|
|
};
|
|
browser.windows.onFocusChanged.addListener(onFocusChanged);
|
|
|
|
// On Thunderbird, closing of a composition window won't notify "windows.onRemoved" events, so we need to listen "tabs.onRemoved" also.
|
|
let onWindowClosed, onTabClosed;
|
|
const promisedDismissed = new Promise((resolve, _reject) => {
|
|
onWindowClosed = windowId => {
|
|
if (win.closed) {
|
|
return;
|
|
}
|
|
switch (windowId) {
|
|
case ownerWin.id:
|
|
browser.windows.remove(win.id);
|
|
break;
|
|
case win.id:
|
|
win.closed = true;
|
|
resolve({ buttonIndex: -1 });
|
|
break;
|
|
}
|
|
};
|
|
onTabClosed = (_tabId, removeInfo) => {
|
|
if (win.closed || !removeInfo.isWindowClosing) {
|
|
return;
|
|
}
|
|
switch (removeInfo.windowId) {
|
|
case ownerWin.id:
|
|
browser.windows.remove(win.id);
|
|
break;
|
|
case win.id:
|
|
win.closed = true;
|
|
resolve({ buttonIndex: -1 });
|
|
break;
|
|
}
|
|
};
|
|
});
|
|
browser.windows.onRemoved.addListener(onWindowClosed);
|
|
browser.tabs.onRemoved.addListener(onTabClosed);
|
|
|
|
const result = await Promise.race([
|
|
promisedDismissed,
|
|
(async () => {
|
|
try {
|
|
let onDialogOpenedCalled = false;
|
|
const frameSize = await new Promise((resolve, _reject) => {
|
|
let timeout;
|
|
const getFrameSize = function getFrameSize(title, uniqueKey) {
|
|
if (title)
|
|
document.title = title;
|
|
const classList = document.documentElement.classList;
|
|
classList.add(`rich-confirm-${uniqueKey}`);
|
|
classList.add('popup-window');
|
|
return {
|
|
width: window.outerWidth - window.innerWidth,
|
|
height: window.outerHeight - window.innerHeight,
|
|
url: location.href
|
|
};
|
|
};
|
|
const onTabUpdated = (tabId, updateInfo, _tab) => {
|
|
if (updateInfo.status != 'complete' ||
|
|
!browser.tabs.onUpdated.hasListener(onTabUpdated))
|
|
return;
|
|
if (timeout)
|
|
clearTimeout(timeout);
|
|
if (typeof browser.tabs.executeScript == 'function') // Manifest V2
|
|
browser.tabs.executeScript(tabId, {
|
|
code: `(${getFrameSize.toString()})(
|
|
${JSON.stringify(params.title)},
|
|
${JSON.stringify(uniqueKey)}
|
|
);`,
|
|
matchAboutBlank: true,
|
|
runAt: 'document_start'
|
|
}).then(results => {
|
|
if (results[0].url != dialogUrl)
|
|
return;
|
|
browser.tabs.onUpdated.removeListener(onTabUpdated);
|
|
resolve(results[0]);
|
|
});
|
|
else // Manifest V3
|
|
browser.scripting.executeScript({
|
|
target: { tabId },
|
|
func: getFrameSize,
|
|
args: [params.title, uniqueKey],
|
|
}).then(injectionResults => {
|
|
const result = injectionResults[0].result;
|
|
if (result.url != dialogUrl)
|
|
return;
|
|
browser.tabs.onUpdated.removeListener(onTabUpdated);
|
|
resolve(result);
|
|
});
|
|
|
|
if (typeof params.onDialogOpened == 'function' &&
|
|
!onDialogOpenedCalled) {
|
|
onDialogOpenedCalled = true;
|
|
params.onDialogOpened({
|
|
close() {
|
|
browser.windows.remove(win.id);
|
|
},
|
|
});
|
|
}
|
|
};
|
|
timeout = setTimeout(() => {
|
|
if (!browser.tabs.onUpdated.hasListener(onTabUpdated))
|
|
return;
|
|
timeout = null;
|
|
if (typeof browser.tabs.executeScript == 'function') // Manifest V2
|
|
browser.tabs.executeScript(activeTab.id, {
|
|
code: `(${getFrameSize.toString()})(
|
|
${JSON.stringify(params.title)},
|
|
${JSON.stringify(uniqueKey)}
|
|
);`,
|
|
matchAboutBlank: true,
|
|
runAt: 'document_start'
|
|
}).then(results => {
|
|
if (results[0].url != dialogUrl)
|
|
return;
|
|
browser.tabs.onUpdated.removeListener(onTabUpdated);
|
|
resolve(results[0]);
|
|
}).catch(console.error);
|
|
else // Manifest V3
|
|
browser.scripting.executeScript({
|
|
target: { tabId: activeTab.id },
|
|
func: getFrameSize,
|
|
args: [params.title, uniqueKey],
|
|
}).then(injectionResults => {
|
|
const result = injectionResults[0].result;
|
|
if (result.url != dialogUrl)
|
|
return;
|
|
browser.tabs.onUpdated.removeListener(onTabUpdated);
|
|
resolve(result);
|
|
}).catch(console.error);
|
|
|
|
if (typeof params.onDialogOpened == 'function' &&
|
|
!onDialogOpenedCalled) {
|
|
onDialogOpenedCalled = true;
|
|
params.onDialogOpened({
|
|
close() {
|
|
browser.windows.remove(win.id);
|
|
},
|
|
});
|
|
}
|
|
}, 500);
|
|
browser.tabs.onUpdated.addListener(onTabUpdated, {
|
|
properties: ['status'],
|
|
tabId: activeTab.id
|
|
});
|
|
});
|
|
|
|
if (typeof browser.tabs.setZoom == 'function')
|
|
await browser.tabs.setZoom(activeTab.id, 1);
|
|
return this.showInTab(activeTab.id, {
|
|
...params,
|
|
popup: true,
|
|
onReady(dialogSize) {
|
|
const actualWidth = Math.min(
|
|
Math.ceil(dialogSize.width + frameSize.width),
|
|
Math.max(
|
|
ownerWin.left + ownerWin.width - simulatedSize.left,
|
|
minWidth
|
|
)
|
|
);
|
|
const actualHeight = Math.min(
|
|
Math.ceil(dialogSize.height + frameSize.height),
|
|
Math.max(
|
|
ownerWin.top + ownerWin.height - simulatedSize.top,
|
|
minHeight
|
|
)
|
|
);
|
|
// This won't be proceeded until the promise returned by windows.create() is resolved,
|
|
// even if it is already detected via windows.query()... so we'll see oddly sized window
|
|
// until it become focused.
|
|
browser.windows.update(win.id, {
|
|
width: actualWidth,
|
|
height: actualHeight,
|
|
// We should reposition the dialog at truly center of the
|
|
// owner window, but it will produce an annoying slip of
|
|
// the window, so I give up for now.
|
|
//top: Math.floor(ownerWin.top + ((ownerWin.height - actualHeight) / 2)),
|
|
//left: Math.floor(ownerWin.left + ((ownerWin.width - actualWidth) / 2))
|
|
});
|
|
}
|
|
});
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
return null;
|
|
}
|
|
})()
|
|
]);
|
|
|
|
browser.windows.onFocusChanged.removeListener(onFocusChanged);
|
|
browser.windows.onRemoved.removeListener(onWindowClosed);
|
|
browser.tabs.onRemoved.removeListener(onTabClosed);
|
|
|
|
if (!win.closed) {
|
|
// A window closed with a blank page won't appear
|
|
// in the "Recently Closed Windows" list.
|
|
const reloadWithBlank = function reloadWithBlank() {
|
|
location.replace('about:blank');
|
|
};
|
|
(typeof browser.tabs.executeScript == 'function' ?
|
|
browser.tabs.executeScript(activeTab.id, { // Manifest V2
|
|
code: `(${reloadWithBlank.toString()})();`,
|
|
matchAboutBlank: true,
|
|
runAt: 'document_start'
|
|
}) :
|
|
browser.scripting.executeScript({ // Manifest V3
|
|
target: { tabId: activeTab.id },
|
|
func: reloadWithBlank,
|
|
}))
|
|
.then(() => {
|
|
browser.windows.remove(win.id);
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
};
|
|
RichConfirm.uniqueKey = RichConfirm.prototype.uniqueKey = uniqueKey;
|
|
RichConfirm.DIALOG_READY_NOTIFICATION_TYPE = `__RichConfirm_${uniqueKey}__confirmation-dialog-ready`;
|
|
window.RichConfirm = RichConfirm;
|
|
return true; // this is required to run this script as a content script
|
|
})(parseInt(Math.random() * Math.pow(2, 16)));
|
|
export default RichConfirm;
|