diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index f9284ee..4bb5176 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -32,7 +32,7 @@ "description": "Title used on the example scripts page" }, "getting_started_subtitle": { - "message": "To add a script, drag it to your Bookmarks Bar.", + "message": "To add a script, drag it to your Bookmarks Bar or press the %r button", "description": "Subtitle used to explain how to add bookmarklets to the bookmarkbar" }, "edit_script_title": { diff --git a/src/background/index.js b/src/background/index.js index 6142148..9ce5568 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -1,6 +1,18 @@ +import { CREATE_BOOKMARK } from '../content/show_button_inside_element'; import createIconRenderer from './icon_renderer'; const renderIcon = createIconRenderer(); setInterval(renderIcon, 1000); renderIcon(); + +chrome.runtime.onMessage.addListener( + (message = { action: null, payload: null }) => { + if (message.action === CREATE_BOOKMARK) { + chrome.bookmarks.create({ + title: message.payload.title, + url: message.payload.url + }); + } + } +); diff --git a/src/content/add_icon.svg b/src/content/add_icon.svg new file mode 100644 index 0000000..b493266 --- /dev/null +++ b/src/content/add_icon.svg @@ -0,0 +1,3 @@ + diff --git a/src/content/check_icon.svg b/src/content/check_icon.svg new file mode 100644 index 0000000..fddb0af --- /dev/null +++ b/src/content/check_icon.svg @@ -0,0 +1 @@ + diff --git a/src/content/create_shadow_dom_inside.js b/src/content/create_shadow_dom_inside.js new file mode 100644 index 0000000..8d264ee --- /dev/null +++ b/src/content/create_shadow_dom_inside.js @@ -0,0 +1,23 @@ +export function createShadowDomInside(target) { + const shadowElement = document.createElement('div'); + + shadowElement.style.background = 'none'; + shadowElement.style.border = 'none'; + shadowElement.style.outline = 'none'; + shadowElement.style.boxShadow = 'none'; + shadowElement.style.margin = '0'; + shadowElement.style.padding = '0'; + shadowElement.style.top = '0'; + shadowElement.style.left = '0'; + shadowElement.style.transform = 'none'; + shadowElement.style.width = '0'; + shadowElement.style.height = '0'; + shadowElement.style.minWidth = '0'; + shadowElement.style.minHeight = '0'; + shadowElement.style.position = 'relative'; + shadowElement.style.display = 'inline'; + + target.appendChild(shadowElement); + + return shadowElement.attachShadow({ mode: 'open' }); +} diff --git a/src/content/default_window_methods.js b/src/content/default_window_methods.js new file mode 100644 index 0000000..89eaba5 --- /dev/null +++ b/src/content/default_window_methods.js @@ -0,0 +1,50 @@ +export const DEFAULT_WINDOW_METHODS = [ + 'alert', + 'atob', + 'blur', + 'btoa', + 'cancelAnimationFrame', + 'cancelIdleCallback', + 'captureEvents', + 'clearInterval', + 'clearTimeout', + 'close', + 'confirm', + 'createImageBitmap', + 'fetch', + 'find', + 'focus', + 'getComputedStyle', + 'getSelection', + 'matchMedia', + 'moveBy', + 'moveTo', + 'open', + 'postMessage', + 'print', + 'prompt', + 'queueMicrotask', + 'releaseEvents', + 'reportError', + 'requestAnimationFrame', + 'requestIdleCallback', + 'resizeBy', + 'resizeTo', + 'scroll', + 'scrollBy', + 'scrollTo', + 'setInterval', + 'setTimeout', + 'stop', + 'structuredClone', + 'webkitCancelAnimationFrame', + 'webkitRequestAnimationFrame', + 'getScreenDetails', + 'queryLocalFonts', + 'showDirectoryPicker', + 'showOpenFilePicker', + 'showSaveFilePicker', + 'openDatabase', + 'webkitRequestFileSystem', + 'webkitResolveLocalFileSystemURL' +]; diff --git a/src/content/index.js b/src/content/index.js new file mode 100644 index 0000000..96e3547 --- /dev/null +++ b/src/content/index.js @@ -0,0 +1,61 @@ +import { DEFAULT_WINDOW_METHODS } from './default_window_methods'; +import { showButtonInsideElement } from './show_button_inside_element'; + +const FUNCTION_REGEX = /^([a-zA-Z0-9].*)\(.*?\)$/g; + +function main() { + const scripts = document.body.querySelectorAll('[href*="javascript:"]'); + + Array.from(scripts).forEach((script) => { + const title = script.textContent; + const href = script.getAttribute('href'); + const cleanHref = href.trim().replaceAll(' ', '').replaceAll(';', ''); + const cleanHrefWithoutPrefix = cleanHref.replace(/^javascript:/, ''); + const classList = Array.from(script.classList); + + // A lot of random buttons on websites include `void` code to prevent the + // default behaviour of links. These should not be considered bookmarklets. + if ( + cleanHref === 'javascript:' || + cleanHref === 'javascript:void(0)' || + cleanHref === 'javascript:void0' + ) { + return; + } + + // Empty links are not considered bookmarklets, such as icon buttons. + if (title.trim() === '') { + return; + } + + // A single character, like "x", is something used for buttons. Example on + // this cookie banner: https://www.universityofgalway.ie/t4training/bookmarklets.html + if (title.length === 1) { + return; + } + + // A regex object is stateful. Not resetting this will cause the regex test + // to toggle between `true` and `false` on each iteration of the loop, which + // is bananas! + FUNCTION_REGEX.lastIndex = 0; + + // Look for `href` values that are single function calls. If it's not a + // default window method, then it's probably not a bookmarklet. + const match = FUNCTION_REGEX.exec(cleanHrefWithoutPrefix); + + if (match) { + const potentialFunctionName = match[1].replace(/^window\./g, ''); + const isDefaultWindowMethod = DEFAULT_WINDOW_METHODS.includes( + potentialFunctionName + ); + + if (!isDefaultWindowMethod) { + return; + } + } + + showButtonInsideElement(script, title, href); + }); +} + +main(); diff --git a/src/content/show_button_inside_element.js b/src/content/show_button_inside_element.js new file mode 100644 index 0000000..d026018 --- /dev/null +++ b/src/content/show_button_inside_element.js @@ -0,0 +1,148 @@ +import { createShadowDomInside } from './create_shadow_dom_inside'; + +import addIcon from './add_icon.svg'; +import checkIcon from './check_icon.svg'; + +const CONTAINER_CLASS_NAME = 'powerlet-button'; +const MANAGE_BUTTON_CLASS_NAME = 'manage-button'; +const LABEL_CLASS_NAME = 'add-remove-button__label'; + +export const CREATE_BOOKMARK = 'CREATE_BOOKMARK'; + +export function showButtonInsideElement(target, title, code) { + const shadow = createShadowDomInside(target); + const style = document.createElement('style'); + + const container = document.createElement('div'); + const button = document.createElement('button'); + const icon = document.createElement('img'); + const label = document.createElement('span'); + + container.classList.add(CONTAINER_CLASS_NAME); + button.classList.add(MANAGE_BUTTON_CLASS_NAME); + + label.classList.add(LABEL_CLASS_NAME); + label.textContent = 'Add Bookmarklet'; + + icon.src = addIcon; + + style.innerHTML = ` + .${CONTAINER_CLASS_NAME} { + width: 28px; + height: 28px; + position: absolute; + transform: translate(-30%, -100%); + top: 50%; + left: 50%; + } + + .${CONTAINER_CLASS_NAME}:hover .${MANAGE_BUTTON_CLASS_NAME} { + width: auto; + height: auto; + padding: 5px; + transform: translate(-12.5px, -50%); + } + + .${CONTAINER_CLASS_NAME}:hover .${LABEL_CLASS_NAME} { + display: block; + } + + .${MANAGE_BUTTON_CLASS_NAME} { + color: white; + background-image: linear-gradient(-30deg, #D86299 0%, #A449D6 100%); + border-radius: 100px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: 15px; + font-weight: 500; + position: absolute; + top: 50%; + left: 50%; + width: 19px; + height: 19px; + transform: translate(-9px, -50%); + } + + .${MANAGE_BUTTON_CLASS_NAME}--added { + background: green; + animation: added 600ms ease; + } + + .${MANAGE_BUTTON_CLASS_NAME} img { + flex-shrink: 0; + user-select: none; + pointer-events: none; + } + + .${MANAGE_BUTTON_CLASS_NAME}--added img { + transform: translateY(1px); + } + + .${LABEL_CLASS_NAME} { + margin-right: 4px; + white-space: nowrap; + display: none; + user-select: none; + pointer-events: none; + } + + @keyframes added { + from { + box-shadow: 0 0 0 rgba(0, 100, 0, 1); + } + + to { + box-shadow: 0 0 50px rgba(0, 100, 0, 0); + } + } + `; + + button.addEventListener('mouseenter', () => { + container.style.zIndex = '2147483647'; + }); + + button.addEventListener('mouseleave', () => { + container.style.zIndex = 'auto'; + }); + + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (button.classList.contains(`${MANAGE_BUTTON_CLASS_NAME}--added`)) { + return; + } + + try { + chrome.runtime.sendMessage({ + action: CREATE_BOOKMARK, + payload: { + title: title.trim(), + url: code.trim() + } + }); + } catch (error) { + alert( + 'Powerlet: Something went wrong when adding bookmarklet. Please reload the page and try again.' + ); + console.error(error); + return; + } + + label.textContent = 'Added!'; + button.classList.add(`${MANAGE_BUTTON_CLASS_NAME}--added`); + icon.src = checkIcon; + }); + + button.append(icon); + button.append(label); + container.append(button); + + shadow.appendChild(style); + shadow.appendChild(container); +} diff --git a/src/manifest.json b/src/manifest.json index 0f09247..289a70e 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -9,6 +9,12 @@ "scripts": ["background.bundle.js"], "persistent": false }, + "content_scripts": [ + { + "js": ["content.bundle.js"], + "matches": ["http://*/*", "https://*/*"] + } + ], "browser_action": { "default_popup": "popup.html", "default_icon": { diff --git a/src/pages/examples.html b/src/pages/examples.html index 2d5c533..58bfaf6 100644 --- a/src/pages/examples.html +++ b/src/pages/examples.html @@ -36,16 +36,27 @@ display: inline-block; font-size: 16px; font-weight: 500; - padding: 12px 24px; + padding: 7px; text-decoration: none; - margin-bottom: 20px; - margin-right: 10px; + margin-right: 20px; + margin-bottom: 22px; } .bookmarklet:hover { color: #2A7CEA; } + .add-button { + background-image: linear-gradient(-30deg, #D86299 0%, #A449D6 100%); + border-radius: 100px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 19px; + height: 19px; + margin: 0 3px; + } + @media(prefers-color-scheme: dark) { body { background: #202124; @@ -66,8 +77,8 @@