Recording - stay on same page when start recording, make popup iframe draggable, add close button to messages

This commit is contained in:
Francis McKenzie 2021-02-15 22:25:09 +01:00
parent 7a43e06767
commit 8c03a76422
6 changed files with 719 additions and 402 deletions

View file

@ -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;
}

View file

@ -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
View 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

View file

@ -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;

View file

@ -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}); }
}
};

View file

@ -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);
}
});