From 8c03a76422be7c208123bf9ad8b70f6c314b240f Mon Sep 17 00:00:00 2001 From: Francis McKenzie Date: Mon, 15 Feb 2021 22:25:09 +0100 Subject: [PATCH] Recording - stay on same page when start recording, make popup iframe draggable, add close button to messages --- src/css/content.css | 132 +++-- src/css/popup.css | 1 + src/img/drag.svg | 7 + src/js/background/messageHandler.js | 3 + src/js/background/recordManager.js | 95 +-- src/js/content-script.js | 883 ++++++++++++++++++---------- 6 files changed, 719 insertions(+), 402 deletions(-) create mode 100644 src/img/drag.svg diff --git a/src/css/content.css b/src/css/content.css index 6433e42..897c998 100644 --- a/src/css/content.css +++ b/src/css/content.css @@ -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; } diff --git a/src/css/popup.css b/src/css/popup.css index fc4d4fa..ea91c9c 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -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%; diff --git a/src/img/drag.svg b/src/img/drag.svg new file mode 100644 index 0000000..7705e40 --- /dev/null +++ b/src/img/drag.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/js/background/messageHandler.js b/src/js/background/messageHandler.js index 13ffec8..292315c 100644 --- a/src/js/background/messageHandler.js +++ b/src/js/background/messageHandler.js @@ -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; diff --git a/src/js/background/recordManager.js b/src/js/background/recordManager.js index 7702ba1..915e4af 100644 --- a/src/js/background/recordManager.js +++ b/src/js/background/recordManager.js @@ -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}); } } }; diff --git a/src/js/content-script.js b/src/js/content-script.js index caa77ff..807aafc 100644 --- a/src/js/content-script.js +++ b/src/js/content-script.js @@ -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); } });