From 0e4280a6632ce58a1b7e505929c69bb99d856ef1 Mon Sep 17 00:00:00 2001 From: Sam Dixon Date: Wed, 23 Feb 2022 12:10:21 -0800 Subject: [PATCH] Merge refactor branch with settings page (#50) * move refactor changes from private repo * Update src/editors.ts Co-authored-by: Tommy MacWilliam * inject script tag to documentElement, use mutationObserver for all DOM changes, not just the ReactDOM, shorten timeout for monaco listeners * remove stray print, remove alert for use on copy clickables and replace with div overlay * style fixes, simplify scroll command response logic * remove debug print statements * fix bug in test page and ace editor language determination * fix bug in codemirror language determination * Add popup and "Always Show Clickables" setting (#48) * add popup page and match styling to client * implement message passing between popup and background, store settings * first functional prototype * add missing await in message passing from content to injected script, don't return immediately if always show clickables is true * add show inputs and links, change UI phrasing * remove debug prints Co-authored-by: Tommy MacWilliam --- .gitignore | 1 + manifest.json | 6 ++- src/content-script.ts | 39 +++++++++----- src/extension.ts | 4 +- src/injected-command-handler.ts | 92 +++++++++++++++------------------ src/ipc.ts | 7 ++- src/popup.css | 92 +++++++++++++++++++++++++++++++++ src/popup.html | 18 +++++++ src/popup.ts | 36 +++++++++++++ webpack.config.js | 1 + 10 files changed, 227 insertions(+), 69 deletions(-) create mode 100644 src/popup.css create mode 100644 src/popup.html create mode 100644 src/popup.ts diff --git a/.gitignore b/.gitignore index a03259c..a971eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build *.log .idea package-lock.json +.vscode diff --git a/manifest.json b/manifest.json index 4f095e6..eaf3227 100644 --- a/manifest.json +++ b/manifest.json @@ -3,8 +3,12 @@ "name": "Serenade", "version": "2.0.0", "description": "Code with voice. Learn more at https://serenade.ai.", - "permissions": ["tabs", "scripting"], + "permissions": ["tabs", "scripting", "storage"], "host_permissions": ["ws://localhost:17373/", "*://*/*"], + "action": { + "default_title": "Serenade for Chrome", + "default_popup": "src/popup.html" + }, "background": { "service_worker": "build/extension.js" }, diff --git a/src/content-script.ts b/src/content-script.ts index 4883d3d..21c1068 100644 --- a/src/content-script.ts +++ b/src/content-script.ts @@ -15,23 +15,34 @@ document.addEventListener(`serenade-injected-script-command-response`, (e: any) } }); +async function sendMessageToInjectedScript(data: any) { + const id = Math.random(); + const response = await new Promise((resolve) => { + resolvers[id] = resolve; + document.dispatchEvent( + new CustomEvent(`serenade-injected-script-command-request`, { + detail: { + id, + data: data, + }, + }) + ); + }); + return response +} + chrome.runtime.onMessage.addListener(async (request, _sender, sendResponse) => { if (request.type == "injected-script-command-request") { - const id = Math.random(); - const response = await new Promise((resolve) => { - resolvers[id] = resolve; - document.dispatchEvent( - new CustomEvent(`serenade-injected-script-command-request`, { - detail: { - id, - data: request.data, - }, - }) - ); - }); - + const response = await sendMessageToInjectedScript(request.data) sendResponse(response); } - return true; }); + +document.addEventListener("DOMContentLoaded", async () => { + const settings = await chrome.storage.sync.get(["alwaysShowClickables"]); + sendMessageToInjectedScript({ + type: "updateSettings", + ...settings, + }) +}); diff --git a/src/extension.ts b/src/extension.ts index dc8edcb..ca063b0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,8 +6,8 @@ const ipc = new IPC( navigator.userAgent.indexOf("Brave") != -1 ? "brave" : navigator.userAgent.indexOf("Edg") != -1 - ? "edge" - : "chrome", + ? "edge" + : "chrome", extensionCommandHandler ); diff --git a/src/injected-command-handler.ts b/src/injected-command-handler.ts index 26c69ae..87a2252 100644 --- a/src/injected-command-handler.ts +++ b/src/injected-command-handler.ts @@ -1,7 +1,10 @@ import * as editors from "./editors"; export default class InjectedCommandHandler { - overlays: { node: Node, type: string }[] = []; + private overlays: { node: Node; type: string }[] = []; + private settings = { + alwaysShowClickables: false, + }; private clickNode(node: Node) { const element = node as HTMLElement; @@ -31,47 +34,13 @@ export default class InjectedCommandHandler { private inViewport(element: HTMLElement) { const bounding = element.getBoundingClientRect(); - // If all four of the corners are covered by another element that's not a parent, no need to show - if ( - !element.contains( - document.elementFromPoint(bounding.left + 1, bounding.top + 1) - ) && - !element.contains( - document.elementFromPoint(bounding.right - 1, bounding.top + 1) - ) && - !element.contains( - document.elementFromPoint(bounding.left + 1, bounding.bottom - 1) - ) && - !element.contains( - document.elementFromPoint(bounding.right - 1, bounding.bottom - 1) - ) && - !document - .elementFromPoint(bounding.left + 1, bounding.top + 1) - ?.contains(element) && - !document - .elementFromPoint(bounding.right - 1, bounding.top + 1) - ?.contains(element) && - !document - .elementFromPoint(bounding.left + 1, bounding.bottom - 1) - ?.contains(element) && - !document - .elementFromPoint(bounding.right - 1, bounding.bottom - 1) - ?.contains(element) - ) { - return false; - } - // Check that this is in the viewport and has some dimensions return ( ((bounding.top >= 0 && bounding.top <= window.innerHeight) || (bounding.bottom >= 0 && bounding.bottom <= window.innerHeight)) && ((bounding.left >= 0 && bounding.left <= window.innerWidth) || (bounding.right >= 0 && bounding.right <= window.innerWidth)) && - !!( - element.offsetWidth || - element.offsetHeight || - element.getClientRects().length - ) + !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length) ); } @@ -124,15 +93,22 @@ export default class InjectedCommandHandler { } private elementIsScrollable(element: HTMLElement, direction: string): boolean { - if (direction === "up" || direction === "down" || direction === "bottom" || direction === "top") { + if ( + direction === "up" || + direction === "down" || + direction === "bottom" || + direction === "top" + ) { const overflowStyle = window.getComputedStyle(element).overflowY; return ( - element.scrollHeight > element.clientHeight && (overflowStyle === "scroll" || overflowStyle === "auto") + element.scrollHeight > element.clientHeight && + (overflowStyle === "scroll" || overflowStyle === "auto") ); } else if (direction === "left" || direction === "right") { const overflowStyle = window.getComputedStyle(element).overflowX; return ( - element.scrollWidth > element.clientWidth && (overflowStyle === "scroll" || overflowStyle === "auto") + element.scrollWidth > element.clientWidth && + (overflowStyle === "scroll" || overflowStyle === "auto") ); } return false; @@ -191,13 +167,15 @@ export default class InjectedCommandHandler { private async scrollInDirection(direction: string) { let hoveredElements = document.querySelectorAll("*:hover"); - let lastHoveredElement = hoveredElements.length ? hoveredElements[hoveredElements.length - 1] as HTMLElement : null; + let lastHoveredElement = hoveredElements.length + ? (hoveredElements[hoveredElements.length - 1] as HTMLElement) + : null; let scrolled = false; while (lastHoveredElement && !scrolled) { if (this.elementIsScrollable(lastHoveredElement, direction)) { let options = this.scrollOptions(lastHoveredElement, direction); if (direction === "top" || direction === "bottom") { - lastHoveredElement.scrollTo(options) + lastHoveredElement.scrollTo(options); } else { lastHoveredElement.scrollBy(options); } @@ -209,7 +187,7 @@ export default class InjectedCommandHandler { if (!scrolled) { let options = this.scrollOptions(window, direction); if (direction === "top" || direction === "bottom") { - window.scrollTo(options) + window.scrollTo(options); } else { window.scrollBy(options); } @@ -238,7 +216,7 @@ export default class InjectedCommandHandler { } // Use first match if (!target) { - target = matches[0] + target = matches[0]; } const style = window.getComputedStyle(target as Element); @@ -271,7 +249,9 @@ export default class InjectedCommandHandler { overlay.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif'; document.body.appendChild(overlay); - setTimeout(() => {document.body.removeChild(overlay)}, 1000); + setTimeout(() => { + document.body.removeChild(overlay); + }, 1000); } private showOverlays(nodes: Node[], overlayType: string) { @@ -288,7 +268,7 @@ export default class InjectedCommandHandler { overlay.style.position = "absolute"; overlay.style.zIndex = "999"; overlay.style.top = elementRect.top - bodyRect.top + "px"; - overlay.style.left = elementRect.left - bodyRect.left + "px"; + overlay.style.left = elementRect.left - bodyRect.left - overlay.clientWidth + "px"; overlay.style.padding = "3px"; overlay.style.textAlign = "center"; overlay.style.color = "#e6ecf2"; @@ -338,8 +318,7 @@ export default class InjectedCommandHandler { const pathNumber = parseInt(data.path, 10); if (!isNaN(pathNumber)) { // if path is a number, check that it is available - response.clickable = - pathNumber - 1 >= 0 && pathNumber - 1 < this.overlays.length; + response.clickable = pathNumber - 1 >= 0 && pathNumber - 1 < this.overlays.length; } else { // otherwise, search for matching nodes let matches = this.nodesMatchingPath(data.path); @@ -404,6 +383,9 @@ export default class InjectedCommandHandler { } async COMMAND_TYPE_GET_EDITOR_STATE(_data: any): Promise { + if (this.settings.alwaysShowClickables) { + this.COMMAND_TYPE_SHOW({ text: "all" }); + } const editor = await editors.active(); if (!editor) { return; @@ -438,10 +420,11 @@ export default class InjectedCommandHandler { if (data.text == "links") { selector = 'a, button, summary, [role="link"], [role="button"]'; } else if (data.text == "inputs") { - selector = - 'input, textarea, [role="checkbox"], [role="radio"], .CodeMirror'; + selector = 'input, textarea, [role="checkbox"], [role="radio"]'; } else if (data.text == "code") { selector = "pre, code"; + } else if (data.text == "all") { + selector = 'a, button, summary, [role="link"], [role="button"], input, textarea, [role="checkbox"], [role="radio"]'; } else { return; } @@ -456,7 +439,7 @@ export default class InjectedCommandHandler { async COMMAND_TYPE_USE(data: any): Promise { let overlay = this.overlays[data.index - 1]; - if (overlay.type === "links" || overlay.type === "inputs") { + if (overlay.type === "links" || overlay.type === "inputs" || overlay.type === "all") { this.clickNode(overlay.node); } else if (overlay.type === "code") { await this.copyCode(overlay.node); @@ -464,4 +447,13 @@ export default class InjectedCommandHandler { } this.clearOverlays(); } + + async updateSettings(data: any): Promise { + this.settings = { + alwaysShowClickables: data.alwaysShowClickables, + }; + if (!this.settings.alwaysShowClickables) { + this.clearOverlays(); + } + } } diff --git a/src/ipc.ts b/src/ipc.ts index 9af651b..e65172f 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -70,17 +70,20 @@ export default class IPC { } catch (e) { } } - private async tab(): Promise { + private async tab(): Promise { const [result] = await chrome.tabs.query({ active: true, currentWindow: true, }); - return result.id!; + return result?.id; } private async sendMessageToContentScript(message: any): Promise { let tabId = await this.tab(); + if (!tabId) { + return; + } return new Promise((resolve) => { chrome.tabs.sendMessage(tabId, message, (response) => { resolve(response); diff --git a/src/popup.css b/src/popup.css new file mode 100644 index 0000000..3183438 --- /dev/null +++ b/src/popup.css @@ -0,0 +1,92 @@ +html { + min-width: 400px; +} + +body { + font-family: aktiv-grotesk, sans-serif; + font-size: medium; + color: #475569; + text-align: center; + padding-bottom: 1em; +} + +h1 { + font-size: medium; +} + +a { + color: #fff; + background-color: rgb(59 130 246); + display: inline-block; + text-decoration: none; + padding: 0.5em 1.5em; + margin-bottom: 1em; + font-weight: normal; + text-align: center; + vertical-align: middle; + border-radius: 1em; + box-shadow: 0 4px 6px -1px #3b82f688; +} + +a:hover { + background-color: rgb(37 99 235); + box-shadow: 0 4px 6px -1px #3b82f688; +} + +/* The switch - the box around the slider */ +.switch { + --width: 40px; + --height: calc(var(--width) / 1.8); + position: relative; + display: inline-block; + width: var(--width); + height: var(--height); + vertical-align: middle; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgb(29 78 216); +} + +.slider:before { + position: absolute; + content: ""; + height: calc(0.8 * var(--height)); + width: calc(0.8 * var(--height)); + top: calc(0.1 * var(--height)); + left: calc(0.1 * var(--height)); + border-radius: calc(var(--height) / 2); + background-color: #fff; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3); +} + +input:checked + .slider { + background-color: rgb(59 130 246); +} + +input:focus + .slider { + box-shadow: 0 0 1px rgb(59 130 246); +} + +input:checked + .slider:before { + transform: translateX(calc(var(--width) - var(--height))); +} + +/* Rounded sliders */ +.slider.round { + border-radius: var(--height); +} diff --git a/src/popup.html b/src/popup.html new file mode 100644 index 0000000..047749a --- /dev/null +++ b/src/popup.html @@ -0,0 +1,18 @@ + + + + Serenade for Chrome + + + +

Serenade for Chrome

+ Read the docs +
+ + Always show link and input overlays + + + diff --git a/src/popup.ts b/src/popup.ts new file mode 100644 index 0000000..d959029 --- /dev/null +++ b/src/popup.ts @@ -0,0 +1,36 @@ +const showClickablesCheckbox = document.getElementById("showClickables") as HTMLInputElement; + +async function restoreSettings() { + chrome.storage.sync.get( + { + alwaysShowClickables: false, + }, + function (settings) { + showClickablesCheckbox.checked = settings.alwaysShowClickables; + } + ); +} + +function saveSettingsAndUpdate() { + const settings = { + alwaysShowClickables: showClickablesCheckbox.checked, + }; + chrome.storage.sync.set({ + alwaysShowClickables: settings.alwaysShowClickables + }); + chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { + if (!tabs[0].id) { + return; + } + chrome.tabs.sendMessage(tabs[0].id, { + type: "injected-script-command-request", + data: { + type: "updateSettings", + ...settings, + }, + }); + }); +} + +document.addEventListener("DOMContentLoaded", restoreSettings); +showClickablesCheckbox?.addEventListener("change", saveSettingsAndUpdate); diff --git a/webpack.config.js b/webpack.config.js index 853e4a1..1754335 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,6 +10,7 @@ module.exports = [ extension: "./src/extension.ts", "content-script": "./src/content-script.ts", injected: "./src/injected.ts", + popup: "./src/popup.ts", }, output: { path: path.resolve(__dirname, "build"),