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,
|
||||||
#container-notifications * {
|
#container-notifications * {
|
||||||
all: unset;
|
all: unset;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications {
|
#container-notifications {
|
||||||
display: block;
|
|
||||||
inline-size: 100vw;
|
inline-size: 100vw;
|
||||||
inset-block-start: 0; /* stylelint-disable-line property-no-unknown */
|
inset-block-start: 0; /* stylelint-disable-line property-no-unknown */
|
||||||
inset-inline-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-block-start: 0;
|
||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
margin-inline-start: 0;
|
margin-inline-start: 0;
|
||||||
offset-block-start: 0;
|
|
||||||
offset-inline-start: 0;
|
|
||||||
padding-block-end: 0;
|
padding-block-end: 0;
|
||||||
padding-block-start: 0;
|
padding-block-start: 0;
|
||||||
padding-inline-end: 0;
|
padding-inline-end: 0;
|
||||||
|
@ -22,86 +28,124 @@
|
||||||
z-index: 999999999999;
|
z-index: 999999999999;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications > iframe {
|
#container-notifications > .popup {
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
inset-block-start: 4px; /* stylelint-disable-line property-no-unknown */
|
inset-block-start: 4px; /* stylelint-disable-line property-no-unknown */
|
||||||
inset-inline-end: 4px; /* stylelint-disable-line property-no-unknown */
|
inset-inline-end: 3em; /* stylelint-disable-line property-no-unknown */
|
||||||
offset-block-start: 4px;
|
position: fixed;
|
||||||
offset-inline-end: 4px;
|
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;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications > div.recording {
|
#container-notifications > .popup.drag > .draggable-mask {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#container-notifications > .message.recording {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications > div {
|
#container-notifications > .message {
|
||||||
display: block;
|
|
||||||
max-block-size: 0;
|
max-block-size: 0;
|
||||||
|
opacity: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
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;
|
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 > .message:hover,
|
||||||
#container-notifications > div:focus,
|
#container-notifications > .message:focus,
|
||||||
#container-notifications > div:visited {
|
#container-notifications > .message:visited {
|
||||||
color: #003f07;
|
color: #003f07;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications > div > div.real {
|
#container-notifications > .message > .real {
|
||||||
inset-block-end: 0; /* stylelint-disable-line property-no-unknown */
|
inset-block-end: 0; /* stylelint-disable-line property-no-unknown */
|
||||||
offset-block-end: 0;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications > div > div.dummy {
|
#container-notifications > .message > .dummy {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications > div > div > div {
|
#container-notifications > .message > div > div {
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
background: #efefef;
|
background: #efefef;
|
||||||
|
block-size: 3em;
|
||||||
color: #003f07;
|
color: #003f07;
|
||||||
display: flex;
|
display: flex;
|
||||||
font: 12px sans-serif;
|
font: 1em sans-serif;
|
||||||
inline-size: 100vw;
|
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 {
|
#container-notifications > .message > div > div > .logo {
|
||||||
font-weight: bold;
|
align-self: center;
|
||||||
padding-left: 0.5rem;
|
block-size: 1em;
|
||||||
padding-right: 1rem;
|
flex: 0 0 1em;
|
||||||
}
|
|
||||||
|
|
||||||
#container-notifications > div > div > div > .logo {
|
|
||||||
block-size: 16px;
|
|
||||||
display: inline-block;
|
|
||||||
inline-size: 16px;
|
|
||||||
margin-inline-end: 3px;
|
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;
|
background: #fcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
#container-notifications > div.recording > div > div > .title {
|
#container-notifications > .message.recording > div > div > .title {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
|
@ -802,6 +802,7 @@ span ~ .panel-header-text {
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
flex: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-block-start: 6px;
|
padding-block-start: 6px;
|
||||||
padding-inline-start: 30%;
|
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":
|
case "setOrRemoveRecording":
|
||||||
response = recordManager.setTabId(m.tabId);
|
response = recordManager.setTabId(m.tabId);
|
||||||
break;
|
break;
|
||||||
|
case "setTabPopupPosition":
|
||||||
|
response = recordManager.setTabPopupPosition(m.tabId, m.x, m.y);
|
||||||
|
break;
|
||||||
case "sortTabs":
|
case "sortTabs":
|
||||||
backgroundLogic.sortTabs();
|
backgroundLogic.sortTabs();
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const recordManager = {
|
const recordManager = {
|
||||||
recording: null,
|
recording: undefined,
|
||||||
listening: null,
|
listening: undefined,
|
||||||
|
|
||||||
Recording: class {
|
Recording: class {
|
||||||
constructor(tab) {
|
constructor(tab) {
|
||||||
|
@ -8,15 +8,25 @@ const recordManager = {
|
||||||
this.windowId = tab.windowId;
|
this.windowId = tab.windowId;
|
||||||
this.tabId = tab.id;
|
this.tabId = tab.id;
|
||||||
this.isTabActive = tab.active;
|
this.isTabActive = tab.active;
|
||||||
|
this.isTabReady = tab.url.startsWith("http");
|
||||||
|
this.tabMessage = {};
|
||||||
} else {
|
} else {
|
||||||
this.windowId = browser.windows.WINDOW_ID_NONE;
|
this.windowId = browser.windows.WINDOW_ID_NONE;
|
||||||
this.tabId = browser.tabs.TAB_ID_NONE;
|
this.tabId = browser.tabs.TAB_ID_NONE;
|
||||||
this.isTabActive = false;
|
this.isTabActive = false;
|
||||||
|
this.isTabReady = false;
|
||||||
|
this.tabMessage = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get valid() {
|
get valid() { return this.tabId !== browser.tabs.TAB_ID_NONE; }
|
||||||
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() {
|
async sendTabMessage() {
|
||||||
|
@ -44,9 +54,11 @@ const recordManager = {
|
||||||
|
|
||||||
// Update GUI
|
// Update GUI
|
||||||
const baPopup = messageHandler.browserAction.popup;
|
const baPopup = messageHandler.browserAction.popup;
|
||||||
const tabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId);
|
const showTabPopup = this.isTabActive && (!baPopup || baPopup.windowId !== this.windowId);
|
||||||
this.tabMessage = { recording: true, popup: tabPopup, popupOptions: {tabId: this.tabId} };
|
this.tabMessage = { recording: true, popup: showTabPopup, popupOptions: {tabId: this.tabId, hide:!showTabPopup} };
|
||||||
const showingPage = browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") });
|
const showingPage = this.isTabReady
|
||||||
|
? Promise.resolve()
|
||||||
|
: browser.tabs.update(this.tabId, { url: browser.runtime.getURL("/recording.html") });
|
||||||
const messagingTab = this.sendTabMessage();
|
const messagingTab = this.sendTabMessage();
|
||||||
|
|
||||||
return Promise.all([showingPage, messagingTab]);
|
return Promise.all([showingPage, messagingTab]);
|
||||||
|
@ -62,7 +74,10 @@ const recordManager = {
|
||||||
// Show/hide tabPopup on this tab show/hide
|
// Show/hide tabPopup on this tab show/hide
|
||||||
onTabsActivated(activeInfo) {
|
onTabsActivated(activeInfo) {
|
||||||
if (this.tabId === activeInfo.tabId) {
|
if (this.tabId === activeInfo.tabId) {
|
||||||
|
this.isTabActive = true;
|
||||||
this.sendTabMessage();
|
this.sendTabMessage();
|
||||||
|
} else if (this.windowId === activeInfo.windowId) {
|
||||||
|
this.isTabActive = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,8 +98,8 @@ const recordManager = {
|
||||||
// Show/hide tabPopup on hide/show browserActionPopup
|
// Show/hide tabPopup on hide/show browserActionPopup
|
||||||
onToggleBrowserActionPopup(baPopupVisible, baPopup) {
|
onToggleBrowserActionPopup(baPopupVisible, baPopup) {
|
||||||
if (this.windowId === baPopup.windowId && this.isTabActive) {
|
if (this.windowId === baPopup.windowId && this.isTabActive) {
|
||||||
this.tabMessage.popup = !baPopupVisible;
|
const showTabPopup = !baPopupVisible;
|
||||||
this.tabMessage.popupOptions = { tabId:this.tabId, width:baPopup.width, height:baPopup.height };
|
this.updateTabPopup(showTabPopup, { width:baPopup.width, height:baPopup.height, hide:!showTabPopup });
|
||||||
this.sendTabMessage();
|
this.sendTabMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -161,16 +176,20 @@ const recordManager = {
|
||||||
const newRecording = this.recording = new recordManager.Recording(tab);
|
const newRecording = this.recording = new recordManager.Recording(tab);
|
||||||
|
|
||||||
// Don't wait for stop
|
// Don't wait for stop
|
||||||
oldRecording.stop();
|
if (oldRecording.valid) { oldRecording.stop(); }
|
||||||
try {
|
try {
|
||||||
// But DO wait for start
|
// But DO wait for start
|
||||||
await newRecording.start();
|
if (newRecording.valid) { await newRecording.start(); }
|
||||||
|
|
||||||
// If error while starting, immediately stop, but don't wait
|
// If error while starting, immediately stop, but don't wait
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.setTabId(browser.tabs.TAB_ID_NONE);
|
this.setTabId(browser.tabs.TAB_ID_NONE);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setTabPopupPosition(tabId, x, y) {
|
||||||
|
if (this.isRecordingTabId(tabId)) { this.recording.updateTabPopupOptions({x,y}); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,283 +1,472 @@
|
||||||
function asError(reason) { return reason && (reason instanceof Error) ? reason : new Error(reason); }
|
class Defer extends Promise {
|
||||||
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 {
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._promise = Promise.race([
|
let resolve, reject;
|
||||||
// Interrupter
|
super((res, rej) => { resolve = res; reject = rej; });
|
||||||
new Promise((resolve, reject) => { this.interrupt = reject; }),
|
this.resolve = resolve; this.reject = 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(); }); } })
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _tryHandler(handler, name, ...args) {
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mustBeRunning(running, optional) {
|
||||||
|
const wantFinished = running === false;
|
||||||
|
const ok = wantFinished === !!this.finished;
|
||||||
|
if (!ok && !optional) { throw new Error(`${this} ${wantFinished ? "unfinished" : "cancelled"}`); }
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
addFinishListener(listener) {
|
||||||
|
if (this.finished) {
|
||||||
|
listener({target: this});
|
||||||
|
} else {
|
||||||
|
if (!(this.completions || (this.completions = [])).find(c => c === listener)) { this.completions.push(listener); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFinishListener(listener) {
|
||||||
|
if (this.completions) { this.completions = this.completions.filter(c => c !== listener); }
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type, listener) {
|
||||||
|
if (/^finish$/i.test(type)) { this.addFinishListener(listener); }
|
||||||
|
else { throw new Error(`${this} unsupported event '${type}'`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
await handler(...args);
|
const result = await handler(this.operation, ...args);
|
||||||
|
this.operation.mustBeRunning(!opts.finished, opts.optional);
|
||||||
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed: ${name}: ${e.message}`);
|
if (!this.operation.finished || opts.finished) {
|
||||||
this.reject(e);
|
this.operation.errored(name, e);
|
||||||
|
this.operation.reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promise(handler) {
|
delay(millis) {
|
||||||
if (handler) { this._tryHandler(handler, "promise", this); }
|
return this.exec(() => { return new Promise(resolve => setTimeout(resolve, millis)); }, {name: "delay"});
|
||||||
return this._promise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onCompletion(completion) {
|
onStart(handler) { this.exec(handler, {name: "start"}); return this; }
|
||||||
if (!this.completions) { this.completions = []; }
|
onFinish(handler) { this.operation.addFinishListener(e => handler(e.target)); return this; }
|
||||||
this.completions.push(completion);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
onTimeout(delay, timeoutHandler) {
|
onTimeout(delay, handler) {
|
||||||
const timer = () => { this._tryHandler(timeoutHandler, "timeout", this.resolve, this.reject); };
|
const timer = () => this.exec(handler, {name: "timeout", optional: true});
|
||||||
let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay);
|
let timeoutId = setTimeout(() => { timeoutId = null; timer(); }, delay);
|
||||||
this.onCompletion(() => { clearTimeout(timeoutId); });
|
this.onFinish(() => clearTimeout(timeoutId));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
onFutureEvent(target, eventName, eventHandler) {
|
onEvent(target, type, handler) {
|
||||||
const listener = (event) => { this._tryHandler(eventHandler, eventName, this.resolve, this.reject, event); };
|
if (target) {
|
||||||
target.addEventListener(eventName, listener, {once: true});
|
const options = {name: type, optional: true, finished: this.isFinishEvent(target, type)};
|
||||||
this.onCompletion(() => { target.removeEventListener(eventName, listener); });
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
onEvent(target, eventName, eventHandler) {
|
isPriorEvent(target, type) {
|
||||||
if (target === window) {
|
if (this.isWindowLoadEvent(target, type) || this.isWindowInteractiveEvent(target, type)) {
|
||||||
eventName = eventName.toLowerCase();
|
|
||||||
if (eventName === "domcontentloaded" || eventName === "load") {
|
|
||||||
switch (document.readyState) {
|
switch (document.readyState) {
|
||||||
case "loading": break;
|
case "loading": return false;
|
||||||
case "interactive":
|
case "complete": return true;
|
||||||
if (eventName === "load") { break; }
|
case "interactive": return this.isWindowInteractiveEvent(target, type);
|
||||||
// Fall through
|
|
||||||
case "complete":
|
|
||||||
// Event already fired - run immediately
|
|
||||||
this._tryHandler(eventHandler, eventName, this.resolve, this.reject);
|
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
} else if (this.isFinishEvent(target, type)) {
|
||||||
|
return target.finished;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
this.onFutureEvent(target, eventName, eventHandler);
|
|
||||||
return this;
|
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 Animation {
|
class Animator extends Operator {
|
||||||
static delay(delay = 350) {
|
static isShown(elem) { return elem && elem.classList.contains("show"); }
|
||||||
return new Promise((resolve) => { setTimeout(resolve, delay); });
|
|
||||||
}
|
|
||||||
|
|
||||||
static async toggle(element, show, timeoutDelay = 3000) {
|
toggle(elem, show, timeoutDelay = 3000) {
|
||||||
const shown = element.classList.contains("show");
|
if (Animator.isShown(elem) === !!show) { return; }
|
||||||
if (shown === !!show) { return; }
|
|
||||||
|
|
||||||
const animate = () => {
|
const animate = (operation) => {
|
||||||
if (show) {
|
if (!operation.finished) {
|
||||||
if (!element.classList.contains("show")) {
|
elem.classList[(show ? "add" : "remove")]("show");
|
||||||
element.classList.add("show");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
element.classList.remove("show");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return new PromiseBuilder()
|
return new Operator(`Animate${show ? "Show" : "Hide"}`)
|
||||||
.onTimeout(timeoutDelay, resolves())
|
// Ensure animation always reaches final state on timeout
|
||||||
.onEvent(element, "transitionend", resolves())
|
.onTimeout(timeoutDelay, operation => { animate(operation); operation.resolve(); })
|
||||||
.promise((promise) => {
|
.onEvent(elem, "transitionend", operation => operation.resolve())
|
||||||
|
.onEvent(this.operation, "finish", operation => operation.reject("Interrupted"))
|
||||||
// Delay until element has been rendered
|
.onStart(operation => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => { // Delay until element has been rendered
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
animate();
|
animate(operation);
|
||||||
}, 10);
|
}, 1);
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure animation always reaches final state
|
|
||||||
promise.onCompletion(animate);
|
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.operation;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UIRequest {
|
class Draggable {
|
||||||
constructor (component, action, options, response) {
|
constructor(elem) {
|
||||||
this.component = component;
|
this.elem = elem;
|
||||||
this.action = action;
|
this.x = this.y = this.left = this.top = this.insetTop = this.insetLeft = this.insetBottom = this.insetRight = this.finishHandler = undefined;
|
||||||
this.options = options;
|
this.onMouseDown = this.onMouseDown.bind(this);
|
||||||
this.response = response || new UIResponse();
|
this.onDrag = this.onDrag.bind(this);
|
||||||
|
this.onMouseUp = this.onMouseUp.bind(this);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class UIResponse {
|
start(finishHandler) {
|
||||||
constructor (value) {
|
this.finishHandler = finishHandler;
|
||||||
let promise;
|
this.elem.querySelector(".draggable").addEventListener("mousedown", this.onMouseDown);
|
||||||
if (value instanceof Promise) { promise = value; }
|
|
||||||
if (value !== undefined) { promise = Promise.resolve(value); }
|
|
||||||
this.modifyingDOM = this.animating = promise;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class UIRequestManager {
|
stop() {
|
||||||
static request(component, action, options) {
|
this.onMouseUp();
|
||||||
// Try for quick return
|
this.elem.querySelector(".draggable").removeEventListener("mousedown", this.onMouseDown);
|
||||||
if (component.unique) {
|
this.finishHandler = undefined;
|
||||||
const previous = this.requests && this.requests[component.name];
|
}
|
||||||
|
|
||||||
// Quick return if request already enqueued
|
onMouseDown(e) {
|
||||||
if (previous && previous.action === action) {
|
e.preventDefault();
|
||||||
// Previous request is also an add, but we've got an extra update to do as well
|
const rect = e.target.getBoundingClientRect();
|
||||||
if (action === "add" && component.onUpdate && options) {
|
const minInsetX = rect.width - 30;
|
||||||
return new UIResponse(previous.response.animating.then((elem) => {
|
const minInsetY = rect.height - 30;
|
||||||
const updating = component.onUpdate(elem, options);
|
this.x = e.clientX;
|
||||||
return updating ? updating.then(elem) : elem;
|
this.y = e.clientY;
|
||||||
}));
|
this.insetTop = e.clientY - rect.top - minInsetY;
|
||||||
// No update needed, so can just reuse previous request
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static moveTo(elem, x, y) {
|
||||||
|
let left, top;
|
||||||
|
if (x === undefined || y === undefined) {
|
||||||
|
left = top = "";
|
||||||
} else {
|
} else {
|
||||||
return previous.response;
|
left = `min(calc(100vw - 30px), ${x}px)`;
|
||||||
|
top = `min(calc(100vh - 30px), ${y}px)`;
|
||||||
}
|
}
|
||||||
|
elem.style.left = left;
|
||||||
|
elem.style.top = top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Component {
|
||||||
|
static async toggle(show, options) {
|
||||||
|
const action = show ? "add" : "remove";
|
||||||
|
const response = UI.request(this, action, options);
|
||||||
|
return response.animating;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick return if no request pending and element already added/removed
|
static getElement(isAll) {
|
||||||
if (!previous) {
|
if (isAll) { return this.getElements(); }
|
||||||
const element = this._get(component);
|
const unique = this.unique;
|
||||||
if (element) {
|
if (unique) {
|
||||||
if (action === "add") { return new UIResponse(element); }
|
|
||||||
} else {
|
|
||||||
if (action === "remove") { return new UIResponse(null); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
if (unique.id) {
|
||||||
return document.getElementById(unique.id);
|
return document.getElementById(unique.id);
|
||||||
} else {
|
} else {
|
||||||
if ("querySelector" in component.parent) {
|
const parentElem = this.getParentElement();
|
||||||
return component.parent.querySelector(unique.selector);
|
return parentElem && parentElem.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) {
|
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 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
request: function(component, action, options = {}) {
|
||||||
|
const request = new UI.Request(component, action, options);
|
||||||
|
const previous = this.requests.getRequest(component);
|
||||||
|
|
||||||
|
// 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component.unique || request.options.all) { this.requests.addRequest(request); }
|
||||||
|
return new UI.Requestor(request, previous)
|
||||||
|
.onFinish(() => this.requests.removeRequest(request))
|
||||||
|
.submit();
|
||||||
|
},
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
this.performRequest();
|
||||||
|
return this.request.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async performRequest() {
|
||||||
try {
|
try {
|
||||||
if (previous) {
|
await this.stillAnimatingPrevious();
|
||||||
try { await previous.response.animating; } catch (e) { /* Ignore previous success/failure */ }
|
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);
|
||||||
const component = request.component;
|
this.operation.resolve(elem);
|
||||||
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) {
|
} catch (e) {
|
||||||
modifiedDOM.reject(e);
|
this.modifiedDOM.reject(e);
|
||||||
animated.reject(e);
|
this.operation.reject(e);
|
||||||
} finally {
|
|
||||||
if (this.requests && this.requests[request.component.name] === request) {
|
|
||||||
this.requests[request.component.name] = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UI {
|
async addElement(elem) {
|
||||||
static async toggle(component, show, options) {
|
const alreadyAdded = elem;
|
||||||
const action = show ? "add" : "remove";
|
if (!alreadyAdded) {
|
||||||
const response = UIRequestManager.request(component, action, options);
|
elem = await this.createElement();
|
||||||
return response.animating;
|
const parentElem = this.request.component.getParentElement() || await this.addParentElement();
|
||||||
|
if (this.request.component.prepend) {
|
||||||
|
parentElem.prepend(elem);
|
||||||
|
} else {
|
||||||
|
parentElem.appendChild(elem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class Container {
|
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 parent() { return document.body; }
|
||||||
static get unique() { return { id: "container-notifications" }; }
|
static get unique() { return { id: "container-notifications" }; }
|
||||||
static create() {
|
static create() {
|
||||||
|
@ -285,69 +474,103 @@ class Container {
|
||||||
elem.id = this.unique.id;
|
elem.id = this.unique.id;
|
||||||
return elem;
|
return elem;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
class Popup {
|
Popup: class extends Component {
|
||||||
static get parent() { return Container; }
|
static get parent() { return UI.Container; }
|
||||||
static get unique() { return { selector: "iframe" }; }
|
static get unique() { return { selector: ".popup" }; }
|
||||||
static get prepend() { return true; }
|
static get prepend() { return true; }
|
||||||
static create(options) {
|
static get options() { return ["all", "hide", "x", "y", "width", "height", "tabId"]; }
|
||||||
const elem = document.createElement("iframe");
|
static create(operation, options) {
|
||||||
elem.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
const popup = document.createElement("div");
|
||||||
elem.src = browser.runtime.getURL("/popup.html") + "?tabId=" + options.tabId;
|
const mask = document.createElement("div");
|
||||||
return elem;
|
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 onUpdate(elem, options) {
|
static isReady(elem) { return elem.style.display !== "none"; }
|
||||||
|
static onUpdate(popup, operation, options) {
|
||||||
|
popup.style.display = "";
|
||||||
if (!options) { return; }
|
if (!options) { return; }
|
||||||
if (options.width) {
|
if (options.width) {
|
||||||
const width = options.width;
|
const width = options.width;
|
||||||
const height = options.height || 400;
|
const height = options.height || 400;
|
||||||
elem.style.width = `${width}px`;
|
popup.style.width = `${width}px`;
|
||||||
elem.style.height = `${height}px`;
|
popup.querySelector("iframe").style.height = `${height}px`;
|
||||||
|
}
|
||||||
|
if (options.x && options.y) {
|
||||||
|
Draggable.moveTo(popup, options.x, options.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
class Recording {
|
Recording: class extends Component {
|
||||||
static get parent() { return Container; }
|
static get parent() { return UI.Container; }
|
||||||
static get unique() { return { selector: ".recording" }; }
|
static get unique() { return { selector: ".recording" }; }
|
||||||
static get prepend() { return true; }
|
static get prepend() { return true; }
|
||||||
static async create() {
|
static async create(operation) {
|
||||||
const elem = await Message.create({
|
const elem = await UI.Message.create(operation, {
|
||||||
title: "Recording",
|
title: "Recording",
|
||||||
text: "Sites will be automatically added to this container as you browse in this tab"
|
text: "Sites will be automatically added to this container as you browse in this tab",
|
||||||
|
component: this
|
||||||
});
|
});
|
||||||
elem.classList.add("recording");
|
elem.classList.add("recording");
|
||||||
return elem;
|
return elem;
|
||||||
}
|
}
|
||||||
static onAdd(elem) { return Animation.toggle(elem, true); }
|
static isReady(elem) { return Animator.isShown(elem); }
|
||||||
static onRemove(elem) { return Animation.toggle(elem, false); }
|
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
|
||||||
|
|
||||||
class Message {
|
|
||||||
static get parent() { return Container; }
|
|
||||||
static async create(options) {
|
|
||||||
// Message
|
// Message
|
||||||
const msgElem = document.createElement("div");
|
const msgElem = document.createElement("div");
|
||||||
|
|
||||||
// Text
|
|
||||||
// Ideally we would use https://bugzilla.mozilla.org/show_bug.cgi?id=1340930 when this is available
|
|
||||||
msgElem.innerText = options.text;
|
|
||||||
|
|
||||||
// Title
|
|
||||||
if (options.title) {
|
|
||||||
const titleElem = document.createElement("span");
|
|
||||||
titleElem.classList.add("title");
|
|
||||||
titleElem.innerText = options.title;
|
|
||||||
msgElem.prepend(titleElem);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
const imageElem = document.createElement("div");
|
const imageElem = document.createElement("div");
|
||||||
const imagePath = browser.extension.getURL("/img/container-site-d-24.png");
|
const imagePath = browser.extension.getURL("/img/container-site-d-24.png");
|
||||||
imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`;
|
imageElem.style.background = `url("${imagePath}") no-repeat center center / cover`;
|
||||||
imageElem.classList.add("logo");
|
imageElem.classList.add("logo");
|
||||||
msgElem.prepend(imageElem);
|
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)
|
// Real/dummy wrappers (required for stacking & sliding animations)
|
||||||
const dummyElem = document.createElement("div");
|
const dummyElem = document.createElement("div");
|
||||||
|
@ -356,40 +579,60 @@ class Message {
|
||||||
dummyElem.classList.add("dummy"); // For sizing
|
dummyElem.classList.add("dummy"); // For sizing
|
||||||
realElem.classList.add("real"); // For display
|
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
|
// Outer container
|
||||||
const elem = document.createElement("div");
|
const elem = document.createElement("div");
|
||||||
|
elem.classList.add("message");
|
||||||
elem.appendChild(dummyElem);
|
elem.appendChild(dummyElem);
|
||||||
elem.appendChild(realElem);
|
elem.appendChild(realElem);
|
||||||
|
|
||||||
return elem;
|
return elem;
|
||||||
}
|
}
|
||||||
static async onAdd(elem) {
|
static async onAdd(elem, operation) {
|
||||||
await Animation.toggle(elem, true);
|
const animator = new Animator(operation);
|
||||||
await Animation.delay(3000);
|
await animator.toggle(elem, true);
|
||||||
await Animation.toggle(elem, false);
|
await animator.delay(3000);
|
||||||
|
await animator.toggle(elem, false);
|
||||||
elem.remove();
|
elem.remove();
|
||||||
}
|
}
|
||||||
}
|
static onRemove(elem, operation) { return new Animator(operation).toggle(elem, false); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
class Messages {
|
class Message extends Operation {
|
||||||
static async handle(message) {
|
constructor(message) { super(); this.message = message; }
|
||||||
let animatePopup, animateRecording, animateMessage;
|
|
||||||
if ("popup" in message) { animatePopup = UI.toggle(Popup, message.popup, message.popupOptions); }
|
async handleMessage() {
|
||||||
if ("recording" in message) { animateRecording = UI.toggle(Recording, message.recording); }
|
if (!UI.initialised) {
|
||||||
if ("text" in message) { animateMessage = UI.toggle(Message, true, message); }
|
UI.initialised = true;
|
||||||
await Promise.all([animatePopup, animateRecording, animateMessage]);
|
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.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) {
|
static async add(message) {
|
||||||
return new PromiseBuilder()
|
await new Operator(new Message(message))
|
||||||
.onEvent(window, "unload", (resolve, reject) => { reject("window unload", {interrupt: true}); })
|
.onEvent(window, "unload", operation => operation.reject("window unload"))
|
||||||
.onEvent(window, "DOMContentLoaded", (resolve) => { resolve(this.handle(message)); })
|
.onEvent(window, "DOMContentLoaded", operation => operation.resolve(operation.handleMessage()))
|
||||||
.promise();
|
.operation;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((message) => {
|
browser.runtime.onMessage.addListener((message) => {
|
||||||
if (message.to === "tab") {
|
if (message.to === "tab") {
|
||||||
return Messages.add(message.content);
|
return Message.add(message.content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue