clipper(web): add web clipper browser extension

This commit is contained in:
Abdullah Atta
2022-11-28 15:12:18 +05:00
parent ad91da9f2d
commit f7cc2d61b6
47 changed files with 39005 additions and 1 deletions

1
extensions/web-clipper/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
build.pem

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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

View 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;
});

View 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();
})();

View 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,
};

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}
}

View 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>

View 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;
}

View 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>
);
}

View 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;
}
});

View 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;

View 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/*"];

View 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>
</>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
};

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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 };

View 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();
}
});

View 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"
};
}
}

View 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;
}

View File

@@ -0,0 +1,6 @@
body {
margin: 0;
padding: 0;
background-color: var(--background);
overflow: hidden;
}

View 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();

View File

@@ -0,0 +1 @@
{}

View 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
});
}
}));

View 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);
}

View 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;
}
}

View 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;
}

View 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>
);
}

View 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 });
}

View File

@@ -0,0 +1 @@
export const s = "";

View 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"]
}

View 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;

View File

@@ -3,5 +3,5 @@
"useNx": true,
"useWorkspaces": false,
"version": "independent",
"packages": ["packages/*", "apps/*"]
"packages": ["packages/*", "apps/*", "extensions/*"]
}