chore: add clipper extension package (#2281)
This commit is contained in:
16
packages/clipper/.eslintrc.js
Normal file
16
packages/clipper/.eslintrc.js
Normal file
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['../../common.eslintrc.js'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
ignorePatterns: ['**/*.spec.ts', '__mocks__'],
|
||||
plugins: ['@typescript-eslint', 'prettier'],
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
globals: {
|
||||
__WEB_VERSION__: true,
|
||||
},
|
||||
}
|
||||
2
packages/clipper/.gitignore
vendored
Normal file
2
packages/clipper/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
web-ext-artifacts
|
||||
53
packages/clipper/README.md
Normal file
53
packages/clipper/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# @standardnotes/clipper
|
||||
|
||||
## Development flow
|
||||
|
||||
- Run `yarn watch:web` in a terminal to watch changes in the `@standardnotes/web` package
|
||||
|
||||
### Chromium
|
||||
|
||||
- Run `yarn watch-mv3` in another terminal to watch changes in the extension source
|
||||
|
||||
#### "Load unpacked" method
|
||||
|
||||
- Go to `chrome://extensions`
|
||||
- Enable `Developer mode`
|
||||
- Click "Load unpacked" and select the `dist` folder in the current package
|
||||
|
||||
You might need to manually press the reload button when you make changers
|
||||
|
||||
#### CLI method
|
||||
|
||||
```console
|
||||
yarn run-chromium --chromium-profile=PATH/TO/PROFILE
|
||||
```
|
||||
|
||||
- You might need to specify the Chromium binary using the `--chromium-binary` argument
|
||||
- Running `yarn run-chromium` without the `--chromium-profile` argument will create a new temporary profile every time
|
||||
|
||||
This method will automatically reload the extension when you make changes
|
||||
|
||||
### Firefox
|
||||
|
||||
- Run `yarn watch` in another terminal to watch changes in the extension source
|
||||
|
||||
```console
|
||||
yarn run-firefox --firefox-profile=PATH/TO/PROFILE
|
||||
```
|
||||
|
||||
- You might need to specify the Firefox binary using the `--firefox` or `-f` argument
|
||||
- Running `yarn run-firefox` without the `--firefox-profile` argument will create a new temporary profile every time
|
||||
|
||||
## Build
|
||||
|
||||
## Firefox
|
||||
|
||||
```console
|
||||
yarn build-firefox
|
||||
```
|
||||
|
||||
## Chromium
|
||||
|
||||
```console
|
||||
yarn build-chromium
|
||||
```
|
||||
BIN
packages/clipper/images/icon128.png
Normal file
BIN
packages/clipper/images/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
packages/clipper/images/icon16.png
Normal file
BIN
packages/clipper/images/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 510 B |
BIN
packages/clipper/images/icon32.png
Normal file
BIN
packages/clipper/images/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
packages/clipper/images/icon48.png
Normal file
BIN
packages/clipper/images/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/clipper/images/icon96.png
Normal file
BIN
packages/clipper/images/icon96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
35
packages/clipper/package.json
Normal file
35
packages/clipper/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@standardnotes/clipper",
|
||||
"description": "Web clipper browser extension for Standard Notes",
|
||||
"version": "1.0.1",
|
||||
"scripts": {
|
||||
"build-mv2": "yarn clean && webpack --config ./webpack.config.prod.js",
|
||||
"build-mv3": "yarn clean && MANIFEST_VERSION=3 webpack --config ./webpack.config.prod.js",
|
||||
"watch:web": "BUILD_TARGET=clipper yarn workspace @standardnotes/web run watch",
|
||||
"watch-mv2": "webpack --config ./webpack.config.dev.js --watch",
|
||||
"watch-mv3": "MANIFEST_VERSION=3 webpack --config ./webpack.config.dev.js --watch",
|
||||
"build-firefox": "BUILD_TARGET=clipper yarn build:web && EXT_TARGET=firefox yarn build-mv2 && web-ext build --source-dir ./dist --overwrite-dest",
|
||||
"build-chromium": "BUILD_TARGET=clipper yarn build:web && EXT_TARGET=chromium yarn build-mv3 && web-ext build --source-dir ./dist --overwrite-dest",
|
||||
"run-firefox": "web-ext run -t firefox-desktop --keep-profile-changes -s ./dist",
|
||||
"run-chromium": "web-ext run -t chromium --keep-profile-changes -s ./dist",
|
||||
"lint": "eslint src/ && yarn tsc",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/webextension-polyfill": "^0.10.0",
|
||||
"babel-loader": "^9.1.0",
|
||||
"copy-webpack-plugin": "11.0.0",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"ts-loader": "^9.4.2",
|
||||
"typescript": "*",
|
||||
"web-ext": "^7.5.0",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"webpack": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.4.2"
|
||||
}
|
||||
}
|
||||
32
packages/clipper/src/background/background.ts
Normal file
32
packages/clipper/src/background/background.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { runtime, action, browserAction, windows, storage } from 'webextension-polyfill'
|
||||
import { RuntimeMessage, RuntimeMessageTypes } from '../types/message'
|
||||
|
||||
const isFirefox = navigator.userAgent.indexOf('Firefox/') !== -1
|
||||
|
||||
const openPopupAndClipSelection = async (payload: { title: string; content: string }) => {
|
||||
await storage.local.set({ clip: payload })
|
||||
|
||||
if (isFirefox) {
|
||||
const popupURL = await browserAction.getPopup({})
|
||||
await windows.create({
|
||||
type: 'detached_panel',
|
||||
url: popupURL,
|
||||
width: 350,
|
||||
height: 450,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const openPopup = runtime.getManifest().manifest_version === 3 ? action.openPopup : browserAction.openPopup
|
||||
|
||||
void openPopup()
|
||||
}
|
||||
|
||||
runtime.onMessage.addListener((message: RuntimeMessage) => {
|
||||
if (message.type === RuntimeMessageTypes.OpenPopupWithSelection) {
|
||||
if (!message.payload) {
|
||||
return
|
||||
}
|
||||
void openPopupAndClipSelection(message.payload)
|
||||
}
|
||||
})
|
||||
119
packages/clipper/src/content/content.ts
Normal file
119
packages/clipper/src/content/content.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { runtime } from 'webextension-polyfill'
|
||||
import { Readability } from '@mozilla/readability'
|
||||
import { RuntimeMessage, RuntimeMessageTypes } from '../types/message'
|
||||
|
||||
let isSelectingNodeForClipping = false
|
||||
|
||||
runtime.onMessage.addListener(async (message: RuntimeMessage) => {
|
||||
switch (message.type) {
|
||||
case RuntimeMessageTypes.StartNodeSelection: {
|
||||
isSelectingNodeForClipping = true
|
||||
return
|
||||
}
|
||||
case RuntimeMessageTypes.HasSelection: {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selection.rangeCount < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
return !range.collapsed
|
||||
}
|
||||
case RuntimeMessageTypes.GetSelection: {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
const result = document.createElement('div')
|
||||
result.appendChild(range.cloneContents())
|
||||
|
||||
return { title: document.title, content: result.innerHTML, url: window.location.href }
|
||||
}
|
||||
case RuntimeMessageTypes.GetFullPage: {
|
||||
return { title: document.title, content: document.body.innerHTML, url: window.location.href }
|
||||
}
|
||||
case RuntimeMessageTypes.GetArticle: {
|
||||
const documentClone = document.cloneNode(true) as Document
|
||||
const article = new Readability(documentClone).parse()
|
||||
if (!article) {
|
||||
return
|
||||
}
|
||||
return { title: article.title, content: article.content, url: window.location.href }
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const nodeOverlayElement = document.createElement('div')
|
||||
nodeOverlayElement.style.border = '2px solid #086dd6'
|
||||
nodeOverlayElement.style.position = 'fixed'
|
||||
nodeOverlayElement.style.top = '0'
|
||||
nodeOverlayElement.style.left = '0'
|
||||
nodeOverlayElement.style.zIndex = '69420'
|
||||
nodeOverlayElement.style.width = window.innerWidth + 'px'
|
||||
nodeOverlayElement.style.height = window.innerHeight - 4 + 'px'
|
||||
nodeOverlayElement.style.pointerEvents = 'none'
|
||||
nodeOverlayElement.style.visibility = 'hidden'
|
||||
nodeOverlayElement.id = 'sn-clipper-node-overlay'
|
||||
|
||||
document.body.appendChild(nodeOverlayElement)
|
||||
|
||||
window.addEventListener('mousemove', (event) => {
|
||||
if (!isSelectingNodeForClipping) {
|
||||
nodeOverlayElement.style.visibility = 'hidden'
|
||||
return
|
||||
}
|
||||
nodeOverlayElement.style.visibility = ''
|
||||
const { target } = event
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
nodeOverlayElement.style.width = targetRect.width + 'px'
|
||||
nodeOverlayElement.style.height = targetRect.height + 'px'
|
||||
nodeOverlayElement.style.transform = `translate3d(${targetRect.x}px, ${targetRect.y}px, 0)`
|
||||
})
|
||||
|
||||
const disableNodeSelection = () => {
|
||||
isSelectingNodeForClipping = false
|
||||
nodeOverlayElement.style.visibility = 'hidden'
|
||||
}
|
||||
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isSelectingNodeForClipping) {
|
||||
return
|
||||
}
|
||||
disableNodeSelection()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const { target } = event
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
const title = document.title
|
||||
const content = target.outerHTML
|
||||
void runtime.sendMessage({
|
||||
type: RuntimeMessageTypes.OpenPopupWithSelection,
|
||||
payload: { title, content, url: window.location.href },
|
||||
} as RuntimeMessage)
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (!isSelectingNodeForClipping) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
disableNodeSelection()
|
||||
}
|
||||
})
|
||||
33
packages/clipper/src/manifest.v2.json
Normal file
33
packages/clipper/src/manifest.v2.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Standard Notes Clipper",
|
||||
"description": "Web clipper for Standard Notes",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"browser_action": {
|
||||
"default_popup": "popup/index.html?route=extension"
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"persistent": false
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
],
|
||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-eval' 'wasm-unsafe-eval'; worker-src blob:; connect-src * data: blob:; style-src 'unsafe-inline' 'self'; frame-src * blob:; img-src * data: blob:;",
|
||||
"icons": {
|
||||
"16": "images/icon16.png",
|
||||
"32": "images/icon32.png",
|
||||
"48": "images/icon48.png",
|
||||
"96": "images/icon96.png",
|
||||
"128": "images/icon128.png"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}",
|
||||
"strict_min_version": "48.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
packages/clipper/src/manifest.v3.json
Normal file
34
packages/clipper/src/manifest.v3.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Standard Notes Clipper",
|
||||
"description": "Web clipper for Standard Notes",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"action": {
|
||||
"default_popup": "popup/index.html?route=extension"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "images/icon16.png",
|
||||
"32": "images/icon32.png",
|
||||
"48": "images/icon48.png",
|
||||
"96": "images/icon96.png",
|
||||
"128": "images/icon128.png"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src * data: blob:; style-src 'unsafe-inline' 'self'; frame-src * blob:; img-src * data: blob:;"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{9f917dfe-accd-4d3a-9685-33c3ac0ca643}",
|
||||
"strict_min_version": "48.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/clipper/src/popup/globals.js
Normal file
7
packages/clipper/src/popup/globals.js
Normal file
@@ -0,0 +1,7 @@
|
||||
window.defaultSyncServer = 'https://api.standardnotes.com'
|
||||
window.defaultFilesHost = 'https://files.standardnotes.com'
|
||||
window.enabledUnfinishedFeatures = false
|
||||
window.websocketUrl = 'wss://sockets.standardnotes.com'
|
||||
window.purchaseUrl = 'https://standardnotes.com/purchase'
|
||||
window.plansUrl = 'https://standardnotes.com/plans'
|
||||
window.dashboardUrl = 'https://standardnotes.com/dashboard'
|
||||
26
packages/clipper/src/popup/index.html
Normal file
26
packages/clipper/src/popup/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="IE=edge" http-equiv="X-UA-Compatible" />
|
||||
<meta content="viewport-fit=cover, width=device-width, initial-scale=1" name="viewport" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<title>Standard Notes</title>
|
||||
|
||||
<script src="./globals.js"></script>
|
||||
|
||||
<script src="../web/app.js" debug="false"></script>
|
||||
<link rel="stylesheet" media="all" href="../web/app.css" debug="false" />
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
min-width: 350px;
|
||||
max-width: 350px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
</html>
|
||||
36
packages/clipper/src/types/message.ts
Normal file
36
packages/clipper/src/types/message.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const RuntimeMessageTypes = {
|
||||
GetArticle: 'get-article',
|
||||
GetSelection: 'get-selection',
|
||||
HasSelection: 'has-selection',
|
||||
GetFullPage: 'get-full-page',
|
||||
OpenPopupWithSelection: 'open-popup-with-selection',
|
||||
StartNodeSelection: 'start-node-selection',
|
||||
} as const
|
||||
|
||||
export type RuntimeMessageType = typeof RuntimeMessageTypes[keyof typeof RuntimeMessageTypes]
|
||||
|
||||
type MessagesWithClipPayload = typeof RuntimeMessageTypes.OpenPopupWithSelection
|
||||
|
||||
export type ClipPayload = {
|
||||
title: string
|
||||
content: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type RuntimeMessageReturnTypes = {
|
||||
[RuntimeMessageTypes.GetArticle]: ClipPayload
|
||||
[RuntimeMessageTypes.GetSelection]: ClipPayload
|
||||
[RuntimeMessageTypes.HasSelection]: boolean
|
||||
[RuntimeMessageTypes.GetFullPage]: ClipPayload
|
||||
[RuntimeMessageTypes.OpenPopupWithSelection]: void
|
||||
[RuntimeMessageTypes.StartNodeSelection]: void
|
||||
}
|
||||
|
||||
export type RuntimeMessage =
|
||||
| {
|
||||
type: MessagesWithClipPayload
|
||||
payload: ClipPayload
|
||||
}
|
||||
| {
|
||||
type: Exclude<RuntimeMessageType, MessagesWithClipPayload>
|
||||
}
|
||||
14
packages/clipper/src/utils/sendMessageToActiveTab.ts
Normal file
14
packages/clipper/src/utils/sendMessageToActiveTab.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { tabs } from 'webextension-polyfill'
|
||||
import { RuntimeMessageReturnTypes, RuntimeMessageType } from '../types/message'
|
||||
|
||||
export default async function sendMessageToActiveTab<T extends RuntimeMessageType>(
|
||||
type: T,
|
||||
): Promise<RuntimeMessageReturnTypes[T] | undefined> {
|
||||
const [activeTab] = await tabs.query({ active: true, currentWindow: true, windowType: 'normal' })
|
||||
|
||||
if (!activeTab || !activeTab.id) {
|
||||
return
|
||||
}
|
||||
|
||||
return await tabs.sendMessage(activeTab.id, { type })
|
||||
}
|
||||
28
packages/clipper/tsconfig.json
Normal file
28
packages/clipper/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"target": "ES2019",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"isolatedModules": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"newLine": "lf",
|
||||
"declarationDir": "dist/@types",
|
||||
"baseUrl": ".",
|
||||
"typeRoots": [
|
||||
"../../node_modules/@types",
|
||||
"node_modules/@types"
|
||||
],
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
9
packages/clipper/webpack.config.dev.js
Normal file
9
packages/clipper/webpack.config.dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const { merge } = require('webpack-merge')
|
||||
const config = require('./webpack.config.js')
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return merge(config(env, argv), {
|
||||
mode: 'development',
|
||||
devtool: 'cheap-module-source-map',
|
||||
})
|
||||
}
|
||||
69
packages/clipper/webpack.config.js
Normal file
69
packages/clipper/webpack.config.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const CopyPlugin = require('copy-webpack-plugin')
|
||||
const package = require('./package.json')
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isProd = !argv.watch
|
||||
|
||||
return {
|
||||
entry: {
|
||||
background: './src/background/background.ts',
|
||||
content: './src/content/content.ts',
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: '../web/dist',
|
||||
to: './web',
|
||||
globOptions: {
|
||||
ignore: isProd ? ['**/app.js.map'] : [],
|
||||
},
|
||||
},
|
||||
{
|
||||
from: `./src/manifest.v${process.env.MANIFEST_VERSION || 2}.json`,
|
||||
to: './manifest.json',
|
||||
transform: (content) => {
|
||||
const manifest = JSON.parse(content.toString())
|
||||
manifest.version = package.version
|
||||
if (process.env.EXT_TARGET === 'chromium') {
|
||||
delete manifest.browser_specific_settings
|
||||
}
|
||||
return JSON.stringify(manifest, null, 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
from: './src/popup',
|
||||
to: './popup',
|
||||
},
|
||||
{
|
||||
from: './images',
|
||||
to: './images',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|ts)$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
'babel-loader',
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
9
packages/clipper/webpack.config.prod.js
Normal file
9
packages/clipper/webpack.config.prod.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const { merge } = require('webpack-merge')
|
||||
const config = require('./webpack.config.js')
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return merge(config(env, argv), {
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user