Recording - stay on same page when start recording, make popup iframe draggable, add close button to messages
This commit is contained in:
parent
7a43e06767
commit
8c03a76422
6 changed files with 719 additions and 402 deletions
|
@ -1,10 +1,18 @@
|
|||
:root {
|
||||
/* calculated from 12px */
|
||||
--block-line-separation-size: 0.33em; /* 10px */
|
||||
|
||||
/* Use for url and icon size */
|
||||
--icon-button-size: calc(calc(var(--block-line-separation-size) * 2) + 1.66em); /* 20px */
|
||||
}
|
||||
|
||||
#container-notifications,
|
||||
#container-notifications * {
|
||||
all: unset;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#container-notifications {
|
||||
display: block;
|
||||
inline-size: 100vw;
|
||||
inset-block-start: 0; /* stylelint-disable-line property-no-unknown */
|
||||
inset-inline-start: 0; /* stylelint-disable-line property-no-unknown */
|
||||
|
@ -12,8 +20,6 @@
|
|||
margin-block-start: 0;
|
||||
margin-inline-end: 0;
|
||||
margin-inline-start: 0;
|
||||
offset-block-start: 0;
|
||||
offset-inline-start: 0;
|
||||
padding-block-end: 0;
|
||||
padding-block-start: 0;
|
||||
padding-inline-end: 0;
|
||||
|
@ -22,86 +28,124 @@
|
|||
z-index: 999999999999;
|
||||
}
|
||||
|
||||
#container-notifications > iframe {
|
||||
#container-notifications > .popup {
|
||||
border: 1px solid;
|
||||
inset-block-start: 4px; /* stylelint-disable-line property-no-unknown */
|
||||
inset-inline-end: 4px; /* stylelint-disable-line property-no-unknown */
|
||||
offset-block-start: 4px;
|
||||
offset-inline-end: 4px;
|
||||
inset-inline-end: 3em; /* stylelint-disable-line property-no-unknown */
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#container-notifications > .popup > .draggable {
|
||||
background: #ebebeb url('../img/drag.svg');
|
||||
background-size: 100% 100%;
|
||||
block-size: var(--icon-button-size);
|
||||
border-block-end: 0.5px solid darkgray;
|
||||
inline-size: 100%;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#container-notifications > .popup > .draggable-mask {
|
||||
background-color: black;
|
||||
display: none;
|
||||
inset-block-end: 0; /* stylelint-disable-line property-no-unknown */
|
||||
inset-block-start: 0; /* stylelint-disable-line property-no-unknown */
|
||||
inset-inline-end: 0; /* stylelint-disable-line property-no-unknown */
|
||||
inset-inline-start: 0; /* stylelint-disable-line property-no-unknown */
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#container-notifications > div.recording {
|
||||
#container-notifications > .popup.drag > .draggable-mask {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#container-notifications > .message.recording {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#container-notifications > div {
|
||||
display: block;
|
||||
#container-notifications > .message {
|
||||
max-block-size: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 1s cubic-bezier(0.07, 0.95, 0, 1);
|
||||
transition: opacity 0.6s ease-in, max-block-size 1s cubic-bezier(0.07, 0.95, 0, 1);
|
||||
}
|
||||
|
||||
#container-notifications > div.show {
|
||||
#container-notifications > .message.show {
|
||||
max-block-size: 500px;
|
||||
transition: all 1s ease-in;
|
||||
opacity: 1;
|
||||
transition-property: max-block-size;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
#container-notifications > div:hover,
|
||||
#container-notifications > div:focus,
|
||||
#container-notifications > div:visited {
|
||||
#container-notifications > .message:hover,
|
||||
#container-notifications > .message:focus,
|
||||
#container-notifications > .message:visited {
|
||||
color: #003f07;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#container-notifications > div > div.real {
|
||||
#container-notifications > .message > .real {
|
||||
inset-block-end: 0; /* stylelint-disable-line property-no-unknown */
|
||||
offset-block-end: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#container-notifications > div > div.dummy {
|
||||
#container-notifications > .message > .dummy {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#container-notifications > div > div > div {
|
||||
align-items: center;
|
||||
#container-notifications > .message > div > div {
|
||||
align-items: stretch;
|
||||
background: #efefef;
|
||||
block-size: 3em;
|
||||
color: #003f07;
|
||||
display: flex;
|
||||
font: 12px sans-serif;
|
||||
font: 1em sans-serif;
|
||||
inline-size: 100vw;
|
||||
justify-content: start;
|
||||
margin-block-end: 0;
|
||||
margin-block-start: 0;
|
||||
margin-inline-end: 0;
|
||||
margin-inline-start: 0;
|
||||
padding-block-end: 8px;
|
||||
padding-block-start: 8px;
|
||||
padding-inline-end: 8px;
|
||||
padding-inline-start: 8px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
#container-notifications > div > div > div > .title {
|
||||
font-weight: bold;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
#container-notifications > div > div > div > .logo {
|
||||
block-size: 16px;
|
||||
display: inline-block;
|
||||
inline-size: 16px;
|
||||
#container-notifications > .message > div > div > .logo {
|
||||
align-self: center;
|
||||
block-size: 1em;
|
||||
flex: 0 0 1em;
|
||||
margin-inline-end: 3px;
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
|
||||
#container-notifications > div.recording > div > div {
|
||||
#container-notifications > .message > div > div > .title {
|
||||
align-self: center;
|
||||
flex: 0;
|
||||
font-weight: bold;
|
||||
margin-inline-end: 1em;
|
||||
margin-inline-start: 0.5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#container-notifications > .message > div > div > .text {
|
||||
align-self: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#container-notifications > .message > div > div > .close {
|
||||
background: url('../img/container-close-tab.svg');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 50%;
|
||||
flex: 0 0 2em;
|
||||
margin-inline-end: 0.5em;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#container-notifications > .message > div > div > .close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#container-notifications > .message.recording > div > div {
|
||||
background: #fcc;
|
||||
}
|
||||
|
||||
#container-notifications > div.recording > div > div > .title {
|
||||
#container-notifications > .message.recording > div > div > .title {
|
||||
color: red;
|
||||
}
|
||||
|
|
|
@ -802,6 +802,7 @@ span ~ .panel-header-text {
|
|||
block-size: 100%;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding-block-start: 6px;
|
||||
padding-inline-start: 30%;
|
||||
|
|
7
src/img/drag.svg
Normal file
7
src/img/drag.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" preserveAspectRatio="none">
|
||||
<g>
|
||||
<line x1="1" y1="4" x2="15" y2="4" stroke-width="0.5" stroke="#ccc"/>
|
||||
<line x1="1" y1="8" x2="15" y2="8" stroke-width="0.5" stroke="#ccc"/>
|
||||
<line x1="1" y1="12" x2="15" y2="12" stroke-width="0.5" stroke="#ccc"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 324 B |
|
@ -43,6 +43,9 @@ const messageHandler = {
|
|||
case "setOrRemoveRecording":
|
||||
response = recordManager.setTabId(m.tabId);
|
||||
break;
|
||||
case "setTabPopupPosition":
|
||||
response = recordManager.setTabPopupPosition(m.tabId, m.x, m.y);
|
||||
break;
|
||||
case "sortTabs":
|
||||
backgroundLogic.sortTabs();
|
||||
break;
|
||||
|
|
|
@ -1,31 +1,41 @@
|
|||
const recordManager = {
|
||||
recording: null,
|
||||
listening: null,
|
||||
|
||||
recording: undefined,
|
||||
listening: undefined,
|
||||
|
||||
Recording: class {
|
||||
constructor(tab) {
|
||||
if (tab) {
|
||||
this.windowId = tab.windowId;
|
||||
this.tabId = tab.id;
|
||||
this.isTabActive = tab.active;
|
||||
this.isTabReady = tab.url.startsWith("http");
|
||||
this.tabMessage = {};
|
||||
} else {
|
||||
this.windowId = browser.windows.WINDOW_ID_NONE;
|
||||
this.tabId = browser.tabs.TAB_ID_NONE;
|
||||
this.isTabActive = false;
|
||||
this.isTabReady = false;
|
||||
this.tabMessage = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
get valid() {
|
||||
return this.tabId !== browser.tabs.TAB_ID_NONE;
|
||||
|
||||
get valid() { return this.tabId !== browser.tabs.TAB_ID_NONE; }
|
||||
|
||||
updateTabPopup(active, opts) {
|
||||
if (this.valid) { this.tabMessage.popup = active; this.updateTabPopupOptions(opts); }
|
||||
}
|
||||
|
||||
|
||||
updateTabPopupOptions(opts) {
|
||||
if (this.valid) { Object.assign(this.tabMessage.popupOptions, opts); }
|
||||
}
|
||||
|
||||
async sendTabMessage() {
|
||||
return messageHandler.sendTabMessage(this.tabId, this.tabMessage);
|
||||
}
|
||||
|
||||
|
||||
async stop() {
|
||||
if (!this.valid) { return; }
|
||||
|
||||
|
||||
recordManager.listening.enabled = false;
|
||||
|
||||
// Update GUI
|
||||
|
@ -36,71 +46,76 @@ const recordManager = {
|
|||
return this.sendTabMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async start() {
|
||||
if (!this.valid) { return; }
|
||||
|
||||
recordManager.listening.enabled = true;
|
||||
|
||||
|
||||
// Update GUI
|
||||
const baPopup = messageHandler.browserAction.popup;
|
||||
const tabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId);
|
||||
this.tabMessage = { recording: true, popup: tabPopup, popupOptions: {tabId: this.tabId} };
|
||||
const showingPage = browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") });
|
||||
const showTabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId);
|
||||
this.tabMessage = { recording: true, popup: showTabPopup, popupOptions: {tabId: this.tabId, hide:!showTabPopup} };
|
||||
const showingPage = this.isTabReady
|
||||
? Promise.resolve()
|
||||
: browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") });
|
||||
const messagingTab = this.sendTabMessage();
|
||||
|
||||
return Promise.all([showingPage, messagingTab]);
|
||||
}
|
||||
|
||||
|
||||
// Re-show recording state on page load
|
||||
onTabsUpdated(tabId, changeInfo) {
|
||||
if (this.tabId === tabId && changeInfo.status === "complete") {
|
||||
this.sendTabMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Show/hide tabPopup on this tab show/hide
|
||||
onTabsActivated(activeInfo) {
|
||||
if (this.tabId === activeInfo.tabId) {
|
||||
this.isTabActive = true;
|
||||
this.sendTabMessage();
|
||||
} else if (this.windowId === activeInfo.windowId) {
|
||||
this.isTabActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Keep track of tab's windowId
|
||||
onTabsAttached(tabId, attachInfo) {
|
||||
if (this.tabId === tabId) {
|
||||
this.windowId = attachInfo.newWindowId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Stop recording on close
|
||||
onTabsRemoved(tabId) {
|
||||
if (this.tabId === tabId) {
|
||||
recordManager.setTabId(browser.tabs.TAB_ID_NONE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Show/hide tabPopup on hide/show browserActionPopup
|
||||
onToggleBrowserActionPopup(baPopupVisible, baPopup) {
|
||||
if (this.windowId === baPopup.windowId && this.isTabActive) {
|
||||
this.tabMessage.popup = !baPopupVisible;
|
||||
this.tabMessage.popupOptions = { tabId:this.tabId, width:baPopup.width, height:baPopup.height };
|
||||
const showTabPopup = !baPopupVisible;
|
||||
this.updateTabPopup(showTabPopup, { width:baPopup.width, height:baPopup.height, hide:!showTabPopup });
|
||||
this.sendTabMessage();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Listening: class {
|
||||
constructor() {
|
||||
this._enabled = false;
|
||||
}
|
||||
|
||||
|
||||
get enabled() { return this._enabled; }
|
||||
|
||||
|
||||
set enabled(enabled) {
|
||||
if (this._enabled === !!enabled) { return; }
|
||||
this._enabled = !!enabled;
|
||||
|
||||
|
||||
if (enabled) {
|
||||
browser.tabs.onUpdated.addListener(this.onTabsUpdated, { properties: ["status"] });
|
||||
browser.tabs.onActivated.addListener(this.onTabsActivated);
|
||||
|
@ -117,7 +132,7 @@ const recordManager = {
|
|||
window.removeEventListener("BrowserActionPopupUnload", this.onBrowserActionPopupUnload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onTabsUpdated(...args) { recordManager.recording.onTabsUpdated(...args); }
|
||||
onTabsActivated(...args) { recordManager.recording.onTabsActivated(...args); }
|
||||
onTabsAttached(...args) { recordManager.recording.onTabsAttached(...args); }
|
||||
|
@ -125,52 +140,56 @@ const recordManager = {
|
|||
onBrowserActionPopupLoad() { recordManager.recording.onToggleBrowserActionPopup(true, messageHandler.browserAction.popup); }
|
||||
onBrowserActionPopupUnload() { recordManager.recording.onToggleBrowserActionPopup(false, messageHandler.browserAction.popup); }
|
||||
},
|
||||
|
||||
|
||||
init() {
|
||||
this.recording = new recordManager.Recording();
|
||||
this.listening = new recordManager.Listening();
|
||||
},
|
||||
|
||||
|
||||
isRecordingTabId(tabId) {
|
||||
if (!this.recording.valid) { return false; }
|
||||
if (this.recording.tabId !== tabId) { return false; }
|
||||
return true;
|
||||
},
|
||||
|
||||
|
||||
getTabId() {
|
||||
return this.recording.tabId;
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
async setTabId(tabId) {
|
||||
// Ensure tab is recordable
|
||||
tabId = backgroundLogic.asTabId(tabId);
|
||||
const tab = await backgroundLogic.getTabOrNull(tabId);
|
||||
const wantRecordableTab = tabId !== browser.tabs.TAB_ID_NONE;
|
||||
const isRecordableTab = tab && "cookieStoreId" in tab;
|
||||
|
||||
|
||||
// Invalid tab - stop recording & throw error
|
||||
if (wantRecordableTab && !isRecordableTab) {
|
||||
this.setTabId(browser.tabs.TAB_ID_NONE); // Don't wait for stop
|
||||
throw new Error(`Recording not possible for tab with id ${tabId}`);
|
||||
}
|
||||
|
||||
|
||||
// Already recording
|
||||
if (this.recording.tabId === tabId) { return; }
|
||||
|
||||
|
||||
const oldRecording = this.recording;
|
||||
const newRecording = this.recording = new recordManager.Recording(tab);
|
||||
|
||||
|
||||
// Don't wait for stop
|
||||
oldRecording.stop();
|
||||
if (oldRecording.valid) { oldRecording.stop(); }
|
||||
try {
|
||||
// But DO wait for start
|
||||
await newRecording.start();
|
||||
|
||||
if (newRecording.valid) { await newRecording.start(); }
|
||||
|
||||
// If error while starting, immediately stop, but don't wait
|
||||
} catch (e) {
|
||||
this.setTabId(browser.tabs.TAB_ID_NONE);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async setTabPopupPosition(tabId, x, y) {
|
||||
if (this.isRecordingTabId(tabId)) { this.recording.updateTabPopupOptions({x,y}); }
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,395 +1,638 @@
|
|||
function asError(reason) { return reason && (reason instanceof Error) ? reason : new Error(reason); }
|
||||
function resolves(value) { return (resolve) => { resolve(value); }; }
|
||||
// function rejects(reason) { return (resolve, reject) => { reject(asError(reason)); }; }
|
||||
|
||||
// Easily build promises that:
|
||||
// 1. combine reusable behaviours (e.g. onTimeout, onEvent)
|
||||
// 2. have a cleanup phase (e.g. to remove listeners)
|
||||
// 3. can be interrupted (e.g. on unload)
|
||||
class PromiseBuilder {
|
||||
class Defer extends Promise {
|
||||
constructor() {
|
||||
this._promise = Promise.race([
|
||||
// Interrupter
|
||||
new Promise((resolve, reject) => { this.interrupt = reject; }),
|
||||
// Main
|
||||
new Promise((resolve, reject) => {
|
||||
this.resolve = resolve;
|
||||
this.reject = (reason, options) => {
|
||||
(options && options.interrupt ? this.interrupt : reject)(asError(reason));
|
||||
};
|
||||
// Cleanup
|
||||
}).finally(() => { if (this.completions) { this.completions.forEach((completion) => { completion(); }); } })
|
||||
]);
|
||||
let resolve, reject;
|
||||
super((res, rej) => { resolve = res; reject = rej; });
|
||||
this.resolve = resolve; this.reject = reject;
|
||||
}
|
||||
|
||||
async _tryHandler(handler, name, ...args) {
|
||||
try {
|
||||
await handler(...args);
|
||||
} catch (e) {
|
||||
console.error(`Failed: ${name}: ${e.message}`);
|
||||
this.reject(e);
|
||||
// Fix to make then/catch/finally return a vanilla Promise, not a Defer
|
||||
static get [Symbol.species]() { return Promise; }
|
||||
get [Symbol.toStringTag]() { return this.constructor.name; }
|
||||
}
|
||||
|
||||
/**
|
||||
Wraps a promise chain that:
|
||||
1. can be interrupted (e.g. to stop an animation)
|
||||
2. has a cleanup phase (e.g. to remove listeners)
|
||||
|
||||
Note: interrupting is important when the browser is about to redirect. The background
|
||||
script may have previously sent us a message and we returned a promise while we show
|
||||
the message using an animation. If the browser now redirects, the promise is lost
|
||||
and the background script hangs waiting forever on a promise that will never finish.
|
||||
By interrupting the promise, we ensure the background script receives an error.
|
||||
*/
|
||||
class Operation extends Defer {
|
||||
constructor(name) {
|
||||
super();
|
||||
this.name = name;
|
||||
this.finished = this.error = this.completions = undefined;
|
||||
const resolveFinally = this.resolve;
|
||||
const rejectFinally = this.reject;
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }).then(
|
||||
v => { this._setFinished(); resolveFinally(v); },
|
||||
v => { this.error = v || new Error(`${this} failed`); this._setFinished(); rejectFinally(this.error); });
|
||||
}
|
||||
|
||||
_setFinished() {
|
||||
this.finished = true;
|
||||
if (this.completions) {
|
||||
this.completions.forEach(completion => { try { completion(this); } catch (e) { this.errored("completion", e); } });
|
||||
this.completions = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
promise(handler) {
|
||||
if (handler) { this._tryHandler(handler, "promise", this); }
|
||||
return this._promise;
|
||||
mustBeRunning(running, optional) {
|
||||
const wantFinished = running === false;
|
||||
const ok = wantFinished === !!this.finished;
|
||||
if (!ok && !optional) { throw new Error(`${this} ${wantFinished ? "unfinished" : "cancelled"}`); }
|
||||
return ok;
|
||||
}
|
||||
|
||||
onCompletion(completion) {
|
||||
if (!this.completions) { this.completions = []; }
|
||||
this.completions.push(completion);
|
||||
return this;
|
||||
addFinishListener(listener) {
|
||||
if (this.finished) {
|
||||
listener({target: this});
|
||||
} else {
|
||||
if (!(this.completions || (this.completions = [])).find(c => c === listener)) { this.completions.push(listener); }
|
||||
}
|
||||
}
|
||||
|
||||
onTimeout(delay, timeoutHandler) {
|
||||
const timer = () => { this._tryHandler(timeoutHandler, "timeout", this.resolve, this.reject); };
|
||||
let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay);
|
||||
this.onCompletion(() => { clearTimeout(timeoutId); });
|
||||
return this;
|
||||
removeFinishListener(listener) {
|
||||
if (this.completions) { this.completions = this.completions.filter(c => c !== listener); }
|
||||
}
|
||||
|
||||
onFutureEvent(target, eventName, eventHandler) {
|
||||
const listener = (event) => { this._tryHandler(eventHandler, eventName, this.resolve, this.reject, event); };
|
||||
target.addEventListener(eventName, listener, {once: true});
|
||||
this.onCompletion(() => { target.removeEventListener(eventName, listener); });
|
||||
return this;
|
||||
addEventListener(type, listener) {
|
||||
if (/^finish$/i.test(type)) { this.addFinishListener(listener); }
|
||||
else { throw new Error(`${this} unsupported event '${type}'`); }
|
||||
}
|
||||
|
||||
onEvent(target, eventName, eventHandler) {
|
||||
if (target === window) {
|
||||
eventName = eventName.toLowerCase();
|
||||
if (eventName === "domcontentloaded" || eventName === "load") {
|
||||
switch (document.readyState) {
|
||||
case "loading": break;
|
||||
case "interactive":
|
||||
if (eventName === "load") { break; }
|
||||
// Fall through
|
||||
case "complete":
|
||||
// Event already fired - run immediately
|
||||
this._tryHandler(eventHandler, eventName, this.resolve, this.reject);
|
||||
return this;
|
||||
removeEventListener(type, listener) {
|
||||
if (/^finish$/i.test(type)) { this.removeFinishListener(listener); }
|
||||
}
|
||||
|
||||
errored(name, e) { console.error("%s error during %s: %s", this, name, e); }
|
||||
toString() { return this.name || this.constructor.name; }
|
||||
}
|
||||
|
||||
/**
|
||||
Builds an operation with concurrent tasks (e.g. onTimeout, onEvent).
|
||||
*/
|
||||
class Operator {
|
||||
constructor(operation) {
|
||||
if (operation) {
|
||||
this.operation = typeof operation === "string" ? new Operation(operation) : operation;
|
||||
} else {
|
||||
const name = this.constructor === Operator ? undefined : `${this.constructor.name}Operation`;
|
||||
this.operation = new Operation(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Performs a named task, checks if operation is already finished and handles errors.
|
||||
async exec(handler, opts = {name: "exec"}, ...args) {
|
||||
if (this.operation.mustBeRunning(!opts.finished, opts.optional)) {
|
||||
try {
|
||||
const result = await handler(this.operation, ...args);
|
||||
this.operation.mustBeRunning(!opts.finished, opts.optional);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (!this.operation.finished || opts.finished) {
|
||||
this.operation.errored(name, e);
|
||||
this.operation.reject(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.onFutureEvent(target, eventName, eventHandler);
|
||||
}
|
||||
|
||||
delay(millis) {
|
||||
return this.exec(() => { return new Promise(resolve => setTimeout(resolve, millis)); }, {name: "delay"});
|
||||
}
|
||||
|
||||
onStart(handler) { this.exec(handler, {name: "start"}); return this; }
|
||||
onFinish(handler) { this.operation.addFinishListener(e => handler(e.target)); return this; }
|
||||
|
||||
onTimeout(delay, handler) {
|
||||
const timer = () => this.exec(handler, {name: "timeout", optional: true});
|
||||
let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay);
|
||||
this.onFinish(() => clearTimeout(timeoutId));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class Animation {
|
||||
static delay(delay = 350) {
|
||||
return new Promise((resolve) => { setTimeout(resolve, delay); });
|
||||
onEvent(target, type, handler) {
|
||||
if (target) {
|
||||
const options = {name: type, optional: true, finished: this.isFinishEvent(target, type)};
|
||||
if (this.isPriorEvent(target, type)) {
|
||||
this.exec(handler, options, {target});
|
||||
} else {
|
||||
const listener = event => this.exec(handler, options, event);
|
||||
target.addEventListener(type, listener, {once: true});
|
||||
this.onFinish(() => target.removeEventListener(type, listener));
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
static async toggle(element, show, timeoutDelay = 3000) {
|
||||
const shown = element.classList.contains("show");
|
||||
if (shown === !!show) { return; }
|
||||
isPriorEvent(target, type) {
|
||||
if (this.isWindowLoadEvent(target, type) || this.isWindowInteractiveEvent(target, type)) {
|
||||
switch (document.readyState) {
|
||||
case "loading": return false;
|
||||
case "complete": return true;
|
||||
case "interactive": return this.isWindowInteractiveEvent(target, type);
|
||||
}
|
||||
} else if (this.isFinishEvent(target, type)) {
|
||||
return target.finished;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
if (show) {
|
||||
if (!element.classList.contains("show")) {
|
||||
element.classList.add("show");
|
||||
}
|
||||
} else {
|
||||
element.classList.remove("show");
|
||||
isWindowLoadEvent(target, type) { return target === window && /^load$/i.test(type); }
|
||||
isWindowInteractiveEvent(target, type) { return target === window && /^domcontentloaded$/i.test(type); }
|
||||
isFinishEvent(target, type) { return "finished" in target && /^finish$/i.test(type); }
|
||||
}
|
||||
|
||||
class Animator extends Operator {
|
||||
static isShown(elem) { return elem && elem.classList.contains("show"); }
|
||||
|
||||
toggle(elem, show, timeoutDelay = 3000) {
|
||||
if (Animator.isShown(elem) === !!show) { return; }
|
||||
|
||||
const animate = (operation) => {
|
||||
if (!operation.finished) {
|
||||
elem.classList[(show ? "add" : "remove")]("show");
|
||||
}
|
||||
};
|
||||
|
||||
return new PromiseBuilder()
|
||||
.onTimeout(timeoutDelay, resolves())
|
||||
.onEvent(element, "transitionend", resolves())
|
||||
.promise((promise) => {
|
||||
|
||||
// Delay until element has been rendered
|
||||
requestAnimationFrame(() => {
|
||||
return new Operator(`Animate${show ? "Show" : "Hide"}`)
|
||||
// Ensure animation always reaches final state on timeout
|
||||
.onTimeout(timeoutDelay, operation => { animate(operation); operation.resolve(); })
|
||||
.onEvent(elem, "transitionend", operation => operation.resolve())
|
||||
.onEvent(this.operation, "finish", operation => operation.reject("Interrupted"))
|
||||
.onStart(operation => {
|
||||
requestAnimationFrame(() => { // Delay until element has been rendered
|
||||
setTimeout(() => {
|
||||
animate();
|
||||
}, 10);
|
||||
animate(operation);
|
||||
}, 1);
|
||||
});
|
||||
|
||||
// Ensure animation always reaches final state
|
||||
promise.onCompletion(animate);
|
||||
});
|
||||
})
|
||||
.operation;
|
||||
}
|
||||
}
|
||||
|
||||
class UIRequest {
|
||||
constructor (component, action, options, response) {
|
||||
this.component = component;
|
||||
this.action = action;
|
||||
this.options = options;
|
||||
this.response = response || new UIResponse();
|
||||
class Draggable {
|
||||
constructor(elem) {
|
||||
this.elem = elem;
|
||||
this.x = this.y = this.left = this.top = this.insetTop = this.insetLeft = this.insetBottom = this.insetRight = this.finishHandler = undefined;
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onDrag = this.onDrag.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
class UIResponse {
|
||||
constructor (value) {
|
||||
let promise;
|
||||
if (value instanceof Promise) { promise = value; }
|
||||
if (value !== undefined) { promise = Promise.resolve(value); }
|
||||
this.modifyingDOM = this.animating = promise;
|
||||
start(finishHandler) {
|
||||
this.finishHandler = finishHandler;
|
||||
this.elem.querySelector(".draggable").addEventListener("mousedown", this.onMouseDown);
|
||||
}
|
||||
}
|
||||
|
||||
class UIRequestManager {
|
||||
static request(component, action, options) {
|
||||
// Try for quick return
|
||||
if (component.unique) {
|
||||
const previous = this.requests && this.requests[component.name];
|
||||
stop() {
|
||||
this.onMouseUp();
|
||||
this.elem.querySelector(".draggable").removeEventListener("mousedown", this.onMouseDown);
|
||||
this.finishHandler = undefined;
|
||||
}
|
||||
|
||||
// Quick return if request already enqueued
|
||||
if (previous && previous.action === action) {
|
||||
// Previous request is also an add, but we've got an extra update to do as well
|
||||
if (action === "add" && component.onUpdate && options) {
|
||||
return new UIResponse(previous.response.animating.then((elem) => {
|
||||
const updating = component.onUpdate(elem, options);
|
||||
return updating ? updating.then(elem) : elem;
|
||||
}));
|
||||
// No update needed, so can just reuse previous request
|
||||
} else {
|
||||
return previous.response;
|
||||
}
|
||||
}
|
||||
onMouseDown(e) {
|
||||
e.preventDefault();
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
const minInsetX = rect.width - 30;
|
||||
const minInsetY = rect.height - 30;
|
||||
this.x = e.clientX;
|
||||
this.y = e.clientY;
|
||||
this.insetTop = e.clientY - rect.top - minInsetY;
|
||||
this.insetLeft = e.clientX - rect.left - minInsetX;
|
||||
this.insetBottom = rect.bottom - e.clientY - minInsetY;
|
||||
this.insetRight = rect.right - e.clientX - minInsetX;
|
||||
this.elem.classList.add("drag");
|
||||
document.addEventListener("mousemove", this.onDrag);
|
||||
window.addEventListener("mouseup", this.onMouseUp);
|
||||
}
|
||||
|
||||
// Quick return if no request pending and element already added/removed
|
||||
if (!previous) {
|
||||
const element = this._get(component);
|
||||
if (element) {
|
||||
if (action === "add") { return new UIResponse(element); }
|
||||
} else {
|
||||
if (action === "remove") { return new UIResponse(null); }
|
||||
}
|
||||
}
|
||||
onDrag(e) {
|
||||
e.preventDefault();
|
||||
const x = Math.max(this.insetLeft, Math.min(window.innerWidth - this.insetRight, e.clientX));
|
||||
const y = Math.max(this.insetTop, Math.min(window.innerHeight - this.insetBottom, e.clientY));
|
||||
const deltaX = this.x - x;
|
||||
const deltaY = this.y - y;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.left = this.elem.offsetLeft - deltaX;
|
||||
this.top = this.elem.offsetTop - deltaY;
|
||||
Draggable.moveTo(this.elem, this.left, this.top);
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.elem.classList.remove("drag");
|
||||
document.removeEventListener("mousemove", this.onDrag);
|
||||
window.removeEventListener("mouseup", this.onMouseUp);
|
||||
if (this.finishHandler) {
|
||||
this.finishHandler(this.left, this.top);
|
||||
}
|
||||
|
||||
// New request
|
||||
const response = new UIResponse();
|
||||
const request = new UIRequest(component, action, options, response);
|
||||
|
||||
// Enqueue
|
||||
let previous;
|
||||
if (component.unique) {
|
||||
if (!this.requests) { this.requests = {}; }
|
||||
previous = this.requests[component.name];
|
||||
this.requests[component.name] = request;
|
||||
}
|
||||
|
||||
// Execute
|
||||
response.modifyingDOM = new Promise((resolve,reject) => {
|
||||
const modifiedDOM = {resolve,reject};
|
||||
response.animating = new Promise((resolve,reject) => {
|
||||
const animated = {resolve,reject};
|
||||
this._execute(request, previous, modifiedDOM, animated);
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
static _get(component) {
|
||||
const unique = component.unique;
|
||||
if (!unique) { return null; }
|
||||
if (unique.id) {
|
||||
return document.getElementById(unique.id);
|
||||
static moveTo(elem, x, y) {
|
||||
let left, top;
|
||||
if (x === undefined || y === undefined) {
|
||||
left = top = "";
|
||||
} else {
|
||||
if ("querySelector" in component.parent) {
|
||||
return component.parent.querySelector(unique.selector);
|
||||
} else {
|
||||
const parent = this._get(component.parent);
|
||||
if (parent) {
|
||||
return parent.querySelector(unique.selector);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async _execute(request, previous, modifiedDOM, animated) {
|
||||
try {
|
||||
if (previous) {
|
||||
try { await previous.response.animating; } catch (e) { /* Ignore previous success/failure */ }
|
||||
}
|
||||
|
||||
const component = request.component;
|
||||
const options = request.options;
|
||||
|
||||
// Get parent
|
||||
let parentElement;
|
||||
if ("querySelector" in component.parent) {
|
||||
parentElement = component.parent;
|
||||
} else {
|
||||
if (request.action === "add") {
|
||||
parentElement = await this.request(component.parent, "add", options).modifyingDOM;
|
||||
} else {
|
||||
parentElement = this._get(component.parent);
|
||||
}
|
||||
}
|
||||
|
||||
let element;
|
||||
|
||||
// Add
|
||||
if (request.action === "add") {
|
||||
element = await component.create(options);
|
||||
if (component.onUpdate) { await component.onUpdate(element, options); }
|
||||
|
||||
if (component.prepend) {
|
||||
parentElement.prepend(element);
|
||||
} else {
|
||||
parentElement.appendChild(element);
|
||||
}
|
||||
|
||||
modifiedDOM.resolve(element);
|
||||
|
||||
if (component.onAdd) { await component.onAdd(element, options); }
|
||||
|
||||
// Remove
|
||||
} else {
|
||||
if (parentElement) {
|
||||
element = this._get(component);
|
||||
if (element) {
|
||||
if (component.onRemove) { await component.onRemove(element, options); }
|
||||
element.remove();
|
||||
}
|
||||
modifiedDOM.resolve(element);
|
||||
}
|
||||
}
|
||||
|
||||
animated.resolve(element);
|
||||
|
||||
} catch (e) {
|
||||
modifiedDOM.reject(e);
|
||||
animated.reject(e);
|
||||
} finally {
|
||||
if (this.requests && this.requests[request.component.name] === request) {
|
||||
this.requests[request.component.name] = null;
|
||||
}
|
||||
left = `min(calc(100vw - 30px), ${x}px)`;
|
||||
top = `min(calc(100vh - 30px), ${y}px)`;
|
||||
}
|
||||
elem.style.left = left;
|
||||
elem.style.top = top;
|
||||
}
|
||||
}
|
||||
|
||||
class UI {
|
||||
static async toggle(component, show, options) {
|
||||
class Component {
|
||||
static async toggle(show, options) {
|
||||
const action = show ? "add" : "remove";
|
||||
const response = UIRequestManager.request(component, action, options);
|
||||
const response = UI.request(this, action, options);
|
||||
return response.animating;
|
||||
}
|
||||
}
|
||||
|
||||
class Container {
|
||||
static get parent() { return document.body; }
|
||||
static get unique() { return { id: "container-notifications" }; }
|
||||
static create() {
|
||||
const elem = document.createElement("div");
|
||||
elem.id = this.unique.id;
|
||||
return elem;
|
||||
}
|
||||
}
|
||||
|
||||
class Popup {
|
||||
static get parent() { return Container; }
|
||||
static get unique() { return { selector: "iframe" }; }
|
||||
static get prepend() { return true; }
|
||||
static create(options) {
|
||||
const elem = document.createElement("iframe");
|
||||
elem.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
||||
elem.src = browser.runtime.getURL("/popup.html") + "?tabId=" + options.tabId;
|
||||
return elem;
|
||||
}
|
||||
static onUpdate(elem, options) {
|
||||
if (!options) { return; }
|
||||
if (options.width) {
|
||||
const width = options.width;
|
||||
const height = options.height || 400;
|
||||
elem.style.width = `${width}px`;
|
||||
elem.style.height = `${height}px`;
|
||||
static getElement(isAll) {
|
||||
if (isAll) { return this.getElements(); }
|
||||
const unique = this.unique;
|
||||
if (unique) {
|
||||
if (unique.id) {
|
||||
return document.getElementById(unique.id);
|
||||
} else {
|
||||
const parentElem = this.getParentElement();
|
||||
return parentElem && parentElem.querySelector(unique.selector);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Recording {
|
||||
static get parent() { return Container; }
|
||||
static get unique() { return { selector: ".recording" }; }
|
||||
static get prepend() { return true; }
|
||||
static async create() {
|
||||
const elem = await Message.create({
|
||||
title: "Recording",
|
||||
text: "Sites will be automatically added to this container as you browse in this tab"
|
||||
});
|
||||
elem.classList.add("recording");
|
||||
return elem;
|
||||
static getElements() {
|
||||
const identifier = this.all || this.unique;
|
||||
if (identifier) {
|
||||
const parents = this.getParentElement(true);
|
||||
if (parents) {
|
||||
const selector = identifier.selector || `#${identifier.id}`;
|
||||
return parents.flatMap(parent => Array.from(parent.querySelectorAll(selector)));
|
||||
}
|
||||
}
|
||||
}
|
||||
static onAdd(elem) { return Animation.toggle(elem, true); }
|
||||
static onRemove(elem) { return Animation.toggle(elem, false); }
|
||||
|
||||
static getParentElement(isAll) {
|
||||
const parent = this.parent;
|
||||
return "querySelector" in parent ? (isAll ? [parent] : parent) : parent.getElement(isAll);
|
||||
}
|
||||
|
||||
static get options() { return this.unique || this.all ? ["all"] : []; }
|
||||
static isReady() { return true; }
|
||||
static toString() { return this.name; }
|
||||
}
|
||||
|
||||
class Message {
|
||||
static get parent() { return Container; }
|
||||
static async create(options) {
|
||||
// Message
|
||||
const msgElem = document.createElement("div");
|
||||
const UI = {
|
||||
requests: {
|
||||
_store: [],
|
||||
getRequest: function(component) {
|
||||
if (component.unique) { return this._store[component.name]; }
|
||||
},
|
||||
addRequest: function(request) {
|
||||
if (request.component.unique) { this._store[request.component.name] = request; }
|
||||
},
|
||||
removeRequest: function(request) {
|
||||
if (this._store[request.component.name] === request) {
|
||||
this._store[request.component.name] = undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Text
|
||||
// Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available
|
||||
msgElem.innerText = options.text;
|
||||
request: function(component, action, options = {}) {
|
||||
const request = new UI.Request(component, action, options);
|
||||
const previous = this.requests.getRequest(component);
|
||||
|
||||
// Title
|
||||
if (options.title) {
|
||||
const titleElem = document.createElement("span");
|
||||
titleElem.classList.add("title");
|
||||
titleElem.innerText = options.title;
|
||||
msgElem.prepend(titleElem);
|
||||
// Already requested
|
||||
if (request.isEqual(previous)) { return previous.response; }
|
||||
|
||||
// Element already added/removed
|
||||
if (!previous) {
|
||||
if (component.unique || request.options.all) {
|
||||
const elem = component.getElement(request.options.all);
|
||||
if (elem && (!request.options.all || elem.length > 0)) {
|
||||
if (action === "add") { if (component.isReady(elem, options)) { return UI.Response.element(elem); } }
|
||||
} else {
|
||||
if (action === "remove") { return UI.Response.element(null); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Icon
|
||||
const imageElem = document.createElement("div");
|
||||
const imagePath = browser.extension.getURL("/img/container-site-d-24.png");
|
||||
imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`;
|
||||
imageElem.classList.add("logo");
|
||||
msgElem.prepend(imageElem);
|
||||
if (component.unique || request.options.all) { this.requests.addRequest(request); }
|
||||
return new UI.Requestor(request, previous)
|
||||
.onFinish(() => this.requests.removeRequest(request))
|
||||
.submit();
|
||||
},
|
||||
|
||||
// Real/dummy wrappers (required for stacking & sliding animations)
|
||||
const dummyElem = document.createElement("div");
|
||||
dummyElem.appendChild(msgElem);
|
||||
const realElem = document.importNode(dummyElem, true); // Clone
|
||||
dummyElem.classList.add("dummy"); // For sizing
|
||||
realElem.classList.add("real"); // For display
|
||||
Requestor: class extends Operator {
|
||||
constructor (request, previous) {
|
||||
super(`${request}`);
|
||||
this.request = request;
|
||||
this.previous = previous;
|
||||
this.modifiedDOM = new Defer();
|
||||
this.request.response = new UI.Response(this.modifiedDOM, this.operation);
|
||||
this.operation.addFinishListener(() => this.modifiedDOM.reject()); // Terminate immediately on interrupt
|
||||
}
|
||||
|
||||
// Outer container
|
||||
const elem = document.createElement("div");
|
||||
elem.appendChild(dummyElem);
|
||||
elem.appendChild(realElem);
|
||||
submit() {
|
||||
this.performRequest();
|
||||
return this.request.response;
|
||||
}
|
||||
|
||||
return elem;
|
||||
async performRequest() {
|
||||
try {
|
||||
await this.stillAnimatingPrevious();
|
||||
const existing = this.request.component.getElement(this.request.options.all);
|
||||
const elem = await (this.request.action === "add" ? this.addElement(existing) : this.removeElement(existing));
|
||||
this.modifiedDOM.resolve(elem);
|
||||
this.operation.resolve(elem);
|
||||
} catch (e) {
|
||||
this.modifiedDOM.reject(e);
|
||||
this.operation.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
async addElement(elem) {
|
||||
const alreadyAdded = elem;
|
||||
if (!alreadyAdded) {
|
||||
elem = await this.createElement();
|
||||
const parentElem = this.request.component.getParentElement() || await this.addParentElement();
|
||||
if (this.request.component.prepend) {
|
||||
parentElem.prepend(elem);
|
||||
} else {
|
||||
parentElem.appendChild(elem);
|
||||
}
|
||||
}
|
||||
|
||||
await this.event("onUpdate", elem);
|
||||
this.modifiedDOM.resolve(elem); // Resolve before start animating
|
||||
if (!alreadyAdded || !this.request.component.isReady(elem)) {
|
||||
await this.event("onAdd", elem);
|
||||
}
|
||||
return elem;
|
||||
}
|
||||
|
||||
async removeElement(elem) {
|
||||
if (elem) {
|
||||
const removeOne = async elem => {
|
||||
await this.event("onRemove", elem);
|
||||
if (this.request.options.hide) {
|
||||
elem.style.display = "none";
|
||||
} else {
|
||||
elem.remove();
|
||||
}
|
||||
};
|
||||
if (this.request.options.all) {
|
||||
await Promise.all(elem.map(e => removeOne(e)));
|
||||
} else {
|
||||
await removeOne(elem);
|
||||
}
|
||||
}
|
||||
return elem;
|
||||
}
|
||||
|
||||
async stillAnimatingPrevious() {
|
||||
if (this.previous) {
|
||||
try { await this.previous.response.animating; } catch (e) { /* Ignore request success/failure */ }
|
||||
this.operation.mustBeRunning();
|
||||
}
|
||||
}
|
||||
|
||||
async addParentElement() {
|
||||
const elem = await UI.request(this.request.component.parent, "add", this.request.options).modifyingDOM;
|
||||
this.operation.mustBeRunning();
|
||||
return elem;
|
||||
}
|
||||
|
||||
async createElement() {
|
||||
const elem = await this.request.component.create(this.operation, this.request.options);
|
||||
this.operation.mustBeRunning();
|
||||
return elem;
|
||||
}
|
||||
|
||||
async event(name, elem) {
|
||||
if (this.request.component[name]) {
|
||||
await this.request.component[name](elem, this.operation, this.request.options);
|
||||
this.operation.mustBeRunning();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Request: class {
|
||||
constructor (component, action, options) {
|
||||
this.component = component;
|
||||
this.action = action;
|
||||
this.options = this.validateOptions(options);
|
||||
this.response = undefined;
|
||||
}
|
||||
|
||||
validateOptions(options) {
|
||||
return options && this.component.options.reduce((result, key) => {
|
||||
if (key in options) {
|
||||
let value = options[key];
|
||||
if (key === "all") { value = this.action === "remove" && (this.component.all || this.component.unique) ? !!value : undefined; }
|
||||
if (value !== undefined) { result[key] = value; }
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
isEqual(other) {
|
||||
return other && other.component === this.component && other.action === this.action && this.isEqualOptions(other.options);
|
||||
}
|
||||
|
||||
isEqualOptions(options) {
|
||||
return options === this.options || (options && this.options &&
|
||||
Object.keys(options).every(k => options[k] === this.options[k]));
|
||||
}
|
||||
|
||||
toString() { return `${this.component}::${this.action}::${JSON.stringify(this.options)}`; }
|
||||
},
|
||||
|
||||
Response: class {
|
||||
constructor (modifyingDOM, animating) {
|
||||
this.modifyingDOM = modifyingDOM;
|
||||
this.animating = animating || modifyingDOM;
|
||||
}
|
||||
|
||||
static element(elem) {
|
||||
return new this(Promise.resolve(elem));
|
||||
}
|
||||
},
|
||||
|
||||
Container: class extends Component {
|
||||
static get parent() { return document.body; }
|
||||
static get unique() { return { id: "container-notifications" }; }
|
||||
static create() {
|
||||
const elem = document.createElement("div");
|
||||
elem.id = this.unique.id;
|
||||
return elem;
|
||||
}
|
||||
},
|
||||
|
||||
Popup: class extends Component {
|
||||
static get parent() { return UI.Container; }
|
||||
static get unique() { return { selector: ".popup" }; }
|
||||
static get prepend() { return true; }
|
||||
static get options() { return ["all", "hide", "x", "y", "width", "height", "tabId"]; }
|
||||
static create(operation, options) {
|
||||
const popup = document.createElement("div");
|
||||
const mask = document.createElement("div");
|
||||
const draggable = document.createElement("div");
|
||||
const iframe = document.createElement("iframe");
|
||||
const popupURL = browser.runtime.getURL("/popup.html");
|
||||
const popupQueryString = options.tabId ? `?tabId=${options.tabId}` : "";
|
||||
popup.classList.add("popup");
|
||||
mask.classList.add("draggable-mask");
|
||||
draggable.classList.add("draggable");
|
||||
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
||||
iframe.src = `${popupURL}${popupQueryString}`;
|
||||
popup.appendChild(draggable);
|
||||
popup.appendChild(iframe);
|
||||
popup.appendChild(mask);
|
||||
Draggable.moveTo(popup, options.x, options.y);
|
||||
new Draggable(popup).start((x, y) => {
|
||||
browser.runtime.sendMessage({ method:"setTabPopupPosition", tabId:options.tabId, x, y });
|
||||
});
|
||||
return popup;
|
||||
}
|
||||
static isReady(elem) { return elem.style.display !== "none"; }
|
||||
static onUpdate(popup, operation, options) {
|
||||
popup.style.display = "";
|
||||
if (!options) { return; }
|
||||
if (options.width) {
|
||||
const width = options.width;
|
||||
const height = options.height || 400;
|
||||
popup.style.width = `${width}px`;
|
||||
popup.querySelector("iframe").style.height = `${height}px`;
|
||||
}
|
||||
if (options.x && options.y) {
|
||||
Draggable.moveTo(popup, options.x, options.y);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Recording: class extends Component {
|
||||
static get parent() { return UI.Container; }
|
||||
static get unique() { return { selector: ".recording" }; }
|
||||
static get prepend() { return true; }
|
||||
static async create(operation) {
|
||||
const elem = await UI.Message.create(operation, {
|
||||
title: "Recording",
|
||||
text: "Sites will be automatically added to this container as you browse in this tab",
|
||||
component: this
|
||||
});
|
||||
elem.classList.add("recording");
|
||||
return elem;
|
||||
}
|
||||
static isReady(elem) { return Animator.isShown(elem); }
|
||||
static onAdd(elem, operation) { return new Animator(operation).toggle(elem, true); }
|
||||
static onRemove(elem, operation) { return new Animator(operation).toggle(elem, false); }
|
||||
},
|
||||
|
||||
Message: class extends Component {
|
||||
static get parent() { return UI.Container; }
|
||||
static get all() { return { selector: ".message" }; }
|
||||
static get options() { return ["all", "title", "text"]; }
|
||||
static async create(operation, options) {
|
||||
// Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available
|
||||
|
||||
// Message
|
||||
const msgElem = document.createElement("div");
|
||||
|
||||
// Icon
|
||||
const imageElem = document.createElement("div");
|
||||
const imagePath = browser.extension.getURL("/img/container-site-d-24.png");
|
||||
imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`;
|
||||
imageElem.classList.add("logo");
|
||||
msgElem.appendChild(imageElem);
|
||||
|
||||
// Title
|
||||
if (options.title) {
|
||||
const titleElem = document.createElement("div");
|
||||
titleElem.classList.add("title");
|
||||
titleElem.innerText = options.title;
|
||||
msgElem.appendChild(titleElem);
|
||||
}
|
||||
|
||||
// Text
|
||||
const textElem = document.createElement("div");
|
||||
textElem.classList.add("text");
|
||||
textElem.innerText = options.text;
|
||||
msgElem.appendChild(textElem);
|
||||
|
||||
// Close
|
||||
const closeElem = document.createElement("div");
|
||||
closeElem.classList.add("close");
|
||||
msgElem.appendChild(closeElem);
|
||||
|
||||
// Real/dummy wrappers (required for stacking & sliding animations)
|
||||
const dummyElem = document.createElement("div");
|
||||
dummyElem.appendChild(msgElem);
|
||||
const realElem = document.importNode(dummyElem, true); // Clone
|
||||
dummyElem.classList.add("dummy"); // For sizing
|
||||
realElem.classList.add("real"); // For display
|
||||
|
||||
// Close listener
|
||||
const finishedAnimating = operation.resolve;
|
||||
realElem.querySelector(".close").addEventListener("click", (e) => {
|
||||
finishedAnimating();
|
||||
e.target.closest(".message").classList.remove("show");
|
||||
});
|
||||
|
||||
// Outer container
|
||||
const elem = document.createElement("div");
|
||||
elem.classList.add("message");
|
||||
elem.appendChild(dummyElem);
|
||||
elem.appendChild(realElem);
|
||||
|
||||
return elem;
|
||||
}
|
||||
static async onAdd(elem, operation) {
|
||||
const animator = new Animator(operation);
|
||||
await animator.toggle(elem, true);
|
||||
await animator.delay(3000);
|
||||
await animator.toggle(elem, false);
|
||||
elem.remove();
|
||||
}
|
||||
static onRemove(elem, operation) { return new Animator(operation).toggle(elem, false); }
|
||||
}
|
||||
static async onAdd(elem) {
|
||||
await Animation.toggle(elem, true);
|
||||
await Animation.delay(3000);
|
||||
await Animation.toggle(elem, false);
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class Messages {
|
||||
static async handle(message) {
|
||||
class Message extends Operation {
|
||||
constructor(message) { super(); this.message = message; }
|
||||
|
||||
async handleMessage() {
|
||||
if (!UI.initialised) {
|
||||
UI.initialised = true;
|
||||
await Promise.all([UI.Popup.toggle(false, {all:true}), UI.Message.toggle(false, {all:true})]);
|
||||
}
|
||||
|
||||
const message = this.message;
|
||||
let animatePopup, animateRecording, animateMessage;
|
||||
if ("popup" in message) { animatePopup = UI.toggle(Popup, message.popup, message.popupOptions); }
|
||||
if ("recording" in message) { animateRecording = UI.toggle(Recording, message.recording); }
|
||||
if ("text" in message) { animateMessage = UI.toggle(Message, true, message); }
|
||||
await Promise.all([animatePopup, animateRecording, animateMessage]);
|
||||
if ("popup" in message) { animatePopup = UI.Popup.toggle(message.popup, message.popupOptions); }
|
||||
if ("recording" in message) { animateRecording = UI.Recording.toggle(message.recording); }
|
||||
if ("text" in message) { animateMessage = UI.Message.toggle(true, message); }
|
||||
return Promise.all([animatePopup, animateRecording, animateMessage]);
|
||||
}
|
||||
toString() { return `Message: ${JSON.stringify(this.message)}`; }
|
||||
|
||||
static async add(message) {
|
||||
return new PromiseBuilder()
|
||||
.onEvent(window, "unload", (resolve, reject) => { reject("window unload", {interrupt: true}); })
|
||||
.onEvent(window, "DOMContentLoaded", (resolve) => { resolve(this.handle(message)); })
|
||||
.promise();
|
||||
await new Operator(new Message(message))
|
||||
.onEvent(window, "unload", operation => operation.reject("window unload"))
|
||||
.onEvent(window, "DOMContentLoaded", operation => operation.resolve(operation.handleMessage()))
|
||||
.operation;
|
||||
}
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener((message) => {
|
||||
if (message.to === "tab") {
|
||||
return Messages.add(message.content);
|
||||
return Message.add(message.content);
|
||||
}
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue