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,
|
||||
"useWorkspaces": false,
|
||||
"version": "independent",
|
||||
"packages": ["packages/*", "apps/*"]
|
||||
"packages": ["packages/*", "apps/*", "extensions/*"]
|
||||
}
|
||||
|
||||