clipper(web): add web clipper browser extension
1
extensions/web-clipper/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
build.pem
|
||||||
BIN
extensions/web-clipper/assets/128x128.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
extensions/web-clipper/assets/16x16.png
Normal file
|
After Width: | Height: | Size: 520 B |
BIN
extensions/web-clipper/assets/24x24.png
Normal file
|
After Width: | Height: | Size: 917 B |
BIN
extensions/web-clipper/assets/256x256.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
extensions/web-clipper/assets/32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
extensions/web-clipper/assets/48x48.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
extensions/web-clipper/assets/64x64.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
18
extensions/web-clipper/assets/logo.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 339 339">
|
||||||
|
<defs>
|
||||||
|
<linearGradient xlink:href="#a" id="b" x1="188.61227" x2="193.54405" y1="165.2058" y2="216.81519" gradientTransform="rotate(5 4448 -4204) scale(2.93671)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
<linearGradient id="a">
|
||||||
|
<stop offset="0"/>
|
||||||
|
<stop offset="1" stop-color="#fff9f9" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient xlink:href="#a" id="c" x1="167.8" x2="270.6" y1="76.9" y2="64.2" gradientTransform="rotate(5 465 -2050) scale(1.50082)" gradientUnits="userSpaceOnUse"/>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(0 42)">
|
||||||
|
<path fill="url(#b)" d="m160 205 154 42-141 44-155-42z"/>
|
||||||
|
<path fill="url(#c)" d="M160-35v240l154 42 1-253z"/>
|
||||||
|
<path fill="none" d="M160 205V-35m0 240L18 249m142-44 154 41"/>
|
||||||
|
<path d="m84 109 35 54V98l21-7v91l-27 9-35-54v65l-21 6v-91z"/>
|
||||||
|
<rect width="86.1" height="12.6" x="185" y="97" fill="#bebebe" ry="2.3" transform="skewY(15) scale(.9669 1)"/>
|
||||||
|
<path fill="#bebebe" d="m181 169 99 26 2 3v8c0 1-1 2-2 1l-99-26-2-3v-7c0-2 1-2 2-2zm0-47 99 27 2 2v8l-2 2-99-27c-1 0-2-1-2-3v-7l2-2z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
15
extensions/web-clipper/build-utils/build.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Do this as the first thing so that any code reading it knows the right env.
|
||||||
|
process.env.BABEL_ENV = "production";
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
process.env.ASSET_PATH = "/";
|
||||||
|
|
||||||
|
var webpack = require("webpack"),
|
||||||
|
config = require("../webpack.config");
|
||||||
|
|
||||||
|
delete config.chromeExtensionBoilerplate;
|
||||||
|
|
||||||
|
config.mode = "production";
|
||||||
|
|
||||||
|
webpack(config, function (err) {
|
||||||
|
if (err) throw err;
|
||||||
|
});
|
||||||
60
extensions/web-clipper/build-utils/dev.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Do this as the first thing so that any code reading it knows the right env.
|
||||||
|
process.env.BABEL_ENV = "development";
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
process.env.ASSET_PATH = "/";
|
||||||
|
|
||||||
|
var WebpackDevServer = require("webpack-dev-server"),
|
||||||
|
webpack = require("webpack"),
|
||||||
|
config = require("../webpack.config"),
|
||||||
|
env = require("./env"),
|
||||||
|
path = require("path");
|
||||||
|
|
||||||
|
var options = config.chromeExtensionBoilerplate || {};
|
||||||
|
var excludeEntriesToHotReload = options.notHotReload || [];
|
||||||
|
|
||||||
|
for (var entryName in config.entry) {
|
||||||
|
if (excludeEntriesToHotReload.indexOf(entryName) === -1) {
|
||||||
|
config.entry[entryName] = [
|
||||||
|
"webpack/hot/dev-server",
|
||||||
|
`webpack-dev-server/client?hot=true&hostname=localhost&port=${env.PORT}`,
|
||||||
|
].concat(config.entry[entryName]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.plugins = [new webpack.HotModuleReplacementPlugin()].concat(
|
||||||
|
config.plugins || []
|
||||||
|
);
|
||||||
|
|
||||||
|
delete config.chromeExtensionBoilerplate;
|
||||||
|
|
||||||
|
var compiler = webpack(config);
|
||||||
|
|
||||||
|
var server = new WebpackDevServer(
|
||||||
|
{
|
||||||
|
https: false,
|
||||||
|
hot: false,
|
||||||
|
client: false,
|
||||||
|
host: "localhost",
|
||||||
|
port: env.PORT,
|
||||||
|
static: {
|
||||||
|
directory: path.join(__dirname, "../build"),
|
||||||
|
},
|
||||||
|
devMiddleware: {
|
||||||
|
publicPath: `http://localhost:${env.PORT}/`,
|
||||||
|
writeToDisk: true,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
allowedHosts: "all",
|
||||||
|
},
|
||||||
|
compiler
|
||||||
|
);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development" && module.hot) {
|
||||||
|
module.hot.accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await server.start();
|
||||||
|
})();
|
||||||
5
extensions/web-clipper/build-utils/env.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// tiny wrapper with default env vars
|
||||||
|
module.exports = {
|
||||||
|
NODE_ENV: process.env.NODE_ENV || "development",
|
||||||
|
PORT: process.env.PORT || 3000,
|
||||||
|
};
|
||||||
68
extensions/web-clipper/build-utils/manifest.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const ICONS = {
|
||||||
|
16: "16x16.png",
|
||||||
|
32: "32x32.png",
|
||||||
|
48: "48x48.png",
|
||||||
|
64: "64x64.png",
|
||||||
|
128: "128x128.png",
|
||||||
|
256: "256x256.png"
|
||||||
|
};
|
||||||
|
const BACKGROUND_SCRIPT = "background.bundle.js";
|
||||||
|
const ACTION = {
|
||||||
|
default_icon: ICONS,
|
||||||
|
default_title: "Notesnook Web Clipper",
|
||||||
|
default_popup: "popup.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
name: "Notesnook Web Clipper",
|
||||||
|
version: "1.0",
|
||||||
|
description: "Clip web pages.",
|
||||||
|
permissions: [
|
||||||
|
"activeTab",
|
||||||
|
"tabs",
|
||||||
|
"storage",
|
||||||
|
"contextMenus",
|
||||||
|
"notifications"
|
||||||
|
],
|
||||||
|
key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmXQb9xwsSWbvAbBVnWa+DwsXLtRLbjfLyWRZtT5KF8bjCrEg3InvntWlk1PrNo73lsTov1m8Y5K9fJcCVKuYZkTCmNf4iGsHqOafi9ny5MX53oQ53+/s7gao2ZicHtTylnCIqn8f/l+RkV3tHO8BwANDLX2TTe7zCYLzFH19jiKAI+7qmUDZvyCH/OMVohluUCQO94s7sghslalwPbAcQQLpAKxYdd5GJDn4FryitsCMTYX962X+O6Tivq2QPML/Gm7BrZqJsU1enFRH1ss0UK0b9COpEYqqPhZ+GJP5K6WOL46NX+CvZnQmux1ehTZgIhw64IQJ57TvG2kIQTA8ZQIDAQAB",
|
||||||
|
content_scripts: [
|
||||||
|
{
|
||||||
|
js: ["nnContentScript.bundle.js"],
|
||||||
|
matches: ["*://app.notesnook.com/*", "*://localhost/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
js: ["contentScript.bundle.js"],
|
||||||
|
matches: ["http://*/*", "https://*/*"],
|
||||||
|
exclude_matches: ["*://app.notesnook.com/*", "*://localhost/*"]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
browser_specific_settings: {
|
||||||
|
gecko: {
|
||||||
|
strict_min_version: "105.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icons: ICONS,
|
||||||
|
};
|
||||||
|
|
||||||
|
const v2 = {
|
||||||
|
...common,
|
||||||
|
manifest_version: 2,
|
||||||
|
background: {
|
||||||
|
scripts: [BACKGROUND_SCRIPT]
|
||||||
|
},
|
||||||
|
browser_action: ACTION
|
||||||
|
};
|
||||||
|
|
||||||
|
const v3 = {
|
||||||
|
...common,
|
||||||
|
manifest_version: 3,
|
||||||
|
background: {
|
||||||
|
service_worker: BACKGROUND_SCRIPT
|
||||||
|
},
|
||||||
|
action: ACTION
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
v2,
|
||||||
|
v3
|
||||||
|
};
|
||||||
6
extensions/web-clipper/global.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
declare module "*.svg" {
|
||||||
|
import React = require("react");
|
||||||
|
export const ReactComponent: React.SFC<React.SVGProps<SVGSVGElement>>;
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
36502
extensions/web-clipper/package-lock.json
generated
Normal file
110
extensions/web-clipper/package.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"name": "@notesnook/web-clipper",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.10.0",
|
||||||
|
"@hot-loader/react-dom": "^17.0.2",
|
||||||
|
"@mdi/js": "^6.5.95",
|
||||||
|
"@mdi/react": "^1.4.0",
|
||||||
|
"@notesnook/clipper": "*",
|
||||||
|
"@notesnook/theme": "*",
|
||||||
|
"@theme-ui/components": "^0.14.7",
|
||||||
|
"@theme-ui/core": "^0.14.7",
|
||||||
|
"comlink": "^4.3.1",
|
||||||
|
"comlink-extension": "^1.0.8",
|
||||||
|
"hyperapp": "^2.0.22",
|
||||||
|
"mac-scrollbar": "^0.10.3",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"react-frame-component": "^5.2.1",
|
||||||
|
"react-hot-loader": "^4.13.0",
|
||||||
|
"react-modal": "^3.14.4",
|
||||||
|
"react-scripts": "5.0.0",
|
||||||
|
"svg-react-loader": "^0.4.6",
|
||||||
|
"webextension-polyfill-ts": "^0.26.0",
|
||||||
|
"zustand": "^4.1.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build:firefox": "MANIFEST_VERSION=2 node build-utils/build.js",
|
||||||
|
"build:chrome": "MANIFEST_VERSION=3 node build-utils/build.js",
|
||||||
|
"dev:chrome": "MANIFEST_VERSION=3 node build-utils/dev.js",
|
||||||
|
"dev:firefox": "MANIFEST_VERSION=2 node build-utils/dev.js",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "react-app",
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"commonjs": true,
|
||||||
|
"es6": true,
|
||||||
|
"node": true,
|
||||||
|
"webextensions": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.17.0",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.16.7",
|
||||||
|
"@babel/preset-env": "^7.16.11",
|
||||||
|
"@babel/preset-react": "^7.16.7",
|
||||||
|
"@types/chrome": "^0.0.180",
|
||||||
|
"@types/firefox-webext-browser": "^94.0.1",
|
||||||
|
"@types/inline-css": "^3.0.1",
|
||||||
|
"@types/jest": "^26.0.14",
|
||||||
|
"@types/postlight__mercury-parser": "^2.2.4",
|
||||||
|
"@types/react": "^17.0.2",
|
||||||
|
"@types/react-dom": "^17.0.2",
|
||||||
|
"@types/react-modal": "^3.13.1",
|
||||||
|
"@types/sanitize-html": "^2.6.2",
|
||||||
|
"@types/webextension-polyfill": "^0.8.3",
|
||||||
|
"babel-eslint": "^10.1.0",
|
||||||
|
"babel-loader": "^8.2.3",
|
||||||
|
"babel-preset-react-app": "^10.0.1",
|
||||||
|
"clean-webpack-plugin": "^4.0.0",
|
||||||
|
"copy-webpack-plugin": "^7.0.0",
|
||||||
|
"css-loader": "^6.6.0",
|
||||||
|
"eslint": "^8.8.0",
|
||||||
|
"eslint-config-react-app": "^7.0.0",
|
||||||
|
"eslint-plugin-flowtype": "^8.0.3",
|
||||||
|
"eslint-plugin-import": "^2.25.4",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||||
|
"eslint-plugin-react": "^7.28.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
|
"fs-extra": "^10.0.0",
|
||||||
|
"html-loader": "^3.1.0",
|
||||||
|
"html-webpack-plugin": "^5.5.0",
|
||||||
|
"node-sass": "^6.0.1",
|
||||||
|
"prettier": "^2.5.1",
|
||||||
|
"sass-loader": "^12.4.0",
|
||||||
|
"source-map-loader": "^3.0.1",
|
||||||
|
"style-loader": "^3.3.1",
|
||||||
|
"terser-webpack-plugin": "^5.3.1",
|
||||||
|
"ts-loader": "^9.2.6",
|
||||||
|
"typescript": "^4.6.3",
|
||||||
|
"webpack": "^5.68.0",
|
||||||
|
"webpack-cli": "^4.9.2",
|
||||||
|
"webpack-dev-server": "^4.7.4"
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"build": {
|
||||||
|
"patterns": [
|
||||||
|
"./src/**/**/**/*",
|
||||||
|
"./public/**/*"
|
||||||
|
],
|
||||||
|
"extensions": "ts,js,json,tsx,jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
extensions/web-clipper/public/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
58
extensions/web-clipper/src/api.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { browser, Runtime } from "webextension-polyfill-ts";
|
||||||
|
import { Remote, wrap } from "comlink";
|
||||||
|
import { createEndpoint } from "./utils/comlink-extension";
|
||||||
|
import { Server } from "./common/bridge";
|
||||||
|
import { APP_URL, APP_URL_FILTER } from "./common/constants";
|
||||||
|
|
||||||
|
let api: Remote<Server> | undefined;
|
||||||
|
export async function connectApi(openNew = false, onDisconnect?: () => void) {
|
||||||
|
if (api) return api;
|
||||||
|
|
||||||
|
const tab = await getTab(openNew);
|
||||||
|
if (!tab || !tab.id) return false;
|
||||||
|
|
||||||
|
return await new Promise<Remote<Server> | undefined>(function connect(
|
||||||
|
resolve,
|
||||||
|
reject
|
||||||
|
) {
|
||||||
|
const port = browser.tabs.connect(tab.id!);
|
||||||
|
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
api = undefined;
|
||||||
|
onDisconnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onMessage(message: any, port: Runtime.Port) {
|
||||||
|
if (message?.success) {
|
||||||
|
port.onMessage.removeListener(onMessage);
|
||||||
|
api = wrap<Server>(createEndpoint(port));
|
||||||
|
resolve(api);
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
port.onMessage.addListener(onMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTab(openNew = false) {
|
||||||
|
const tabs = await browser.tabs.query({
|
||||||
|
url: APP_URL_FILTER
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tabs.length) return tabs[0];
|
||||||
|
|
||||||
|
if (openNew) {
|
||||||
|
const [tab] = await Promise.all([
|
||||||
|
browser.tabs.create({ url: APP_URL, active: false }),
|
||||||
|
new Promise((resolve) => {
|
||||||
|
browser.runtime.onMessage.addListener((message) => {
|
||||||
|
if (message.type === "start_connection") resolve(true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
18
extensions/web-clipper/src/app.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ThemeProvider } from "./components/theme-provider";
|
||||||
|
import { useAppStore } from "./stores/app-store";
|
||||||
|
import { Login } from "./views/login";
|
||||||
|
import { Main } from "./views/main";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const isLoggedIn = useAppStore((s) => s.isLoggedIn);
|
||||||
|
const user = useAppStore((s) => s.user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider accent={user?.accent} theme={user?.theme}>
|
||||||
|
{(() => {
|
||||||
|
if (!isLoggedIn) return <Login />;
|
||||||
|
return <Main />;
|
||||||
|
})()}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
extensions/web-clipper/src/background.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { browser } from "webextension-polyfill-ts";
|
||||||
|
import { storeClip } from "./utils/storage";
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((message) => {
|
||||||
|
if (message?.type === "manual") {
|
||||||
|
storeClip(message.data).then(() => {
|
||||||
|
return browser.notifications?.create({
|
||||||
|
title: "Clip successful!",
|
||||||
|
message: "Open Notesnook Web Clipper to save the clip!",
|
||||||
|
type: "basic",
|
||||||
|
iconUrl: browser.runtime.getURL("256x256.png"),
|
||||||
|
isClickable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
79
extensions/web-clipper/src/common/bridge.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
WEB_EXTENSION_CHANNEL?: MessageChannel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClipArea = "full-page" | "visible" | "selection" | "article";
|
||||||
|
|
||||||
|
export type ClipMode = "simplified" | "screenshot" | "complete";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
email: string;
|
||||||
|
pro: boolean;
|
||||||
|
accent: string;
|
||||||
|
theme: "dark" | "light";
|
||||||
|
};
|
||||||
|
export type ItemReference = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotebookReference = ItemReference & {
|
||||||
|
topics: ItemReference[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientMetadata = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Gateway {
|
||||||
|
connect(): ClientMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectedNotebook = ItemReference & { topic: ItemReference };
|
||||||
|
|
||||||
|
export type Clip = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
data: string;
|
||||||
|
area: ClipArea;
|
||||||
|
mode: ClipMode;
|
||||||
|
pageTitle?: string;
|
||||||
|
tags?: string[];
|
||||||
|
note?: ItemReference;
|
||||||
|
notebook?: SelectedNotebook;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
login(): Promise<User | null>;
|
||||||
|
getNotes(): Promise<ItemReference[] | undefined>;
|
||||||
|
getNotebooks(): Promise<NotebookReference[] | undefined>;
|
||||||
|
getTags(): Promise<ItemReference[] | undefined>;
|
||||||
|
saveClip(clip: Clip): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEB_EXTENSION_CHANNEL_EVENTS = {
|
||||||
|
ON_CREATED: "web-extension-channel-created",
|
||||||
|
ON_READY: "web-extension-channel-ready"
|
||||||
|
} as const;
|
||||||
8
extensions/web-clipper/src/common/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const APP_URL =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "https://app.notesnook.com"
|
||||||
|
: "http://localhost:3000";
|
||||||
|
export const APP_URL_FILTER =
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? ["*://app.notesnook.com/*"]
|
||||||
|
: ["*://localhost/*"];
|
||||||
117
extensions/web-clipper/src/components/filtered-list/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Flex, Input, Text } from "@theme-ui/components";
|
||||||
|
import { Icon } from "../icons/icon";
|
||||||
|
import { Icons } from "../icons";
|
||||||
|
import { FlexScrollContainer } from "../scroll-container";
|
||||||
|
|
||||||
|
type FilteredListT<T> = {
|
||||||
|
placeholder: string;
|
||||||
|
getAll: () => Array<T>;
|
||||||
|
filter: (items: T[], query: string) => T[];
|
||||||
|
onCreateNewItem?: (title: string) => void;
|
||||||
|
renderItem: (item: T, index: number) => JSX.Element;
|
||||||
|
itemName: string;
|
||||||
|
refreshItems: () => T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FilteredList<T>({
|
||||||
|
placeholder,
|
||||||
|
getAll,
|
||||||
|
filter,
|
||||||
|
onCreateNewItem,
|
||||||
|
renderItem,
|
||||||
|
refreshItems,
|
||||||
|
itemName
|
||||||
|
}: FilteredListT<T>) {
|
||||||
|
const [items, setItems] = useState<T[]>([]);
|
||||||
|
const [query, setQuery] = useState<string | null>("");
|
||||||
|
const noItemsFound = items.length <= 0 && !!query?.length;
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
refreshItems();
|
||||||
|
setItems(getAll());
|
||||||
|
}, [refreshItems, getAll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const _filter = useCallback(
|
||||||
|
(query) => {
|
||||||
|
setItems(() => {
|
||||||
|
const items = getAll();
|
||||||
|
if (!query) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return filter(items, query);
|
||||||
|
});
|
||||||
|
setQuery(query);
|
||||||
|
},
|
||||||
|
[getAll, filter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _createNewItem = useCallback(
|
||||||
|
(title) => {
|
||||||
|
onCreateNewItem?.(title);
|
||||||
|
refresh();
|
||||||
|
setQuery(null);
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputRef, refresh, onCreateNewItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
mb: 1,
|
||||||
|
mr: 1,
|
||||||
|
fontSize: "body",
|
||||||
|
py: "7px",
|
||||||
|
color: "text"
|
||||||
|
}}
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
placeholder={items.length <= 0 ? `Add a ${itemName}` : placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
_filter(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyUp={async (e) => {
|
||||||
|
if (e.key === "Enter" && noItemsFound) {
|
||||||
|
_createNewItem(query);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlexScrollContainer>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{noItemsFound && onCreateNewItem && (
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
sx={{ display: "flex", alignItems: "center" }}
|
||||||
|
onClick={async () => {
|
||||||
|
await _createNewItem(query);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon path={Icons.plus} color="icon" size={18} />
|
||||||
|
<Text
|
||||||
|
variant={"body"}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
>{`Add "${query}" ${itemName}`}</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{items.map(renderItem)}
|
||||||
|
</Flex>
|
||||||
|
</FlexScrollContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
extensions/web-clipper/src/components/icons/icon.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import MDIIcon from "@mdi/react";
|
||||||
|
import { SchemeColors } from "@notesnook/theme/dist/theme/colorscheme";
|
||||||
|
import { Flex, FlexProps } from "@theme-ui/components";
|
||||||
|
import { useTheme } from "@emotion/react";
|
||||||
|
import { Theme } from "@notesnook/theme";
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
title?: string;
|
||||||
|
path: string;
|
||||||
|
size?: number;
|
||||||
|
color?: keyof SchemeColors;
|
||||||
|
stroke?: string;
|
||||||
|
rotate?: boolean;
|
||||||
|
};
|
||||||
|
function MDIIconWrapper({
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
size = 24,
|
||||||
|
color = "icon",
|
||||||
|
stroke,
|
||||||
|
rotate
|
||||||
|
}: IconProps) {
|
||||||
|
const theme = useTheme() as Theme;
|
||||||
|
|
||||||
|
const themedColor: string = theme.colors
|
||||||
|
? (theme.colors[color] as string)
|
||||||
|
: color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MDIIcon
|
||||||
|
className="icon"
|
||||||
|
title={title}
|
||||||
|
path={path}
|
||||||
|
size={size + "px"}
|
||||||
|
style={{
|
||||||
|
strokeWidth: stroke || "0px",
|
||||||
|
stroke: themedColor
|
||||||
|
}}
|
||||||
|
color={themedColor}
|
||||||
|
spin={rotate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NNIconProps = FlexProps & IconProps;
|
||||||
|
|
||||||
|
export function Icon(props: NNIconProps) {
|
||||||
|
const { sx, title, color, size, stroke, rotate, path, ...restProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
...sx
|
||||||
|
}}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<MDIIconWrapper
|
||||||
|
title={title}
|
||||||
|
path={path}
|
||||||
|
rotate={rotate}
|
||||||
|
color={color}
|
||||||
|
stroke={stroke}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
extensions/web-clipper/src/components/icons/index.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
mdiCheck,
|
||||||
|
mdiChevronDown,
|
||||||
|
mdiLoading,
|
||||||
|
mdiPlus,
|
||||||
|
mdiClose,
|
||||||
|
mdiDeleteOutline,
|
||||||
|
mdiDownloadOutline,
|
||||||
|
mdiCheckboxMarkedOutline,
|
||||||
|
mdiChevronUp,
|
||||||
|
mdiChevronRight,
|
||||||
|
mdiWeatherNight,
|
||||||
|
mdiWeatherSunny,
|
||||||
|
mdiCogOutline,
|
||||||
|
mdiNewspaperVariantOutline,
|
||||||
|
mdiTextBoxOutline,
|
||||||
|
mdiFitToScreenOutline,
|
||||||
|
mdiCursorDefaultClickOutline,
|
||||||
|
mdiNewspaper,
|
||||||
|
mdiMagnify,
|
||||||
|
mdiViewDayOutline,
|
||||||
|
mdiViewDashboardOutline
|
||||||
|
} from "@mdi/js";
|
||||||
|
|
||||||
|
export const Icons = {
|
||||||
|
darkMode: mdiWeatherNight,
|
||||||
|
lightMode: mdiWeatherSunny,
|
||||||
|
settings: mdiCogOutline,
|
||||||
|
search: mdiMagnify,
|
||||||
|
|
||||||
|
fullPage: mdiNewspaper,
|
||||||
|
article: mdiNewspaperVariantOutline,
|
||||||
|
visible: mdiViewDayOutline,
|
||||||
|
selection: mdiCursorDefaultClickOutline,
|
||||||
|
|
||||||
|
simplified: mdiTextBoxOutline,
|
||||||
|
screenshot: mdiFitToScreenOutline,
|
||||||
|
complete: mdiViewDashboardOutline,
|
||||||
|
|
||||||
|
check: mdiCheck,
|
||||||
|
checkbox: mdiCheckboxMarkedOutline,
|
||||||
|
loading: mdiLoading,
|
||||||
|
plus: mdiPlus,
|
||||||
|
close: mdiClose,
|
||||||
|
delete: mdiDeleteOutline,
|
||||||
|
download: mdiDownloadOutline,
|
||||||
|
chevronDown: mdiChevronDown,
|
||||||
|
chevronUp: mdiChevronUp,
|
||||||
|
chevronRight: mdiChevronRight,
|
||||||
|
|
||||||
|
none: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IconNames = keyof typeof Icons;
|
||||||
114
extensions/web-clipper/src/components/note-picker/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Flex, Text } from "@theme-ui/components";
|
||||||
|
import { FilteredList } from "../filtered-list";
|
||||||
|
import { ItemReference } from "../../common/bridge";
|
||||||
|
import { Icon } from "../icons/icon";
|
||||||
|
import { Icons } from "../icons";
|
||||||
|
import { useAppStore } from "../../stores/app-store";
|
||||||
|
import { Picker } from "../picker";
|
||||||
|
|
||||||
|
type NotePickerProps = {
|
||||||
|
selectedNote?: ItemReference;
|
||||||
|
onSelected: (note?: ItemReference) => void;
|
||||||
|
};
|
||||||
|
export const NotePicker = (props: NotePickerProps) => {
|
||||||
|
const { selectedNote, onSelected } = props;
|
||||||
|
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const notes = useAppStore((s) => s.notes);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
};
|
||||||
|
const open = () => setModalVisible(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex sx={{ alignItems: "center" }}>
|
||||||
|
<Button
|
||||||
|
variant="tool"
|
||||||
|
onClick={open}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
height: 33
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
selectedNote
|
||||||
|
? `Append to ${selectedNote?.title}`
|
||||||
|
: `Select a note to append to`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="text"
|
||||||
|
sx={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
overflow: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedNote
|
||||||
|
? `Append to "${selectedNote?.title}"`
|
||||||
|
: `Select a note to append to`}
|
||||||
|
</Text>
|
||||||
|
<Icon path={Icons.chevronDown} color="text" size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tool"
|
||||||
|
onClick={() => onSelected(undefined)}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
height: 33
|
||||||
|
}}
|
||||||
|
title={"Clear selection"}
|
||||||
|
>
|
||||||
|
<Icon path={Icons.close} color="text" size={16} />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Picker onClose={close} isOpen={modalVisible}>
|
||||||
|
<FilteredList
|
||||||
|
getAll={() => notes}
|
||||||
|
filter={(items, query) =>
|
||||||
|
items.filter((i) => i.title.toLowerCase().indexOf(query) > -1)
|
||||||
|
}
|
||||||
|
itemName="note"
|
||||||
|
placeholder={"Search for a note"}
|
||||||
|
refreshItems={() => notes}
|
||||||
|
renderItem={(note) => (
|
||||||
|
<Note
|
||||||
|
note={note}
|
||||||
|
onSelected={() => {
|
||||||
|
onSelected(note);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Picker>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Note({
|
||||||
|
note,
|
||||||
|
onSelected
|
||||||
|
}: {
|
||||||
|
note: ItemReference;
|
||||||
|
onSelected: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button onClick={onSelected} variant="list" sx={{ py: "7px" }}>
|
||||||
|
<Text variant="body">{note.title}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
extensions/web-clipper/src/components/notebook-picker/index.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Flex, Text } from "@theme-ui/components";
|
||||||
|
import { FilteredList } from "../filtered-list";
|
||||||
|
import {
|
||||||
|
NotebookReference,
|
||||||
|
ItemReference,
|
||||||
|
SelectedNotebook
|
||||||
|
} from "../../common/bridge";
|
||||||
|
import { Icon } from "../icons/icon";
|
||||||
|
import { Icons } from "../icons";
|
||||||
|
import { useAppStore } from "../../stores/app-store";
|
||||||
|
import { Picker } from "../picker";
|
||||||
|
|
||||||
|
type NotebookPickerProps = {
|
||||||
|
selectedNotebook?: SelectedNotebook;
|
||||||
|
onSelected: (notebook?: SelectedNotebook) => void;
|
||||||
|
};
|
||||||
|
export const NotebookPicker = (props: NotebookPickerProps) => {
|
||||||
|
const { selectedNotebook, onSelected } = props;
|
||||||
|
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const notebooks = useAppStore((s) => s.notebooks);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setExpanded(null);
|
||||||
|
setModalVisible(false);
|
||||||
|
};
|
||||||
|
const open = () => setModalVisible(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex sx={{ alignItems: "center" }}>
|
||||||
|
<Button
|
||||||
|
variant="tool"
|
||||||
|
onClick={open}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
height: 33
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
selectedNotebook
|
||||||
|
? `${selectedNotebook.title} > ${selectedNotebook.topic.title}`
|
||||||
|
: `Select a notebook`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text variant="text">
|
||||||
|
{selectedNotebook
|
||||||
|
? `${selectedNotebook.title} > ${selectedNotebook.topic.title}`
|
||||||
|
: `Select a notebook`}
|
||||||
|
</Text>
|
||||||
|
<Icon path={Icons.chevronDown} color="text" size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tool"
|
||||||
|
onClick={() => onSelected(undefined)}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
height: 33
|
||||||
|
}}
|
||||||
|
title={"Clear selection"}
|
||||||
|
>
|
||||||
|
<Icon path={Icons.close} color="text" size={16} />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Picker onClose={close} isOpen={modalVisible}>
|
||||||
|
<FilteredList
|
||||||
|
getAll={() => notebooks}
|
||||||
|
filter={(items, query) =>
|
||||||
|
items.filter((item) => item.title.toLowerCase().indexOf(query) > -1)
|
||||||
|
}
|
||||||
|
itemName="notebook"
|
||||||
|
placeholder={"Search for a notebook"}
|
||||||
|
refreshItems={() => notebooks}
|
||||||
|
renderItem={(item, index) => (
|
||||||
|
<Notebook
|
||||||
|
notebook={item}
|
||||||
|
isExpanded={expanded === item.id}
|
||||||
|
onExpand={(id) => setExpanded(id || null)}
|
||||||
|
onSelected={(notebook) => {
|
||||||
|
onSelected(notebook);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Picker>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotebookProps = {
|
||||||
|
notebook: NotebookReference;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onExpand: (notebookId?: string) => void;
|
||||||
|
onSelected: (notebook: SelectedNotebook) => void;
|
||||||
|
};
|
||||||
|
function Notebook(props: NotebookProps) {
|
||||||
|
const { notebook, isExpanded, onExpand, onSelected } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="list"
|
||||||
|
onClick={() => {
|
||||||
|
if (isExpanded) return onExpand();
|
||||||
|
onExpand(notebook.id);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: isExpanded ? "border" : "transparent",
|
||||||
|
py: "7px",
|
||||||
|
px: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
sx={{
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "var(--paragraph)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notebook.title}
|
||||||
|
</Text>
|
||||||
|
<Icon
|
||||||
|
path={isExpanded ? Icons.chevronUp : Icons.chevronDown}
|
||||||
|
color="text"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isExpanded ? (
|
||||||
|
<FilteredList
|
||||||
|
getAll={() => notebook.topics}
|
||||||
|
filter={(items, query) =>
|
||||||
|
items.filter((item) => item.title.toLowerCase().indexOf(query) > -1)
|
||||||
|
}
|
||||||
|
itemName="topic"
|
||||||
|
placeholder={"Search for a topic"}
|
||||||
|
refreshItems={() => notebook.topics}
|
||||||
|
renderItem={(topic, index) => (
|
||||||
|
<Topic
|
||||||
|
topic={topic}
|
||||||
|
onSelected={() => {
|
||||||
|
onSelected({ id: notebook.id, title: notebook.title, topic });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopicProps = {
|
||||||
|
topic: ItemReference;
|
||||||
|
onSelected: () => void;
|
||||||
|
};
|
||||||
|
function Topic(props: TopicProps) {
|
||||||
|
const { topic, onSelected } = props;
|
||||||
|
return (
|
||||||
|
<Button variant="list" onClick={onSelected} sx={{ pl: 3, py: "7px" }}>
|
||||||
|
<Text variant="text">{topic.title}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
extensions/web-clipper/src/components/picker/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import Modal from "react-modal";
|
||||||
|
import { Flex } from "@theme-ui/components";
|
||||||
|
import { ThemeProvider } from "../theme-provider";
|
||||||
|
Modal.setAppElement("#root");
|
||||||
|
|
||||||
|
const customStyles = {
|
||||||
|
content: {
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
right: "auto",
|
||||||
|
bottom: "auto",
|
||||||
|
marginRight: "-50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
boxShadow: "0px 1px 10px var(--info)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
|
padding: "10px",
|
||||||
|
|
||||||
|
height: "80vh",
|
||||||
|
width: "85vw",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden"
|
||||||
|
} as const
|
||||||
|
};
|
||||||
|
|
||||||
|
type PickerProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
export const Picker = (props: PropsWithChildren<PickerProps>) => {
|
||||||
|
const { children, isOpen, onClose } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
style={{
|
||||||
|
content: customStyles.content,
|
||||||
|
overlay: {
|
||||||
|
backgroundColor: "var(--overlay)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
isOpen={isOpen}
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
</ThemeProvider>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { PropsWithChildren } from "react";
|
||||||
|
import { MacScrollbar } from "mac-scrollbar";
|
||||||
|
import "mac-scrollbar/dist/mac-scrollbar.css";
|
||||||
|
|
||||||
|
type ScrollContainerProps = {
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
forwardedRef?: (ref: HTMLDivElement | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScrollContainer = ({
|
||||||
|
children,
|
||||||
|
forwardedRef,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<ScrollContainerProps>) => {
|
||||||
|
return (
|
||||||
|
<MacScrollbar
|
||||||
|
{...props}
|
||||||
|
ref={(div) => {
|
||||||
|
forwardedRef && forwardedRef(div as HTMLDivElement);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: "100%"
|
||||||
|
}}
|
||||||
|
minThumbSize={40}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MacScrollbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ScrollContainer;
|
||||||
|
|
||||||
|
type FlexScrollContainerProps = {
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FlexScrollContainer({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
className
|
||||||
|
}: PropsWithChildren<FlexScrollContainerProps>) {
|
||||||
|
return (
|
||||||
|
<MacScrollbar className={className} style={style} minThumbSize={40}>
|
||||||
|
{children}
|
||||||
|
</MacScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
extensions/web-clipper/src/components/tag-picker/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Flex, Text } from "@theme-ui/components";
|
||||||
|
import { FilteredList } from "../filtered-list";
|
||||||
|
import { ItemReference } from "../../common/bridge";
|
||||||
|
import { Icon } from "../icons/icon";
|
||||||
|
import { Icons } from "../icons";
|
||||||
|
import { useAppStore } from "../../stores/app-store";
|
||||||
|
import { Picker } from "../picker";
|
||||||
|
|
||||||
|
type TagPickerProps = {
|
||||||
|
selectedTags: string[];
|
||||||
|
onSelected: (tag: string) => void;
|
||||||
|
onDeselected: (tag: string) => void;
|
||||||
|
};
|
||||||
|
export const TagPicker = (props: TagPickerProps) => {
|
||||||
|
const { selectedTags, onSelected, onDeselected } = props;
|
||||||
|
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const tags = useAppStore((s) => s.tags);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
};
|
||||||
|
const open = () => setModalVisible(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
p: 1,
|
||||||
|
pb: 0,
|
||||||
|
borderRadius: "default",
|
||||||
|
flexWrap: "wrap"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedTags.length
|
||||||
|
? selectedTags.map((tag, index) => (
|
||||||
|
<InlineTag
|
||||||
|
key={tag}
|
||||||
|
tag={tag}
|
||||||
|
onDeselected={() => {
|
||||||
|
onDeselected(tag);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
<Flex
|
||||||
|
onClick={open}
|
||||||
|
sx={{
|
||||||
|
bg: "bgSecondary",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "small",
|
||||||
|
mr: 1,
|
||||||
|
px: 1,
|
||||||
|
mb: 1,
|
||||||
|
cursor: "pointer",
|
||||||
|
":hover": {
|
||||||
|
bg: "hover"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Click to assign more tags"
|
||||||
|
>
|
||||||
|
<Icon path={Icons.plus} size={12} color="fontTertiary" />
|
||||||
|
<Text variant="subBody" sx={{ ml: "2px", color: "icon" }}>
|
||||||
|
Assign a tag
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Picker onClose={close} isOpen={modalVisible}>
|
||||||
|
<FilteredList
|
||||||
|
getAll={() => tags}
|
||||||
|
filter={(items, query) =>
|
||||||
|
items.filter((item) => item.title.toLowerCase().indexOf(query) > -1)
|
||||||
|
}
|
||||||
|
itemName="tag"
|
||||||
|
placeholder={"Search for a tag"}
|
||||||
|
onCreateNewItem={(tag) => onSelected(tag)}
|
||||||
|
refreshItems={() => tags}
|
||||||
|
renderItem={(tag, index) => (
|
||||||
|
<Tag
|
||||||
|
tag={tag}
|
||||||
|
onSelected={() => {
|
||||||
|
onSelected(tag.title);
|
||||||
|
}}
|
||||||
|
isSelected={selectedTags.indexOf(tag.title) > -1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Picker>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type TagProps = {
|
||||||
|
tag: ItemReference;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelected: () => void;
|
||||||
|
};
|
||||||
|
function Tag(props: TagProps) {
|
||||||
|
const { tag, onSelected, isSelected } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="list"
|
||||||
|
onClick={onSelected}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
py: "7px",
|
||||||
|
px: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
sx={{
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "var(--paragraph)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{tag.title}
|
||||||
|
</Text>
|
||||||
|
{isSelected ? <Icon path={Icons.check} color="text" size={14} /> : null}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineTag(props: { tag: string; onDeselected: () => void }) {
|
||||||
|
const { tag, onDeselected } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
onClick={onDeselected}
|
||||||
|
sx={{
|
||||||
|
bg: "bgSecondary",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "small",
|
||||||
|
mr: 1,
|
||||||
|
px: 1,
|
||||||
|
mb: 1,
|
||||||
|
cursor: "pointer",
|
||||||
|
":hover": {
|
||||||
|
bg: "hover"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Click to remove"
|
||||||
|
>
|
||||||
|
<Text variant="subBody" sx={{ color: "icon" }}>
|
||||||
|
#{tag}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// import { useStore } from "../../stores/theme-store";
|
||||||
|
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
|
||||||
|
import { getDefaultAccentColor, useTheme } from "@notesnook/theme";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
accent?: string;
|
||||||
|
theme?: "dark" | "light";
|
||||||
|
};
|
||||||
|
|
||||||
|
function ThemeProvider(props: ThemeProviderProps) {
|
||||||
|
const { accent, children, theme } = props;
|
||||||
|
const themeProperties = useTheme({
|
||||||
|
accent: accent || getDefaultAccentColor(),
|
||||||
|
theme: theme || "light"
|
||||||
|
});
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// const root = document.querySelector("html");
|
||||||
|
// if (root) root.setAttribute("data-theme", theme);
|
||||||
|
// }, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmotionThemeProvider theme={themeProperties}>
|
||||||
|
{children}
|
||||||
|
</EmotionThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export { ThemeProvider };
|
||||||
49
extensions/web-clipper/src/content-scripts/all.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { browser } from "webextension-polyfill-ts";
|
||||||
|
import {
|
||||||
|
clipArticle,
|
||||||
|
cleanup,
|
||||||
|
clipPage,
|
||||||
|
clipScreenshot,
|
||||||
|
enterNodeSelectionMode
|
||||||
|
} from "@notesnook/clipper";
|
||||||
|
import { ClipArea, ClipMode } from "../common/bridge";
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((request) => {
|
||||||
|
const message = request as {
|
||||||
|
type?: string;
|
||||||
|
mode?: ClipMode;
|
||||||
|
area?: ClipArea;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.type === "viewport") {
|
||||||
|
return Promise.resolve({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
height: document.body.clientHeight,
|
||||||
|
width: document.body.clientWidth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isScreenshot = message.mode === "screenshot";
|
||||||
|
const withStyles = message.mode === "complete" || isScreenshot;
|
||||||
|
|
||||||
|
if (isScreenshot && message.area === "full-page") {
|
||||||
|
return clipScreenshot(document.body, "jpeg");
|
||||||
|
} else if (message.area === "full-page") {
|
||||||
|
return clipPage(document, withStyles, false);
|
||||||
|
} else if (message.area === "selection") {
|
||||||
|
enterNodeSelectionMode(document).then((result) =>
|
||||||
|
browser.runtime.sendMessage({ type: "manual", data: result })
|
||||||
|
);
|
||||||
|
} else if (message.area === "article") {
|
||||||
|
return clipArticle(document, withStyles);
|
||||||
|
} else if (message.area === "visible") {
|
||||||
|
return clipPage(document, withStyles, true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
if (message.area !== "selection") cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
55
extensions/web-clipper/src/content-scripts/nn.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { browser } from "webextension-polyfill-ts";
|
||||||
|
import { expose, Remote, wrap } from "comlink";
|
||||||
|
import { createEndpoint } from "../utils/comlink-extension";
|
||||||
|
import {
|
||||||
|
Clip,
|
||||||
|
Gateway,
|
||||||
|
Server,
|
||||||
|
WEB_EXTENSION_CHANNEL_EVENTS
|
||||||
|
} from "../common/bridge";
|
||||||
|
|
||||||
|
var mainPort: MessagePort | undefined;
|
||||||
|
|
||||||
|
browser.runtime.onConnect.addListener((port) => {
|
||||||
|
if (mainPort) {
|
||||||
|
const server: Remote<Server> = wrap<Server>(mainPort);
|
||||||
|
expose(
|
||||||
|
{
|
||||||
|
login: () => server.login(),
|
||||||
|
getNotes: () => server.getNotes(),
|
||||||
|
getNotebooks: () => server.getNotebooks(),
|
||||||
|
getTags: () => server.getTags(),
|
||||||
|
saveClip: (clip: Clip) => server.saveClip(clip)
|
||||||
|
},
|
||||||
|
createEndpoint(port)
|
||||||
|
);
|
||||||
|
port.postMessage({ success: true });
|
||||||
|
} else {
|
||||||
|
port.postMessage({ success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("message", (ev) => {
|
||||||
|
const { type } = ev.data;
|
||||||
|
switch (type) {
|
||||||
|
case WEB_EXTENSION_CHANNEL_EVENTS.ON_CREATED:
|
||||||
|
if (ev.ports.length) {
|
||||||
|
const [port] = ev.ports;
|
||||||
|
mainPort = port;
|
||||||
|
expose(new BackgroundGateway(), port);
|
||||||
|
browser.runtime.sendMessage(undefined, { type: "start_connection" });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.postMessage({ type: WEB_EXTENSION_CHANNEL_EVENTS.ON_READY }, "*");
|
||||||
|
|
||||||
|
class BackgroundGateway implements Gateway {
|
||||||
|
connect() {
|
||||||
|
return {
|
||||||
|
name: "Web clipper",
|
||||||
|
id: "unknown-id"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
32
extensions/web-clipper/src/hooks/use-persistent-state.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { config } from "../utils/config";
|
||||||
|
|
||||||
|
export function usePersistentState<T>(key: string, def?: T) {
|
||||||
|
const defState = config.get<T>(key, def);
|
||||||
|
const [value, setValue] = useState(defState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
config.set<T>(key, value || null);
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
6
extensions/web-clipper/src/index.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--background);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
20
extensions/web-clipper/src/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import "../assets/16x16.png";
|
||||||
|
import "../assets/32x32.png";
|
||||||
|
import "../assets/48x48.png";
|
||||||
|
import "../assets/64x64.png";
|
||||||
|
import "../assets/128x128.png";
|
||||||
|
import "../assets/256x256.png";
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { App } from "./app";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
document.getElementById("root")
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (module.hot) module.hot.accept();
|
||||||
1
extensions/web-clipper/src/manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
60
extensions/web-clipper/src/stores/app-store.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import create from "zustand";
|
||||||
|
import {
|
||||||
|
ItemReference,
|
||||||
|
NotebookReference,
|
||||||
|
User
|
||||||
|
} from "../common/bridge";
|
||||||
|
import { connectApi } from "../api";
|
||||||
|
|
||||||
|
interface AppStore {
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
isLoggingIn: boolean;
|
||||||
|
user?: User;
|
||||||
|
notes: ItemReference[];
|
||||||
|
notebooks: NotebookReference[];
|
||||||
|
tags: ItemReference[];
|
||||||
|
|
||||||
|
login(openNew?: boolean): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppStore>((set, get) => ({
|
||||||
|
isLoggedIn: false,
|
||||||
|
isLoggingIn: false,
|
||||||
|
notebooks: [],
|
||||||
|
notes: [],
|
||||||
|
tags: [],
|
||||||
|
|
||||||
|
async login(openNew = false) {
|
||||||
|
set({ isLoggingIn: true });
|
||||||
|
|
||||||
|
const notesnook = await connectApi(openNew, () => {
|
||||||
|
set({
|
||||||
|
user: undefined,
|
||||||
|
isLoggedIn: false,
|
||||||
|
isLoggingIn: false,
|
||||||
|
notes: [],
|
||||||
|
notebooks: [],
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!notesnook) {
|
||||||
|
set({ isLoggingIn: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await notesnook.login();
|
||||||
|
const notes = await notesnook.getNotes();
|
||||||
|
const notebooks = await notesnook.getNotebooks();
|
||||||
|
const tags = await notesnook.getTags();
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: user || undefined,
|
||||||
|
isLoggedIn: true,
|
||||||
|
isLoggingIn: false,
|
||||||
|
notes: notes,
|
||||||
|
notebooks: notebooks,
|
||||||
|
tags: tags
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
205
extensions/web-clipper/src/utils/comlink-extension.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import * as Comlink from "comlink";
|
||||||
|
import { Runtime, browser } from "webextension-polyfill-ts";
|
||||||
|
|
||||||
|
const SYMBOL = "__PORT__@";
|
||||||
|
|
||||||
|
export type Port = Runtime.Port;
|
||||||
|
export type OnPortCallback = (port: Port) => void;
|
||||||
|
|
||||||
|
export type PortResolver = (id: string, onPort: OnPortCallback) => void;
|
||||||
|
export type PortDeserializer = (id: string) => MessagePort;
|
||||||
|
|
||||||
|
function _resolvePort(id: string, onPort: OnPortCallback) {
|
||||||
|
onPort(browser.runtime.connect({ name: id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _deserializePort(id: string) {
|
||||||
|
const { port1, port2 } = new MessageChannel();
|
||||||
|
forward(
|
||||||
|
port1,
|
||||||
|
browser.runtime.connect({ name: id }),
|
||||||
|
_resolvePort,
|
||||||
|
_deserializePort
|
||||||
|
);
|
||||||
|
return port2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEndpoint(
|
||||||
|
port: Port,
|
||||||
|
resolvePort: PortResolver = _resolvePort,
|
||||||
|
deserializePort: PortDeserializer = _deserializePort
|
||||||
|
): Comlink.Endpoint {
|
||||||
|
const listeners = new WeakMap();
|
||||||
|
|
||||||
|
function serialize(data: any): void {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
data.forEach((value, i) => {
|
||||||
|
serialize(value);
|
||||||
|
});
|
||||||
|
} else if (data && typeof data === "object") {
|
||||||
|
if (data instanceof MessagePort) {
|
||||||
|
const id = SYMBOL + `${+new Date()}${Math.random()}`;
|
||||||
|
(data as any)[SYMBOL] = "port";
|
||||||
|
(data as any).port = id;
|
||||||
|
resolvePort(id, (port) =>
|
||||||
|
forward(data, port, resolvePort, deserializePort)
|
||||||
|
);
|
||||||
|
} else if (data instanceof ArrayBuffer) {
|
||||||
|
(data as any)[SYMBOL] =
|
||||||
|
data instanceof Uint8Array
|
||||||
|
? "uint8"
|
||||||
|
: data instanceof Uint16Array
|
||||||
|
? "uint16"
|
||||||
|
: data instanceof Uint32Array
|
||||||
|
? "uint32"
|
||||||
|
: "buffer";
|
||||||
|
|
||||||
|
(data as any).blob = URL.createObjectURL(new Blob([data]));
|
||||||
|
} else {
|
||||||
|
for (const key in data) {
|
||||||
|
serialize(data[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deserialize(data: any, ports: any[]): Promise<any> {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
await Promise.all(
|
||||||
|
data.map(async (value, i) => {
|
||||||
|
data[i] = await deserialize(value, ports);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (data && typeof data === "object") {
|
||||||
|
const type = data[SYMBOL];
|
||||||
|
|
||||||
|
if (type === "port") {
|
||||||
|
const port = deserializePort(data.port);
|
||||||
|
ports.push(port);
|
||||||
|
return port;
|
||||||
|
} else if (type) {
|
||||||
|
const url = new URL(data.blob);
|
||||||
|
if (url.protocol === "blob:") {
|
||||||
|
const buffer = await (await fetch(url.href)).arrayBuffer();
|
||||||
|
switch (type) {
|
||||||
|
case "uint16=":
|
||||||
|
return new Uint16Array(buffer);
|
||||||
|
case "uint8":
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
case "uint32":
|
||||||
|
return new Uint32Array(buffer);
|
||||||
|
case "buffer":
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.keys(data).map(async (key) => {
|
||||||
|
data[key] = await deserialize(data[key], ports);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
postMessage: (message, transfer: MessagePort[]) => {
|
||||||
|
serialize(message);
|
||||||
|
port.postMessage(message);
|
||||||
|
},
|
||||||
|
addEventListener: (_, handler) => {
|
||||||
|
const listener = async (data: any) => {
|
||||||
|
const ports: MessagePort[] = [];
|
||||||
|
const event = new MessageEvent("message", {
|
||||||
|
data: await deserialize(data, ports),
|
||||||
|
ports
|
||||||
|
});
|
||||||
|
|
||||||
|
if ("handleEvent" in handler) {
|
||||||
|
handler.handleEvent(event);
|
||||||
|
} else {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
port.onMessage.addListener(listener);
|
||||||
|
listeners.set(handler, listener);
|
||||||
|
},
|
||||||
|
removeEventListener: (_, handler) => {
|
||||||
|
const listener = listeners.get(handler);
|
||||||
|
if (!listener) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
port.onMessage.removeListener(listener);
|
||||||
|
listeners.delete(handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forward(
|
||||||
|
messagePort: MessagePort,
|
||||||
|
extensionPort: Port,
|
||||||
|
resolvePort: PortResolver = _resolvePort,
|
||||||
|
deserializePort: PortDeserializer = _deserializePort
|
||||||
|
) {
|
||||||
|
const port = createEndpoint(extensionPort, resolvePort, deserializePort);
|
||||||
|
|
||||||
|
messagePort.onmessage = ({ data, ports }) => {
|
||||||
|
port.postMessage(data, ports as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
port.addEventListener("message", ({ data, ports }: any) => {
|
||||||
|
messagePort.postMessage(data, ports as any);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMessagePort(port: { name: string }) {
|
||||||
|
return port.name.startsWith(SYMBOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const portCallbacks = new Map<string, OnPortCallback[]>();
|
||||||
|
const ports = new Map<string, Runtime.Port>();
|
||||||
|
|
||||||
|
function serializePort(id: string, onPort: OnPortCallback) {
|
||||||
|
if (!portCallbacks.has(id)) {
|
||||||
|
portCallbacks.set(id, []);
|
||||||
|
}
|
||||||
|
const callbacks = portCallbacks.get(id)!;
|
||||||
|
callbacks.push(onPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializePort(id: string) {
|
||||||
|
const port = ports.get(id)!;
|
||||||
|
const { port1, port2 } = new MessageChannel();
|
||||||
|
forward(port2, port, serializePort, deserializePort);
|
||||||
|
return port1;
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.runtime.onConnect.addListener((port) => {
|
||||||
|
if (!isMessagePort(port)) return;
|
||||||
|
ports.set(port.name, port);
|
||||||
|
portCallbacks.get(port.name)?.forEach((cb) => cb(port));
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createBackgroundEndpoint(port: Runtime.Port) {
|
||||||
|
return createEndpoint(port, serializePort, deserializePort);
|
||||||
|
}
|
||||||
42
extensions/web-clipper/src/utils/config.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function set<T>(key: string, value: T | null) {
|
||||||
|
if (!value) return window.localStorage.removeItem(key);
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function get<T>(key: string, def?: T): T | undefined {
|
||||||
|
const value = window.localStorage.getItem(key);
|
||||||
|
if (!value) return def;
|
||||||
|
|
||||||
|
return tryParse(value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = { set, get };
|
||||||
|
|
||||||
|
function tryParse<T>(val: string): T | string | undefined {
|
||||||
|
if (val === "undefined" || val === "null") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(val);
|
||||||
|
} catch (e) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
extensions/web-clipper/src/utils/storage.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
export async function storeClip(data: string) {
|
||||||
|
await browser.storage.local.set({ clip: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteClip() {
|
||||||
|
return await browser.storage.local.remove("clip");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClip() {
|
||||||
|
const { clip } = await browser.storage.local.get("clip");
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
58
extensions/web-clipper/src/views/login.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button, Flex, Image, Text } from "@theme-ui/components";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Logo from "../../assets/logo.svg";
|
||||||
|
import { useAppStore } from "../stores/app-store";
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const isLoggingIn = useAppStore((s) => s.isLoggingIn);
|
||||||
|
const login = useAppStore((s) => s.login);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await login().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
setError(e.message);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [login]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexDirection: "column",
|
||||||
|
m: 2,
|
||||||
|
my: 50,
|
||||||
|
width: 300,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image src={Logo} width={64} />
|
||||||
|
<Text variant="heading" sx={{ textAlign: "center", mt: 2 }}>
|
||||||
|
Notesnook Web Clipper
|
||||||
|
</Text>
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<Text variant="body" sx={{ mt: 4 }}>
|
||||||
|
Connecting with Notesnook...
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
sx={{ px: 4, mt: 4, borderRadius: 100 }}
|
||||||
|
onClick={async () =>
|
||||||
|
await login(true).catch((e) => {
|
||||||
|
setError(e.message);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Connect with Notesnook
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Text variant="error" sx={{ mt: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
392
extensions/web-clipper/src/views/main.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { Box, Button, Flex, Input, Text } from "@theme-ui/components";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { browser } from "webextension-polyfill-ts";
|
||||||
|
import { Icons } from "../components/icons";
|
||||||
|
import { Icon } from "../components/icons/icon";
|
||||||
|
import { NotePicker } from "../components/note-picker";
|
||||||
|
import { NotebookPicker } from "../components/notebook-picker";
|
||||||
|
import { TagPicker } from "../components/tag-picker";
|
||||||
|
import {
|
||||||
|
ItemReference,
|
||||||
|
SelectedNotebook,
|
||||||
|
ClipArea,
|
||||||
|
ClipMode
|
||||||
|
} from "../common/bridge";
|
||||||
|
import { usePersistentState } from "../hooks/use-persistent-state";
|
||||||
|
import { deleteClip, getClip } from "../utils/storage";
|
||||||
|
import { useAppStore } from "../stores/app-store";
|
||||||
|
import { connectApi } from "../api";
|
||||||
|
import { FlexScrollContainer } from "../components/scroll-container";
|
||||||
|
|
||||||
|
const clipAreas: { name: string; id: ClipArea; icon: string }[] = [
|
||||||
|
{
|
||||||
|
name: "Full page",
|
||||||
|
id: "full-page",
|
||||||
|
icon: Icons.fullPage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Article",
|
||||||
|
id: "article",
|
||||||
|
icon: Icons.article
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Visible area",
|
||||||
|
id: "visible",
|
||||||
|
icon: Icons.visible
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Selected nodes",
|
||||||
|
id: "selection",
|
||||||
|
icon: Icons.selection
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const clipModes: { name: string; id: ClipMode; icon: string; pro?: boolean }[] =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "Simplified",
|
||||||
|
id: "simplified",
|
||||||
|
icon: Icons.simplified
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Screenshot",
|
||||||
|
id: "screenshot",
|
||||||
|
icon: Icons.screenshot,
|
||||||
|
pro: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complete with styles",
|
||||||
|
id: "complete",
|
||||||
|
icon: Icons.complete,
|
||||||
|
pro: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Main() {
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
// const [colorMode, setColorMode] = useColorMode();
|
||||||
|
|
||||||
|
const isPremium = useAppStore((s) => s.user?.pro);
|
||||||
|
const [title, setTitle] = useState<string>();
|
||||||
|
const [url, setUrl] = useState<string>();
|
||||||
|
const [clipNonce, setClipNonce] = useState(0);
|
||||||
|
const [clipMode, setClipMode] = usePersistentState<ClipMode>(
|
||||||
|
"clipMode",
|
||||||
|
"simplified"
|
||||||
|
);
|
||||||
|
const [clipArea, setClipArea] = usePersistentState<ClipArea>(
|
||||||
|
"clipArea",
|
||||||
|
"article"
|
||||||
|
);
|
||||||
|
const [isClipping, setIsClipping] = useState(false);
|
||||||
|
const [note, setNote] = usePersistentState<ItemReference>("note");
|
||||||
|
const [notebook, setNotebook] =
|
||||||
|
usePersistentState<SelectedNotebook>("notebook");
|
||||||
|
const [tags, setTags] = usePersistentState<string[]>("tags", []);
|
||||||
|
const [clipData, setClipData] = useState<string>();
|
||||||
|
const pageTitle = useRef<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const [tab] = await browser.tabs.query({ active: true });
|
||||||
|
|
||||||
|
setTitle(tab?.title ? tab.title : "Untitled");
|
||||||
|
setUrl(tab?.url);
|
||||||
|
pageTitle.current = tab.title;
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!clipArea || !clipMode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsClipping(true);
|
||||||
|
setClipData(await clip(clipArea, clipMode));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e instanceof Error) setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setIsClipping(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [clipArea, clipMode, clipNonce]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlexScrollContainer style={{ maxHeight: 560 }}>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flexDirection: "column",
|
||||||
|
p: 2,
|
||||||
|
width: 320,
|
||||||
|
backgroundColor: "background"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
sx={{
|
||||||
|
p: "small",
|
||||||
|
fontStyle: "italic"
|
||||||
|
}}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
value={title || ""}
|
||||||
|
placeholder="Untitled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
variant="subtitle"
|
||||||
|
sx={{ mt: 2, mb: 1, color: "icon", fontSize: "body" }}
|
||||||
|
>
|
||||||
|
Clipping area
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{clipAreas.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
variant="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setClipArea(item.id);
|
||||||
|
setClipNonce((s) => ++s);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
borderRadius: "default",
|
||||||
|
textAlign: "left",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
px: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex>
|
||||||
|
<Icon path={item.icon} color="icon" size={16} />
|
||||||
|
<Text variant="text" sx={{ ml: 1 }}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{clipArea === item.id && (
|
||||||
|
<Icon
|
||||||
|
path={isClipping ? Icons.loading : Icons.check}
|
||||||
|
color="text"
|
||||||
|
size={16}
|
||||||
|
rotate={isClipping}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
variant="subtitle"
|
||||||
|
sx={{ mt: 1, mb: 2, color: "icon", fontSize: "body" }}
|
||||||
|
>
|
||||||
|
Clipping mode
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{clipModes.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
variant="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setClipMode(item.id);
|
||||||
|
setClipNonce((s) => ++s);
|
||||||
|
}}
|
||||||
|
disabled={item.pro && !isPremium}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
borderRadius: "default",
|
||||||
|
textAlign: "left",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
px: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex>
|
||||||
|
<Icon path={item.icon} color="icon" size={16} />
|
||||||
|
<Text variant="text" sx={{ ml: 1 }}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{clipMode === item.id && (
|
||||||
|
<Icon
|
||||||
|
path={isClipping ? Icons.loading : Icons.check}
|
||||||
|
color="text"
|
||||||
|
size={16}
|
||||||
|
rotate={isClipping}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{clipData && !isClipping && (
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
bg: "shade",
|
||||||
|
color: "primary",
|
||||||
|
p: 1,
|
||||||
|
border: "1px solid var(--primary)",
|
||||||
|
borderRadius: "default",
|
||||||
|
cursor: "pointer",
|
||||||
|
":hover": {
|
||||||
|
filter: "brightness(80%)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!clipData) return;
|
||||||
|
const winUrl = URL.createObjectURL(
|
||||||
|
new Blob(["\ufeff", clipData], { type: "text/html" })
|
||||||
|
);
|
||||||
|
await browser.windows.create({
|
||||||
|
url: winUrl
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clip done. Click here to preview.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text
|
||||||
|
variant="text"
|
||||||
|
sx={{
|
||||||
|
mt: 1,
|
||||||
|
bg: "errorBg",
|
||||||
|
color: "error",
|
||||||
|
p: 1,
|
||||||
|
border: "1px solid var(--error)",
|
||||||
|
borderRadius: "default",
|
||||||
|
cursor: "pointer",
|
||||||
|
":hover": {
|
||||||
|
filter: "brightness(80%)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={async () => {
|
||||||
|
setClipNonce((s) => ++s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
<br />
|
||||||
|
Click here to retry.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
variant="subtitle"
|
||||||
|
sx={{ mt: 1, mb: 2, color: "icon", fontSize: "body" }}
|
||||||
|
>
|
||||||
|
Organization
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{notebook || tags?.length ? null : (
|
||||||
|
<NotePicker
|
||||||
|
selectedNote={note}
|
||||||
|
onSelected={(note) => setNote(note)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{note ? null : (
|
||||||
|
<>
|
||||||
|
{notebook || tags?.length ? null : (
|
||||||
|
<Text variant="subBody" sx={{ my: 1, textAlign: "center" }}>
|
||||||
|
— or —
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<NotebookPicker
|
||||||
|
selectedNotebook={notebook}
|
||||||
|
onSelected={(notebook) => setNotebook(notebook)}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 1 }} />
|
||||||
|
<TagPicker
|
||||||
|
selectedTags={tags || []}
|
||||||
|
onDeselected={(tag) => {
|
||||||
|
setTags((tags) => {
|
||||||
|
const copy = tags?.slice() || [];
|
||||||
|
copy.splice(copy.indexOf(tag), 1);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelected={(tag) => {
|
||||||
|
setTags((tags) => {
|
||||||
|
const copy = tags?.slice() || [];
|
||||||
|
if (copy.indexOf(tag) > -1) {
|
||||||
|
copy.splice(copy.indexOf(tag), 1);
|
||||||
|
} else copy.push(tag);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
disabled={!clipData}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!clipData || !title || !clipArea || !clipMode || !url) return;
|
||||||
|
|
||||||
|
const notesnook = await connectApi(false);
|
||||||
|
if (!notesnook) {
|
||||||
|
setError("You are not connected to Notesnook.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await notesnook.saveClip({
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
data: clipData,
|
||||||
|
area: clipArea,
|
||||||
|
mode: clipMode,
|
||||||
|
tags,
|
||||||
|
note,
|
||||||
|
notebook,
|
||||||
|
pageTitle: pageTitle.current
|
||||||
|
});
|
||||||
|
|
||||||
|
setClipData(undefined);
|
||||||
|
window.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="text">Save clip</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
pt: 1,
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
justifyContent: "flex-end"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="icon" sx={{ p: 1 }}>
|
||||||
|
<Icon path={Icons.settings} color="text" size={16} />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</FlexScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clip(
|
||||||
|
area: ClipArea,
|
||||||
|
mode: ClipMode
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const clipData = await getClip();
|
||||||
|
if (area === "selection" && typeof clipData === "string") {
|
||||||
|
await deleteClip();
|
||||||
|
return clipData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tab] = await browser.tabs.query({ active: true });
|
||||||
|
if (!tab || !tab.id) return;
|
||||||
|
|
||||||
|
if (area === "visible" && mode === "screenshot") {
|
||||||
|
if (!tab.height || !tab.width) return;
|
||||||
|
|
||||||
|
const result = await browser.tabs.captureVisibleTab(undefined, {
|
||||||
|
format: "jpeg",
|
||||||
|
quality: 100
|
||||||
|
});
|
||||||
|
return `<img src="${result}" width="${tab.width}px" height="${tab.height}px"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await browser.tabs.sendMessage(tab.id, { mode, area });
|
||||||
|
}
|
||||||
1
extensions/web-clipper/src/views/settings.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const s = "";
|
||||||
12
extensions/web-clipper/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"maxNodeModuleJsDepth": 5,
|
||||||
|
"declaration": false,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "global.d.ts"]
|
||||||
|
}
|
||||||
207
extensions/web-clipper/webpack.config.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
const webpack = require("webpack");
|
||||||
|
const path = require("path");
|
||||||
|
const fileSystem = require("fs-extra");
|
||||||
|
const env = require("./build-utils/env");
|
||||||
|
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
const TerserPlugin = require("terser-webpack-plugin");
|
||||||
|
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||||
|
const { v2, v3 } = require("./build-utils/manifest");
|
||||||
|
|
||||||
|
const ASSET_PATH = process.env.ASSET_PATH || "./public";
|
||||||
|
const MANIFEST_VERSION = process.env.MANIFEST_VERSION || "2";
|
||||||
|
|
||||||
|
var alias = {
|
||||||
|
"react-dom": "@hot-loader/react-dom"
|
||||||
|
};
|
||||||
|
|
||||||
|
// load the secrets
|
||||||
|
var secretsPath = path.join(__dirname, "secrets." + env.NODE_ENV + ".js");
|
||||||
|
|
||||||
|
var fileExtensions = [
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"gif",
|
||||||
|
"eot",
|
||||||
|
"otf",
|
||||||
|
"svg",
|
||||||
|
"ttf",
|
||||||
|
"woff",
|
||||||
|
"woff2"
|
||||||
|
];
|
||||||
|
|
||||||
|
if (fileSystem.existsSync(secretsPath)) {
|
||||||
|
alias["secrets"] = secretsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
mode: process.env.NODE_ENV || "development",
|
||||||
|
entry: {
|
||||||
|
// newtab: path.join(__dirname, "src", "pages", "Newtab", "index.jsx"),
|
||||||
|
// options: path.join(__dirname, "src", "options.tsx"),
|
||||||
|
popup: path.join(__dirname, "src", "index.tsx"),
|
||||||
|
background: path.join(__dirname, "src", "background.ts"),
|
||||||
|
contentScript: path.join(__dirname, "src", "content-scripts", "all.ts"),
|
||||||
|
nnContentScript: path.join(__dirname, "src", "content-scripts", "nn.ts")
|
||||||
|
// mobile: path.join(__dirname, "src", "views", "Mobile", "index.ts"),
|
||||||
|
// index: path.join(__dirname, "src", "index.ts")
|
||||||
|
// devtools: path.join(__dirname, "src", "pages", "Devtools", "index.js"),
|
||||||
|
/// panel: path.join(__dirname, "src", "pages", "Panel", "index.jsx"),
|
||||||
|
},
|
||||||
|
chromeExtensionBoilerplate: {
|
||||||
|
// notHotReload: ["contentScript", "devtools"],
|
||||||
|
notHotReload: ["nnContentScript", "contentScript", "background"]
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, "build"),
|
||||||
|
filename: "[name].bundle.js",
|
||||||
|
clean: true,
|
||||||
|
publicPath: ASSET_PATH
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
// look for .css or .scss files
|
||||||
|
test: /\.(css|scss)$/,
|
||||||
|
// in the `src` directory
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: "style-loader"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: "css-loader"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: "sass-loader",
|
||||||
|
options: {
|
||||||
|
sourceMap: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: new RegExp(".(" + fileExtensions.join("|") + ")$"),
|
||||||
|
loader: "file-loader",
|
||||||
|
options: {
|
||||||
|
name: "[name].[ext]"
|
||||||
|
},
|
||||||
|
exclude: /node_modules/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.html$/,
|
||||||
|
loader: "html-loader",
|
||||||
|
exclude: /node_modules/
|
||||||
|
},
|
||||||
|
{ test: /\.(ts|tsx)$/, loader: "ts-loader", exclude: /node_modules/ },
|
||||||
|
{
|
||||||
|
test: /\.(js|jsx)$/,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: "source-map-loader"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loader: "babel-loader"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
exclude: /node_modules/
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: alias,
|
||||||
|
extensions: fileExtensions
|
||||||
|
.map((extension) => "." + extension)
|
||||||
|
.concat([".js", ".jsx", ".ts", ".tsx", ".css"])
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CleanWebpackPlugin({ verbose: false }),
|
||||||
|
new webpack.ProgressPlugin(),
|
||||||
|
// expose and write the allowed env vars on the compiled bundle
|
||||||
|
new webpack.EnvironmentPlugin(["NODE_ENV"]),
|
||||||
|
new CopyWebpackPlugin({
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
from: "src/manifest.json",
|
||||||
|
to: path.join(__dirname, "build"),
|
||||||
|
force: true,
|
||||||
|
transform: function (content, path) {
|
||||||
|
// generates the manifest file using the package.json informations
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify(MANIFEST_VERSION === "2" ? v2 : v3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
new CopyWebpackPlugin({
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
from: "src/index.css",
|
||||||
|
to: path.join(__dirname, "build"),
|
||||||
|
force: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
// new HtmlWebpackPlugin({
|
||||||
|
// template: path.join(__dirname, "src", "pages", "Newtab", "index.html"),
|
||||||
|
// filename: "newtab.html",
|
||||||
|
// chunks: ["newtab"],
|
||||||
|
// cache: false,
|
||||||
|
// }),
|
||||||
|
// new HtmlWebpackPlugin({
|
||||||
|
// template: path.join(__dirname, "src", "pages", "Options", "index.html"),
|
||||||
|
// filename: "options.html",
|
||||||
|
// chunks: ["options"],
|
||||||
|
// cache: false,
|
||||||
|
// }),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: path.join(__dirname, "public", "index.html"),
|
||||||
|
filename: "popup.html",
|
||||||
|
chunks: ["popup"],
|
||||||
|
cache: false
|
||||||
|
})
|
||||||
|
// new HtmlWebpackPlugin({
|
||||||
|
// template: path.join(
|
||||||
|
// __dirname,
|
||||||
|
// "src",
|
||||||
|
// "views",
|
||||||
|
// "Background",
|
||||||
|
// "index.html"
|
||||||
|
// ),
|
||||||
|
// filename: "background.html",
|
||||||
|
// chunks: ["background"],
|
||||||
|
// cache: false,
|
||||||
|
// }),
|
||||||
|
// new HtmlWebpackPlugin({
|
||||||
|
// template: path.join(__dirname, "src", "pages", "Devtools", "index.html"),
|
||||||
|
// filename: "devtools.html",
|
||||||
|
// chunks: ["devtools"],
|
||||||
|
// cache: false,
|
||||||
|
// }),
|
||||||
|
// new HtmlWebpackPlugin({
|
||||||
|
// template: path.join(__dirname, "src", "pages", "Panel", "index.html"),
|
||||||
|
// filename: "panel.html",
|
||||||
|
// chunks: ["panel"],
|
||||||
|
// cache: false,
|
||||||
|
// }),
|
||||||
|
],
|
||||||
|
infrastructureLogging: {
|
||||||
|
level: "info"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (env.NODE_ENV === "development") {
|
||||||
|
options.devtool = "cheap-module-source-map";
|
||||||
|
} else {
|
||||||
|
options.optimization = {
|
||||||
|
minimize: true,
|
||||||
|
minimizer: [
|
||||||
|
new TerserPlugin({
|
||||||
|
extractComments: false
|
||||||
|
})
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = options;
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"useNx": true,
|
"useNx": true,
|
||||||
"useWorkspaces": false,
|
"useWorkspaces": false,
|
||||||
"version": "independent",
|
"version": "independent",
|
||||||
"packages": ["packages/*", "apps/*"]
|
"packages": ["packages/*", "apps/*", "extensions/*"]
|
||||||
}
|
}
|
||||||
|
|||||||