Compare commits

...

12 Commits

Author SHA1 Message Date
Abdullah Atta
8f35d36a2a clipper(web): remove sqlite files 2025-11-25 14:19:35 +05:00
Abdullah Atta
873ba49e32 clipper(web): initial commit to make web cilpper work independently 2025-11-25 14:07:54 +05:00
Abdullah Atta
9f88eaae77 web: bump version to 3.3.6-beta.0 2025-11-24 14:21:04 +05:00
01zulfi
704ad578fa editor: unhide children when collapsed heading is changed to non-heading node (#8946)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 14:20:02 +05:00
01zulfi
cf608977e1 editor: improve table cell styling (#8960)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 14:18:13 +05:00
01zulfi
bdd78df452 editor: change heading collapse icon pos (#8953)
* editor: enable heading in table, change collapse icon pos, && disable empty heading collapse
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: migrate empty collapsed headings in parseHTML instead of plugin
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: remove migration for empty collapsed headings
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: fix heading collapse on mobile
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: use ontouchend instead of ontouchstart

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2025-11-24 10:49:43 +05:00
01zulfi
cec05b6dfc web: allow archiving when notes drag-n-dropped on nav item
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
01zulfi
3e4779e6bf editor: fade checklist when checked
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

Update packages/editor/styles/styles.css

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
2025-11-24 10:46:21 +05:00
01zulfi
9ebcbb7bd8 web: exclude empty paragraphs in editor stats
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
01zulfi
f3f8020929 editor: disable collapsible headings inside tables
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
01zulfi
43186d28e7 core: fix user keys re-encryption when updating password
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2025-11-24 10:46:21 +05:00
Abdullah Atta
d9d6a40276 Merge pull request #8930 from streetwriters/feat/improve-tables-on-mobile
Improve tables on mobile
2025-11-12 11:23:55 +05:00
37 changed files with 4859 additions and 2340 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/desktop",
"version": "3.3.5",
"version": "3.3.6-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.3.5",
"version": "3.3.6-beta.0",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.3.5",
"version": "3.3.6-beta.0",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
{
"name": "@notesnook/web",
"description": "Your private note taking space",
"version": "3.3.5",
"version": "3.3.6-beta.0",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",
"repository": "https://github.com/streetwriters/notesnook",
"license": "GPL-3.0-or-later",
@@ -30,7 +29,6 @@
"@notesnook/theme": "file:../../packages/theme",
"@notesnook/themes-server": "file:../../servers/themes",
"@notesnook/ui": "file:../../packages/ui",
"@notesnook/web-clipper": "file:../../extensions/web-clipper",
"@paddle/paddle-js": "^1.4.2",
"@react-pdf-viewer/core": "^3.12.0",
"@react-pdf-viewer/toolbar": "^3.12.0",

View File

@@ -30,13 +30,18 @@ export async function handleDrop(
item:
| ItemReference
| Context
| { type: "trash" | "notebooks" | "favorites" | undefined }
| { type: "trash" | "notebooks" | "favorites" | "archive" | undefined }
) {
if (!item.type) return;
const noteIds = getDragData(dataTransfer, "note");
const notebookIds = getDragData(dataTransfer, "notebook");
const { setColor, favorite, delete: trashNotes } = useNoteStore.getState();
const {
setColor,
favorite,
delete: trashNotes,
archive
} = useNoteStore.getState();
switch (item.type) {
case "notebook":
if (noteIds.length > 0) {
@@ -83,5 +88,8 @@ export async function handleDrop(
await useNoteStore.getState().refresh();
}
break;
case "archive":
archive(true, ...noteIds);
break;
}
}

View File

@@ -118,7 +118,7 @@ function countCharacters(text: string) {
function countParagraphs(fragment: Fragment) {
let count = 0;
fragment.nodesBetween(0, fragment.size, (node) => {
if (node.type.name === "paragraph") {
if (node.type.name === "paragraph" && node.content.size > 0) {
count++;
}
return true;
@@ -737,7 +737,7 @@ function toIEditor(editor: Editor): IEditor {
function getSelectedParagraphs(editor: Editor, selection: Selection): number {
let count = 0;
editor.state.doc.nodesBetween(selection.from, selection.to, (node) => {
if (node.type.name === "paragraph") {
if (node.type.name === "paragraph" && node.content.size > 0) {
count++;
}
return true;

View File

@@ -506,6 +506,8 @@ function RouteItem({
? "trash"
: item.path === "/favorites"
? "favorites"
: item.path == "/archive"
? "archive"
: undefined
});
}}

View File

@@ -91,18 +91,7 @@ const features: Record<FeatureKeys, Feature> = {
)
}
]
: [
{
title: "Notesnook Circle",
subtitle:
"Notesnook Circle brings together trusted partners who share our commitment to privacy, transparency, and user freedom. As a member you get exclusive discounts and offers from our partners. Check it out in Settings > Notesnook Circle."
},
{
title: "Collapsible headings",
subtitle:
"You can now collapse and expand headings in your notes. This makes it easier to manage large notes and focus on specific sections."
}
],
: [],
cta: {
title: strings.gotIt(),
icon: Checkmark,

View File

@@ -2,7 +2,6 @@
"extends": "../../tsconfig",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"noEmit": true

View File

@@ -19,18 +19,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
const { version } = require("../package.json");
const ICONS = {
16: "16x16.png",
32: "32x32.png",
48: "48x48.png",
64: "64x64.png",
128: "128x128.png",
256: "256x256.png"
16: "assets/16x16.png",
32: "assets/32x32.png",
48: "assets/48x48.png",
64: "assets/64x64.png",
128: "assets/128x128.png",
256: "assets/256x256.png"
};
const BACKGROUND_SCRIPT = "background.bundle.js";
const ACTION = {
default_icon: ICONS,
default_title: "Notesnook Web Clipper",
default_popup: "popup.html"
default_popup: "public/index.html"
};
const nnHost =
@@ -60,8 +59,20 @@ const v2 = {
},
manifest_version: 2,
background: {
scripts: [BACKGROUND_SCRIPT]
scripts: ["src/background.ts"]
},
content_scripts: [
{
matches: ["<all_urls>"],
js: ["src/content-scripts/all.ts"],
run_at: "document_end"
},
{
matches: [nnHost, v3nnHost],
js: ["src/content-scripts/nn.ts"],
run_at: "document_end"
}
],
browser_action: ACTION
};
@@ -72,12 +83,37 @@ const v3 = {
optional_host_permissions: ["http://*/*", "https://*/*"],
manifest_version: 3,
background: {
service_worker: BACKGROUND_SCRIPT
service_worker: "src/background.ts",
type: "module"
},
content_scripts: [
{
matches: ["<all_urls>"],
js: ["src/content-scripts/all.ts"]
},
{
matches: [nnHost],
js: ["src/content-scripts/nn.ts"]
}
],
content_security_policy: {
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
},
web_accessible_resources: [
{
resources: ["*.wasm"],
matches: ["<all_urls>"]
}
],
action: ACTION
};
function getManifest(version) {
return version === "2" ? v2 : v3;
}
module.exports = {
v2,
v3
v3,
getManifest
};

View File

@@ -0,0 +1,107 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 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 packageJson from "../package.json";
const { version } = packageJson;
const ICONS = {
"16": "assets/16x16.png",
"32": "assets/32x32.png",
"48": "assets/48x48.png",
"64": "assets/64x64.png",
"128": "assets/128x128.png",
"256": "assets/256x256.png"
};
const ACTION = {
default_icon: ICONS,
default_title: "Notesnook Web Clipper",
default_popup: "index.html"
};
const nnHost =
process.env.NODE_ENV === "production"
? "*://app.notesnook.com/*"
: "*://localhost/*";
const corsHost = "https://cors.notesnook.com/*";
const common = {
name: "Notesnook Web Clipper",
version,
description:
"Clip web pages & save interesting things you find on the web directly into Notesnook in a private & secure way.",
permissions: ["activeTab", "tabs", "storage", "notifications"],
icons: ICONS
};
const v2 = {
...common,
permissions: [...common.permissions, corsHost, nnHost],
optional_permissions: ["http://*/*", "https://*/*"],
browser_specific_settings: {
gecko: {
id: "notesnook-web-clipper-unlisted@notesnook.com",
strict_min_version: "105.0"
}
},
manifest_version: 2,
background: {
scripts: ["src/background.ts"]
},
content_scripts: [
{
matches: ["<all_urls>"],
js: ["src/content-scripts/all.ts"],
run_at: "document_end"
}
],
browser_action: ACTION
};
const v3 = {
...common,
permissions: [...common.permissions, "scripting"],
host_permissions: [corsHost, nnHost],
optional_host_permissions: ["http://*/*", "https://*/*"],
manifest_version: 3,
background: {
service_worker: "src/background.ts",
type: "module"
},
content_scripts: [
{
matches: ["<all_urls>"],
js: ["src/content-scripts/all.ts"]
}
],
content_security_policy: {
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
},
web_accessible_resources: [
{
resources: ["*.wasm"],
matches: ["<all_urls>"]
}
],
action: ACTION
};
export function getManifest(version: string) {
return version === "2" ? v2 : v3;
}
export { v2, v3 };

View File

@@ -16,6 +16,9 @@ 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 "vite/client";
declare module "*.svg" {
import React = require("react");
export const ReactComponent: React.SFC<React.SVGProps<SVGSVGElement>>;

View File

@@ -10,5 +10,6 @@
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -2,40 +2,27 @@
"name": "@notesnook/web-clipper",
"version": "0.4.0",
"private": true,
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
},
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./*": {
"require": {
"types": "./dist/types/*",
"default": "./dist/cjs/*"
},
"import": {
"types": "./dist/types/*",
"default": "./dist/esm/*"
}
}
},
"dependencies": {
"@emotion/react": "11.11.1",
"@mdi/js": "7.4.47",
"@mdi/react": "1.6.1",
"@lingui/core": "5.1.2",
"@lingui/react": "5.1.2",
"@notesnook/clipper": "file:../../packages/clipper",
"@notesnook/common": "file:../../packages/common",
"@notesnook/core": "file:../../packages/core",
"@notesnook/crypto": "file:../../packages/crypto",
"@notesnook/logger": "file:../../packages/logger",
"@notesnook/streamable-fs": "file:../../packages/streamable-fs",
"@notesnook/theme": "file:../../packages/theme",
"@notesnook/web": "file:../../apps/web",
"@notesnook/intl": "file:../../packages/intl",
"@streetwriters/kysely": "^0.27.4",
"@theme-ui/components": "0.16.1",
"@theme-ui/core": "0.16.1",
"async-mutex": "^0.5.0",
"comlink": "^4.3.1",
"event-source-polyfill": "^1.0.31",
"hyperapp": "^2.0.22",
"mac-scrollbar": "0.13.6",
"react": "18.3.1",
@@ -45,14 +32,15 @@
"react-scripts": "5.0.0",
"svg-react-loader": "^0.4.6",
"webextension-polyfill-ts": "^0.26.0",
"zustand": "4.5.5"
"zustand": "^4.5.7",
"zustand-mutative": "^1.3.1",
"dayjs": "1.11.13"
},
"scripts": {
"build:firefox": "cross-env MANIFEST_VERSION=2 node build-utils/build.js",
"build:chrome": "cross-env MANIFEST_VERSION=3 node build-utils/build.js",
"dev:chrome": "cross-env MANIFEST_VERSION=3 node build-utils/dev.js",
"dev:firefox": "cross-env MANIFEST_VERSION=2 node build-utils/dev.js",
"build": "node ../../scripts/build.mjs"
"build:firefox": "cross-env MANIFEST_VERSION=2 NODE_ENV=production vite build",
"build:chrome": "cross-env MANIFEST_VERSION=3 NODE_ENV=production vite build",
"dev:chrome": "cross-env MANIFEST_VERSION=3 vite",
"dev:firefox": "cross-env MANIFEST_VERSION=2 vite"
},
"browserslist": {
"production": [
@@ -67,11 +55,10 @@
]
},
"devDependencies": {
"@babel/core": "7.22.5",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@crxjs/vite-plugin": "^2.0.0-beta.25",
"@emotion/babel-plugin": "^11.11.0",
"@types/chrome": "^0.0.180",
"@types/event-source-polyfill": "1.0.5",
"@types/firefox-webext-browser": "^94.0.1",
"@types/inline-css": "^3.0.1",
"@types/jest": "^26.0.14",
@@ -81,12 +68,8 @@
"@types/react-modal": "3.16.3",
"@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",
"@vitejs/plugin-react": "^4.3.4",
"cross-env": "^7.0.3",
"eslint": "^8.8.0",
"eslint-config-react-app": "^7.0.0",
"eslint-plugin-flowtype": "^8.0.3",
@@ -94,21 +77,10 @@
"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": "^9.0.0",
"prettier": "^2.5.1",
"sass-loader": "^13.2.0",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.1",
"ts-loader": "^9.2.6",
"web-ext": "^7.12.0",
"webpack": "5.88.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "^4.7.4"
"vite": "^5.4.11",
"web-ext": "^7.12.0"
},
"license": "GPL-3.0-or-later",
"author": {

View File

@@ -1,112 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 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 { browser, Runtime, Tabs } 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";
type WebExtensionChannelMessage = { success: boolean };
let api: Remote<Server> | undefined;
export async function connectApi(openNew = false, onDisconnect?: () => void) {
if (api) return api;
const tabs = await findNotesnookTabs(openNew);
for (const tab of tabs) {
try {
const api = await Promise.race([
connectToTab(tab, onDisconnect),
timeout(5000)
]);
if (!api) continue;
return api as Remote<Server>;
} catch (e) {
console.error(e);
}
}
return false;
}
function timeout(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms, false));
}
async function connectToTab(tab: Tabs.Tab, onDisconnect?: () => void) {
if (!tab.id) return false;
if (browser.scripting) {
await browser.scripting.executeScript({
files: ["nnContentScript.bundle.js"],
target: { tabId: tab.id }
});
} else {
await browser.tabs.executeScript(tab.id, {
file: "nnContentScript.bundle.js"
});
}
const port = browser.tabs.connect(tab.id);
port.onDisconnect.addListener(() => {
api = undefined;
onDisconnect?.();
});
return new Promise<Remote<Server> | false>(function connect(resolve) {
async function onMessage(
message: WebExtensionChannelMessage,
port: Runtime.Port
) {
if (message.success) {
port.onMessage.removeListener(onMessage);
api = wrap<Server>(createEndpoint(port));
resolve(api);
} else {
resolve(false);
}
}
port.onMessage.addListener(onMessage);
});
}
export async function findNotesnookTabs(openNew = false) {
const tabs = await browser.tabs.query({
url: APP_URL_FILTER,
discarded: false,
status: "complete"
});
if (tabs.length) return tabs;
if (openNew) {
const tab = await browser.tabs.create({ url: APP_URL, active: false });
await new Promise<void>((resolve) =>
browser.tabs.onUpdated.addListener(function onUpdated(id, info) {
if (id === tab.id && info.status === "complete") {
browser.tabs.onUpdated.removeListener(onUpdated);
resolve();
}
})
);
return [tab];
}
return [];
}

View File

@@ -27,27 +27,35 @@ import {
useThemeEngineStore
} from "@notesnook/theme";
import { Global, css } from "@emotion/react";
import { useStore as useUserStore } from "@notesnook/web/src/stores/user-store";
import AppEffects from "@notesnook/web/src/app-effects";
export function App() {
const isLoggedIn = useAppStore((s) => s.isLoggedIn);
const user = useAppStore((s) => s.user);
// const isLoggedIn = useUserStore((store) => store.isLoggedIn);
// const isLoggedIn = useAppStore((s) => s.isLoggedIn);
// const user = useAppStore((s) => s.user);
const route = useAppStore((s) => s.route);
const navigate = useAppStore((s) => s.navigate);
const theme = useThemeEngineStore((store) => store.theme);
useEffect(() => {
if (!isLoggedIn) {
navigate("/login");
} else navigate("/");
}, [isLoggedIn]);
useEffect(() => {
if (user && user.theme) {
document.body.style.backgroundColor =
user.theme.scopes.base.primary.background;
useThemeEngineStore.getState().setTheme(user.theme);
async function main() {
await useUserStore.getState().init();
const { isLoggedIn } = useUserStore.getState();
if (!isLoggedIn) {
navigate("/login");
} else navigate("/");
}
}, [user]);
main();
}, []);
// useEffect(() => {
// if (user && user.theme) {
// document.body.style.backgroundColor =
// user.theme.scopes.base.primary.background;
// useThemeEngineStore.getState().setTheme(user.theme);
// }
// }, [user]);
const cssTheme = useMemo(() => themeToCSS(theme), [theme]);
return (
@@ -57,6 +65,7 @@ export function App() {
${cssTheme}
`}
/>
<AppEffects />
<EmotionThemeProvider scope="base" injectCssVars>
{(() => {
switch (route) {

View File

@@ -17,17 +17,10 @@ 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 { ThemeDefinition } from "@notesnook/theme";
export type ClipArea = "full-page" | "visible" | "selection" | "article";
export type ClipMode = "bookmark" | "simplified" | "screenshot" | "complete";
export type User = {
email?: string;
pro: boolean;
theme: ThemeDefinition;
};
export type ItemReference = {
id: string;
title: string;
@@ -35,15 +28,6 @@ export type ItemReference = {
export type NotebookReference = ItemReference;
export type ClientMetadata = {
id: string;
name: string;
};
export interface Gateway {
connect(): ClientMetadata;
}
type SelectedNotebookReference = ItemReference & {
type: "notebook";
};
@@ -55,27 +39,6 @@ export type SelectedReference =
| SelectedTagReference
| SelectedNotebookReference;
export type Clip = {
url: string;
title: string;
data: string;
area: ClipArea;
mode: ClipMode;
width?: number;
height?: number;
pageTitle?: string;
note?: ItemReference;
refs?: SelectedReference[];
};
export interface Server {
login(): Promise<User | null>;
getNotes(): Promise<ItemReference[] | undefined>;
getNotebooks(parentId?: string): 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"

View File

@@ -0,0 +1,135 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 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 { EventSourcePolyfill as EventSource } from "event-source-polyfill";
import { database, getFeature, getFeatureLimit } from "@notesnook/common";
import { createDialect } from "@notesnook/web/src/common/sqlite";
import { isFeatureSupported } from "@notesnook/web/src/utils/feature-check";
import {
deriveKey,
useKeyStore
} from "@notesnook/web/src/interfaces/key-store";
import {
DatabasePersistence,
NNStorage
} from "@notesnook/web/src/interfaces/storage";
import { generatePassword } from "@notesnook/web/src/utils/password-generator";
const db = database;
async function initializeDatabase(persistence: DatabasePersistence) {
db.host({
API_HOST: "https://api.notesnook.com",
AUTH_HOST: "https://auth.streetwriters.co",
SSE_HOST: "https://events.streetwriters.co",
ISSUES_HOST: "https://issues.streetwriters.co",
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
MONOGRAPH_HOST: "https://monogr.ph",
NOTESNOOK_HOST: "https://notesnook.com"
});
await useKeyStore.getState().init();
let databaseKey = await useKeyStore.getState().getValue("databaseKey");
if (!databaseKey) {
databaseKey = await deriveKey(generatePassword());
await useKeyStore.getState().setValue("databaseKey", databaseKey);
}
const storage = new NNStorage(
"Notesnook",
() => useKeyStore.getState(),
persistence
);
await storage.migrate();
const multiTab = !!globalThis.SharedWorker && isFeatureSupported("opfs");
database.setup({
sqliteOptions: {
dialect: (name, init) =>
createDialect({
name: persistence === "memory" ? ":memory:" : name,
encrypted: true,
async: !isFeatureSupported("opfs"),
init,
multiTab
}),
...(isFeatureSupported("opfs")
? { journalMode: "WAL", lockingMode: "exclusive" }
: {
journalMode: "MEMORY",
lockingMode: "normal"
}),
tempStore: "memory",
synchronous: "normal",
pageSize: 8192,
cacheSize: -32000,
password: Buffer.from(databaseKey).toString("hex"),
skipInitialization: multiTab
},
storage: storage,
eventsource: EventSource,
// @ts-ignore
fs: {},
compressor: () =>
import("@notesnook/web/src/utils/compressor").then(
({ Compressor }) => new Compressor()
),
maxNoteVersions: async () => {
const limit = await getFeatureLimit(getFeature("maxNoteVersions"));
return typeof limit.caption === "number" ? limit.caption : undefined;
},
batchSize: 100
});
// if (IS_TESTING) {
// } else {
// db.host({
// API_HOST: "http://localhost:5264",
// AUTH_HOST: "http://localhost:8264",
// SSE_HOST: "http://localhost:7264",
// });
// const base = `http://localhost`;
// db.host({
// API_HOST: `${base}:5264`,
// AUTH_HOST: `${base}:8264`,
// SSE_HOST: `${base}:7264`,
// ISSUES_HOST: `${base}:2624`,
// SUBSCRIPTIONS_HOST: `${base}:9264`
// });
// }
await db.init();
// window.addEventListener("beforeunload", async () => {
// if (IS_DESKTOP_APP) {
// await db.sql().destroy();
// await logManager?.close();
// }
// });
// if (db.migrations?.required()) {
// await import("../dialogs/migration-dialog").then(({ MigrationDialog }) =>
// MigrationDialog.show({})
// );
// }
return db;
}
export { db, initializeDatabase };

View File

@@ -43,7 +43,8 @@ import {
mdiCircleOutline,
mdiBookOutline,
mdiBookmarkOutline,
mdiPound
mdiPound,
mdiSync
} from "@mdi/js";
export const Icons = {
@@ -78,6 +79,7 @@ export const Icons = {
notebook: mdiBookOutline,
topic: mdiBookmarkOutline,
tag: mdiPound,
sync: mdiSync,
none: "",
back: mdiArrowLeft

View File

@@ -16,15 +16,18 @@ 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 { useState } from "react";
import { useEffect, 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";
import { CheckListItem } from "../check-list-item";
import { FilteredList } from "@notesnook/web/src/components/filtered-list";
import { Note, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { db } from "../../common/db";
import { ResolvedItem } from "@notesnook/common";
type NotePickerProps = {
selectedNote?: ItemReference;
@@ -34,8 +37,20 @@ export const NotePicker = (props: NotePickerProps) => {
const { selectedNote, onSelected } = props;
const [modalVisible, setModalVisible] = useState(false);
const notes = useAppStore((s) => s.notes);
const [notes, setNotes] = useState<VirtualizedGrouping<Note> | undefined>();
useEffect(() => {
(async function () {
if (!notes) {
setNotes(
await db.notes.all.grouped(db.settings.getGroupOptions("notes"))
);
}
})();
}, []);
console.log(notes);
const close = () => {
setModalVisible(false);
};
@@ -97,25 +112,51 @@ export const NotePicker = (props: NotePickerProps) => {
</Flex>
<Picker onClose={close} onDone={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) => (
<CheckListItem
title={note.title}
onSelected={() => {
onSelected(note);
close();
}}
isSelected={selectedNote?.id === note.id}
/>
)}
/>
{notes && (
<FilteredList
getItemKey={(index) => notes.key(index)}
mode="fixed"
estimatedSize={30}
items={notes.placeholders}
sx={{ mt: 2 }}
itemGap={5}
placeholders={{
empty: strings.notesEmpty(),
filter: strings.searchANote()
}}
filter={async (query) => {
setNotes(
query
? await db.lookup.notes(query).sorted()
: await db.notes.all.grouped(
db.settings.getGroupOptions("notes")
)
);
}}
onCreateNewItem={async () => {}}
renderItem={({ index }) => {
console.log("Rendering note at index:", index);
return (
<ResolvedItem
key={index}
type="note"
items={notes}
index={index}
>
{({ item }) => (
<CheckListItem
title={item.title}
onSelected={() => {
onSelected({ id: item.id, title: item.title });
}}
isSelected={selectedNote?.id === item.id}
/>
)}
</ResolvedItem>
);
}}
/>
)}
</Picker>
</>
);

View File

@@ -16,15 +16,25 @@ 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 { useState } from "react";
import { Flex } from "@theme-ui/components";
import { FilteredList } from "../filtered-list";
import { useEffect, useRef, useState } from "react";
import { Button, Flex, Text } from "@theme-ui/components";
import { NotebookReference, SelectedReference } from "../../common/bridge";
import { Icons } from "../icons";
import { useAppStore } from "../../stores/app-store";
import { Picker } from "../picker";
import { InlineTag } from "../inline-tag";
import { CheckListItem } from "../check-list-item";
import { FilteredList } from "@notesnook/web/src/components/filtered-list";
import Field from "@notesnook/web/src/components/field";
import { strings } from "@notesnook/intl";
import { db } from "../../common/db";
import {
TreeNode,
VirtualizedTree,
VirtualizedTreeHandle
} from "@notesnook/web/src/components/virtualized-tree";
import { NotebookItem } from "@notesnook/web/src/dialogs/move-note-dialog";
import { Notebook } from "@notesnook/core";
type NotebookPickerProps = {
selectedItems: SelectedReference[];
@@ -33,10 +43,18 @@ type NotebookPickerProps = {
export const NotebookPicker = (props: NotebookPickerProps) => {
const { onSelected } = props;
const treeRef = useRef<VirtualizedTreeHandle<Notebook>>(null);
const [modalVisible, setModalVisible] = useState(false);
const [selectedItems, setSelectedItems] = useState<SelectedReference[]>(
props.selectedItems
);
const [notebooks, setNotebooks] = useState<string[]>([]);
useEffect(() => {
db.notebooks.roots
.ids(db.settings.getGroupOptions("notebooks"))
.then((ids) => setNotebooks(ids));
}, []);
const close = () => {
setModalVisible(false);
@@ -90,92 +108,121 @@ export const NotebookPicker = (props: NotebookPickerProps) => {
}}
isOpen={modalVisible}
>
<FilteredList
getAll={() => useAppStore.getState().notebooks}
filter={(items, query) =>
items.filter((item) => item.title.toLowerCase().indexOf(query) > -1)
}
itemName="notebook"
placeholder={"Search for a notebook"}
refreshItems={() => useAppStore.getState().notebooks}
renderItem={(item) => (
<Notebook
notebook={item}
isSelected={
!!selectedItems.find(
(n) => n.id === item.id && n.type === "notebook"
)
}
onSelected={(ref) => {
setSelectedItems((items) => {
const copy = items.slice();
const index = copy.findIndex(
(n) => n.id === ref.id && n.type === ref.type
);
if (index > -1) {
copy.splice(index, 1);
} else {
copy.push(ref);
<Flex
id="subnotebooks"
variant="columnFill"
sx={{
height: "80vh"
}}
>
<Field
autoFocus
sx={{ m: 0, mb: 2 }}
styles={{
input: { p: "7.5px" }
}}
placeholder={strings.searchNotebooks()}
onChange={async (e) => {
const query = e.target.value.trim();
const ids = await (query
? db.lookup.notebooks(query).ids()
: db.notebooks.roots.ids(
db.settings.getGroupOptions("notebooks")
));
setNotebooks(ids);
}}
/>
{notebooks.length > 0 ? (
<>
<VirtualizedTree
rootId={"root"}
itemHeight={30}
treeRef={treeRef}
getChildNodes={async ({ id, depth }) => {
const nodes: TreeNode<Notebook>[] = [];
if (id === "root") {
for (const id of notebooks) {
const notebook = (await db.notebooks.notebook(id))!;
const children = await db.relations
.from(notebook, "notebook")
.count();
nodes.push({
data: notebook,
depth: depth + 1,
hasChildren: children > 0,
id,
parentId: "root"
});
}
return nodes;
}
return copy;
});
const subNotebooks = await db.relations
.from({ type: "notebook", id }, "notebook")
.resolve();
for (const notebook of subNotebooks) {
const hasChildren =
(await db.relations.from(notebook, "notebook").count()) >
0;
nodes.push({
parentId: id,
id: notebook.id,
data: notebook,
depth: depth + 1,
hasChildren
});
}
return nodes;
}}
renderItem={({ item, expanded, index, collapse, expand }) => (
<NotebookItem
notebook={item.data}
depth={item.depth}
isExpandable={item.hasChildren}
isExpanded={expanded}
toggle={expanded ? collapse : expand}
onCreateItem={() => {
treeRef.current?.refreshItem(index, item.data, {
expand: true
});
}}
/>
)}
/>
</>
) : (
<Flex
sx={{
my: 2,
flexDirection: "column",
justifyContent: "center",
alignItems: "center"
}}
/>
>
<Text variant="body">{strings.notebooksEmpty()}</Text>
<Button
data-test-id="add-new-notebook"
variant="secondary"
sx={{ mt: 2 }}
onClick={
() => {}
// AddNotebookDialog.show({}).then((res) =>
// res
// ? db.notebooks.roots
// .ids(db.settings.getGroupOptions("notebooks"))
// .then((ids) => setNotebooks(ids))
// : null
// )
}
>
{strings.addNotebook()}
</Button>
</Flex>
)}
/>
</Flex>
</Picker>
</>
);
};
type NotebookProps = {
notebook: NotebookReference;
isSelected: boolean;
onSelected: (notebook: SelectedReference) => void;
};
function Notebook(props: NotebookProps) {
const { notebook, isSelected, onSelected } = props;
return (
<Flex
sx={{
flexDirection: "column",
overflow: "hidden"
}}
>
<CheckListItem
title={notebook.title}
isSelected={isSelected}
onSelected={() => {
onSelected({
id: notebook.id,
title: notebook.title,
type: "notebook"
});
}}
/>
{/* <FilteredList
getAll={() => notebook.topics}
itemName="topic"
placeholder={"Search for a topic"}
refreshItems={() => notebook.topics}
renderItem={(topic) => (
<CheckListItem
title={topic.title}
isSelected={isTopicSelected(topic)}
indentLevel={1}
onSelected={() => {
onSelected({
id: topic.id,
title: topic.title,
type: "topic",
parentId: notebook.id
});
}}
/>
)}
/> */}
</Flex>
);
}

View File

@@ -16,15 +16,23 @@ 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 { useState } from "react";
import { Flex } from "@theme-ui/components";
import { FilteredList } from "../filtered-list";
import { useEffect, useState } from "react";
import { Flex, Text } from "@theme-ui/components";
import { Icons } from "../icons";
import { useAppStore } from "../../stores/app-store";
import { Picker } from "../picker";
import { InlineTag } from "../inline-tag";
import { CheckListItem } from "../check-list-item";
import { SelectedReference } from "../../common/bridge";
import { FilteredList } from "@notesnook/web/src/components/filtered-list";
import { Tag, VirtualizedGrouping } from "@notesnook/core";
import { db } from "../../common/db";
import { strings } from "@notesnook/intl";
import { checkFeature } from "@notesnook/web/src/common";
import {
SelectedCheck,
selectMultiple,
useSelectionStore
} from "@notesnook/web/src/dialogs/move-note-dialog";
import { ResolvedItem } from "@notesnook/common";
type TagPickerProps = {
selectedTags: SelectedReference[];
@@ -34,6 +42,24 @@ export const TagPicker = (props: TagPickerProps) => {
const { selectedTags, onSelected } = props;
const [modalVisible, setModalVisible] = useState(false);
const [tags, setTags] = useState<VirtualizedGrouping<Tag> | undefined>();
useEffect(() => {
(async function () {
if (!tags) {
setTags(await db.tags.all.grouped(db.settings.getGroupOptions("tags")));
return;
}
useSelectionStore.getState().setSelected(
selectedTags.map((t) => ({
id: t.id,
new: false,
op: "add"
}))
);
})();
}, []);
const close = () => {
setModalVisible(false);
@@ -87,35 +113,78 @@ export const TagPicker = (props: TagPickerProps) => {
}}
isOpen={modalVisible}
>
<FilteredList
getAll={() => useAppStore.getState().tags}
filter={(items, query) =>
items.filter((item) => item.title.toLowerCase().indexOf(query) > -1)
}
itemName="tag"
placeholder={"Search for a tag"}
refreshItems={() => useAppStore.getState().tags}
renderItem={(tag) => (
<CheckListItem
title={`#${tag.title}`}
onSelected={() => {
const copy = selectedTags.slice();
const index = copy.findIndex(
(c) => c.type === "tag" && c.id === tag.id
);
if (index <= -1) copy.push({ ...tag, type: "tag" });
else copy.splice(index, 1);
onSelected(copy);
}}
isSelected={
selectedTags.findIndex(
(s) => s.type === "tag" && s.id === tag.id
) > -1
}
/>
)}
/>
{tags && (
<FilteredList
getItemKey={(index) => tags.key(index)}
mode="fixed"
estimatedSize={30}
items={tags.placeholders}
sx={{ mt: 2 }}
itemGap={5}
placeholders={{
empty: strings.addATag(),
filter: strings.searchForTags()
}}
filter={async (query) => {
setTags(
query
? await db.lookup.tags(query).sorted()
: await db.tags.all.grouped(
db.settings.getGroupOptions("tags")
)
);
}}
onCreateNewItem={async (title) => {
if (!(await checkFeature("tags", { type: "toast" }))) return;
const tagId = await db.tags.add({ title });
if (!tagId) return;
setTags(
await db.tags.all.grouped(db.settings.getGroupOptions("tags"))
);
const { selected, setSelected } = useSelectionStore.getState();
setSelected([...selected, { id: tagId, new: true, op: "add" }]);
}}
renderItem={({ index }) => {
return (
<ResolvedItem key={index} type="tag" items={tags} index={index}>
{({ item }) => <TagItem tag={item} />}
</ResolvedItem>
);
}}
/>
)}
</Picker>
</>
);
};
function TagItem(props: { tag: Tag }) {
const { tag } = props;
return (
<Flex
as="li"
data-test-id="tag"
sx={{
cursor: "pointer",
justifyContent: "space-between",
alignItems: "center",
bg: "var(--background-secondary)",
borderRadius: "default",
p: 1
}}
onClick={() => {
const { selected, setSelected } = useSelectionStore.getState();
setSelected(selectMultiple(tag, selected));
}}
>
<Flex sx={{ alignItems: "center" }}>
<SelectedCheck size={18} item={tag} />
<Text className="title" data-test-id="tag-title" variant="body">
#{tag.title}
</Text>
</Flex>
</Flex>
);
}

View File

@@ -1,80 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 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 { 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";
declare global {
// eslint-disable-next-line no-var
var clipperBridgeConnected: boolean;
}
function attachOnConnectListener() {
if (globalThis.clipperBridgeConnected) return;
globalThis.clipperBridgeConnected = true;
browser.runtime.onConnect.addListener((port) => {
window.addEventListener("message", (ev) => {
const { type } = ev.data;
switch (type) {
case WEB_EXTENSION_CHANNEL_EVENTS.ON_CREATED:
if (ev.ports.length) {
const mainPort = ev.ports.at(0);
if (mainPort) {
expose(new BackgroundGateway(), 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 });
}
}
break;
}
});
window.postMessage({ type: WEB_EXTENSION_CHANNEL_EVENTS.ON_READY }, "*");
});
}
class BackgroundGateway implements Gateway {
connect() {
return {
name: "Web clipper",
id: "unknown-id"
};
}
}
attachOnConnectListener();

View File

@@ -16,24 +16,35 @@ 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 "../assets/16x16.png";
import "../assets/32x32.png";
import "../assets/48x48.png";
import "../assets/64x64.png";
import "../assets/128x128.png";
import "../assets/256x256.png";
import "./polyfills";
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./index.css";
import { initializeFeatureChecks } from "@notesnook/web/src/utils/feature-check";
import { initializeDatabase } from "./common/db";
import { i18n } from "@lingui/core";
import { setI18nGlobal, Messages } from "@notesnook/intl";
declare let module: NodeModule & {
hot?: { accept: () => void };
};
const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
if (module.hot) module.hot.accept();
const locale = !import.meta.env.DEV
? import("@notesnook/intl/locales/$pseudo-LOCALE.json")
: import("@notesnook/intl/locales/$en.json");
locale.then(({ default: locale }) => {
i18n.load({
en: locale.messages as unknown as Messages
});
i18n.activate("en");
const root = createRoot(document.getElementById("root")!);
initializeFeatureChecks().then(() =>
initializeDatabase("db").then(() => {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
})
);
});
setI18nGlobal(i18n);

View File

@@ -0,0 +1,21 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 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 { Buffer } from "buffer";
window.Buffer = Buffer;

View File

@@ -17,67 +17,15 @@ 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 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[];
route: string;
login(openNew?: boolean): Promise<void>;
navigate(route: string): void;
}
export const useAppStore = create<AppStore>((set) => ({
isLoggedIn: false,
isLoggingIn: false,
notebooks: [],
notes: [],
tags: [],
route: "/login",
navigate(route) {
set({ route });
},
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 });
throw new Error(
"Please refresh the Notesnook web app to connect with the Web Clipper."
);
}
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

@@ -19,21 +19,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Button, Flex, Image, Text } from "@theme-ui/components";
import { useEffect, useState } from "react";
import Logo from "../../assets/logo.svg";
import { useStore as useUserStore } from "@notesnook/web/src/stores/user-store";
import Field from "@notesnook/web/src/components/field";
import { HeadlessAuth } from "@notesnook/web/src/views/auth";
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]);
const isLoggingIn = useUserStore((store) => store.isLoggingIn);
const navigate = useAppStore((s) => s.navigate);
return (
<Flex
@@ -41,37 +35,33 @@ export function Login() {
flexDirection: "column",
m: 2,
my: 50,
width: 300,
alignItems: "center",
justifyContent: "center"
width: 300
}}
>
<Image src={Logo} width={64} />
<Text variant="heading" sx={{ textAlign: "center", mt: 2 }}>
Notesnook Web Clipper
</Text>
{isLoggingIn ? (
<Image src={Logo} width={50} sx={{ alignSelf: "center", mb: 4 }} />
<HeadlessAuth
route="login:email"
isolated
openURL={(url, context) => {
if (context?.authenticated) {
navigate("/");
}
}}
/>
{/* {isLoggingIn ? (
<Text variant="body" sx={{ mt: 4 }}>
Connecting with Notesnook...
Logging you in...
</Text>
) : (
<Button
variant="accent"
sx={{ px: 4, mt: 4, borderRadius: 100 }}
onClick={async () =>
await login(true).catch((e) => {
setError(e.message);
})
}
>
Connect with Notesnook
</Button>
<>
<Field />
</>
)}
{error && (
<Text variant="error" sx={{ mt: 2 }}>
{error}
</Text>
)}
)} */}
</Flex>
);
}

View File

@@ -34,10 +34,11 @@ import {
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";
import { DEFAULT_SETTINGS, SETTINGS_KEY } from "./settings";
import type { Config } from "@notesnook/clipper/dist/types";
import { useStore as useNNAppStore } from "@notesnook/web/src/stores/app-store";
import { db } from "../common/db";
const ERROR_MAP: Record<string, string> = {
"Could not establish connection. Receiving end does not exist.":
@@ -110,9 +111,10 @@ export function Main() {
const [error, setError] = useState<string>();
// const [colorMode, setColorMode] = useColorMode();
const isPremium = useAppStore((s) => s.user?.pro);
const navigate = useAppStore((s) => s.navigate);
const sync = useNNAppStore((store) => store.sync);
const syncStatus = useNNAppStore((store) => store.syncStatus);
const [settings] = usePersistentState<Config>(SETTINGS_KEY, DEFAULT_SETTINGS);
const [title, setTitle] = useState<string>();
const [hasPermission, setHasPermission] = useState<boolean>(false);
@@ -125,7 +127,7 @@ export function Main() {
"clipArea",
"article"
);
const [note, setNote] = usePersistentState<ItemReference>("note");
const [note, setNote] = usePersistentState<ItemReference | undefined>("note");
const [refs, setRefs] = usePersistentState<SelectedReference[]>("refs", []);
const [clipData, setClipData] = useState<ClipData>();
const [clipperState, setClipperState] = useState<ClipperState>(
@@ -149,15 +151,12 @@ export function Main() {
useEffect(() => {
(async () => {
if (
!isPremium &&
(clipMode === "complete" || clipMode === "screenshot")
) {
if (clipMode === "complete" || clipMode === "screenshot") {
setClipMode("simplified");
return;
}
})();
}, [isPremium, clipArea, clipMode]);
}, [clipArea, clipMode]);
useEffect(() => {
(async () => {
@@ -180,8 +179,8 @@ export function Main() {
await clip(clipArea, clipMode, {
...DEFAULT_SETTINGS,
...settings,
images: isPremium,
inlineImages: isPremium
images: true,
inlineImages: true
})
);
setClipperState(ClipperState.Clipped);
@@ -320,11 +319,7 @@ export function Main() {
setClipperState(ClipperState.Idle);
setClipMode(item.id);
}}
disabled={
isClipping ||
clipperState === ClipperState.Clipped ||
(item.pro && !isPremium)
}
disabled={isClipping || clipperState === ClipperState.Clipped}
sx={{
display: "flex",
borderRadius: "default",
@@ -451,7 +446,7 @@ export function Main() {
)}
<NotebookPicker
selectedItems={refs?.filter((r) => r.type === "notebook") || []}
onSelected={(items) => setRefs(items)}
onSelected={(items) => setRefs(items || [])}
/>
<Box sx={{ mt: 1 }} />
<TagPicker
@@ -485,21 +480,21 @@ export function Main() {
if (!data) return;
const notesnook = await connectApi(false);
if (!notesnook) {
setError("You are not connected to Notesnook.");
return;
}
await notesnook.saveClip({
url,
title,
area: clipArea,
mode: clipMode,
note,
refs,
pageTitle: pageTitle.current,
...data
});
// const notesnook = await connectApi(false);
// if (!notesnook) {
// setError("You are not connected to Notesnook.");
// return;
// }
// await notesnook.saveClip({
// url,
// title,
// area: clipArea,
// mode: clipMode,
// note,
// refs,
// pageTitle: pageTitle.current,
// ...data
// });
setClipData(undefined);
@@ -536,6 +531,21 @@ export function Main() {
>
<Icon path={Icons.settings} size={16} />
</Button>
<Button
variant="icon"
sx={{ p: 1 }}
onClick={async () => {
console.log(await db.user.getUser());
await sync();
}}
>
<Icon
path={Icons.sync}
rotate={syncStatus.key === "syncing"}
size={16}
/>
{syncStatus.progress ? <Text>(${syncStatus.progress})</Text> : null}
</Button>
</Flex>
</Flex>
</FlexScrollContainer>

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react-jsx",
"outDir": "./dist"
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src", "global.d.ts"]
"include": ["src", "global.d.ts", "build-utils"]
}

View File

@@ -0,0 +1,93 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 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 { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { crx } from "@crxjs/vite-plugin";
import path from "path";
import { getManifest } from "./build-utils/manifest.js";
import { version } from "./package.json";
import { execSync } from "child_process";
const MANIFEST_VERSION = process.env.MANIFEST_VERSION || "2";
const gitHash = (() => {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch (e) {
return process.env.GIT_HASH || "gitless";
}
})();
export default defineConfig({
plugins: [
react({
jsxImportSource: "@emotion/react",
babel: {
plugins: ["@emotion/babel-plugin"]
}
}),
crx({ manifest: getManifest(MANIFEST_VERSION) })
],
define: {
APP_TITLE: `"Notesnook Web Clipper"`,
GIT_HASH: `"${gitHash}"`,
APP_VERSION: `"${version}"`,
IS_DESKTOP_APP: false,
PLATFORM: `"${process.env.PLATFORM}"`,
IS_TESTING: false,
IS_BETA: false,
"process.env.NODE_ENV": JSON.stringify(
process.env.NODE_ENV || "development"
)
},
resolve: {
dedupe: [
"react",
"react-dom",
"@mdi/js",
"@mdi/react",
"@emotion/react",
"react-modal",
"dayjs",
"@streetwriters/kysely"
]
},
build: {
outDir: "build",
emptyOutDir: true,
target: "esnext"
},
worker: {
format: "es",
rollupOptions: {
output: {
assetFileNames: "assets/[name]-[hash:12][extname]",
chunkFileNames: "assets/[name]-[hash:12].js",
inlineDynamicImports: true
}
}
},
server: {
port: 3333,
strictPort: true,
hmr: {
port: 3333
}
}
});

View File

@@ -82,9 +82,13 @@ var options = {
},
module: {
rules: [
{
test: /\.wasm$/,
type: "asset/resource" // ensures wasm is emitted as a file
},
{
// look for .css or .scss files
test: /\.(css|scss)$/,
test: /\.(css)$/,
// in the `src` directory
use: [
{
@@ -92,12 +96,6 @@ var options = {
},
{
loader: "css-loader"
},
{
loader: "sass-loader",
options: {
sourceMap: true
}
}
]
},
@@ -133,7 +131,11 @@ var options = {
alias: alias,
extensions: fileExtensions
.map((extension) => "." + extension)
.concat([".js", ".jsx", ".ts", ".tsx", ".css"])
.concat([".js", ".jsx", ".ts", ".tsx", ".css"]),
fallback: {
crypto: false,
url: false
}
},
plugins: [
new CleanWebpackPlugin({

View File

@@ -666,6 +666,15 @@ class UserManager {
usesFallback: await this.usesFallbackPWHash(old_password)
});
// retrieve user keys before deriving a new encryption key
const oldUserKeys = {
attachmentsKey: await this.getAttachmentsKey(),
monographPasswordsKey: await this.getMonographPasswordsKey(),
inboxKeys: (await this.hasInboxKeys())
? await this.getInboxKeys()
: undefined
} as const;
await this.db.storage().deriveCryptoKey({
password: new_password,
salt
@@ -678,27 +687,33 @@ class UserManager {
const userEncryptionKey = await this.getEncryptionKey();
if (userEncryptionKey) {
const updateUserPayload: Partial<User> = {};
const attachmentsKey = await this.getAttachmentsKey();
if (attachmentsKey) {
if (oldUserKeys.attachmentsKey) {
user.attachmentsKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(attachmentsKey));
.encrypt(
userEncryptionKey,
JSON.stringify(oldUserKeys.attachmentsKey)
);
updateUserPayload.attachmentsKey = user.attachmentsKey;
}
const monographPasswordsKey = await this.getMonographPasswordsKey();
if (monographPasswordsKey) {
if (oldUserKeys.monographPasswordsKey) {
user.monographPasswordsKey = await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(monographPasswordsKey));
.encrypt(
userEncryptionKey,
JSON.stringify(oldUserKeys.monographPasswordsKey)
);
updateUserPayload.monographPasswordsKey = user.monographPasswordsKey;
}
const inboxKeys = await this.getInboxKeys();
if (inboxKeys) {
if (oldUserKeys.inboxKeys) {
user.inboxKeys = {
public: inboxKeys.publicKey,
public: oldUserKeys.inboxKeys.publicKey,
private: await this.db
.storage()
.encrypt(userEncryptionKey, JSON.stringify(inboxKeys.privateKey))
.encrypt(
userEncryptionKey,
JSON.stringify(oldUserKeys.inboxKeys.privateKey)
)
};
updateUserPayload.inboxKeys = user.inboxKeys;
}

View File

@@ -3,3 +3,13 @@
exports[`collapse heading > heading collapsed 1`] = `"<h1 data-collapsed="true">Main Heading</h1><p>paragraph.</p><h2>Subheading</h2><p>subheading paragraph</p><h1>Main heading 2</h1><p>paragraph another</p>"`;
exports[`collapse heading > heading uncollapsed 1`] = `"<h1>Main Heading</h1><p>paragraph.</p><h2>Subheading</h2><p>subheading paragraph</p><h1>Main heading 2</h1><p>paragraph another</p>"`;
exports[`replacing collapsed heading with another heading level should not unhide content 1`] = `"<h2 data-collapsed="true">A collapsed heading</h2><p data-hidden="true">Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (blockquote) should unhide content 1`] = `"<blockquote><h1 data-collapsed="true">A collpased heading</h1></blockquote><p>Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (bulletList) should unhide content 1`] = `"<ul><li><p>A collpased heading</p></li></ul><p>Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (codeBlock) should unhide content 1`] = `"<pre><code>A collpased heading</code></pre><p>Hidden paragraph</p>"`;
exports[`replacing collapsed heading with another node (paragraph) should unhide content 1`] = `"<p>A collpased heading</p><p>Hidden paragraph</p>"`;

View File

@@ -18,8 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect } from "vitest";
import { createEditor } from "../../../../test-utils/index.js";
import { createEditor, h } from "../../../../test-utils/index.js";
import { Heading } from "../heading.js";
import { Editor } from "@tiptap/core";
test("collapse heading", () => {
const { editor } = createEditor({
@@ -52,3 +53,59 @@ test("collapse heading", () => {
expect(editor.getHTML()).toMatchSnapshot("heading uncollapsed");
});
test("replacing collapsed heading with another heading level should not unhide content", () => {
const el = h("div", [
h("h1", ["A collapsed heading"], { "data-collapsed": "true" }),
h("p", ["Hidden paragraph"], { "data-hidden": "true" })
]);
const { editor } = createEditor({
extensions: {
heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] })
},
initialContent: el.outerHTML
});
editor.commands.setTextSelection(0);
editor.commands.setHeading({ level: 2 });
expect(editor.getHTML()).toMatchSnapshot();
});
const nodes: { name: string; setNode: (editor: Editor) => void }[] = [
{
name: "paragraph",
setNode: (editor) => editor.commands.setParagraph()
},
{
name: "codeBlock",
setNode: (editor) => editor.commands.setCodeBlock()
},
{
name: "bulletList",
setNode: (editor) => editor.commands.toggleList("bulletList", "listItem")
},
{
name: "blockquote",
setNode: (editor) => editor.commands.toggleBlockquote()
}
];
for (const { name, setNode } of nodes) {
test(`replacing collapsed heading with another node (${name}) should unhide content`, () => {
const el = h("div", [
h("h1", ["A collpased heading"], { "data-collapsed": "true" }),
h("p", ["Hidden paragraph"], { "data-hidden": "true" })
]);
const { editor } = createEditor({
extensions: {
heading: Heading.configure({ levels: [1, 2, 3, 4, 5, 6] })
},
initialContent: el.outerHTML
});
editor.commands.setTextSelection(0);
setNode(editor);
expect(editor.getHTML()).toMatchSnapshot();
});
}

View File

@@ -23,10 +23,8 @@ import {
textblockTypeInputRule
} from "@tiptap/core";
import { Heading as TiptapHeading } from "@tiptap/extension-heading";
import { isClickWithinBounds } from "../../utils/prosemirror.js";
import { Plugin, PluginKey, Selection, Transaction } from "@tiptap/pm/state";
import { Node } from "@tiptap/pm/model";
import { useToolbarStore } from "../../toolbar/stores/toolbar-store.js";
const COLLAPSIBLE_BLOCK_TYPES = [
"paragraph",
@@ -168,6 +166,14 @@ export const Heading = TiptapHeading.extend({
addNodeView() {
return ({ node, getPos, editor, HTMLAttributes }) => {
const heading = document.createElement(`h${node.attrs.level}`);
const contentWrapper = document.createElement("div");
const icon = document.createElement("span");
// providing a minWidth so that empty headings show the blinking cursor
contentWrapper.style.minWidth = "1px";
icon.className = "heading-collapse-icon";
icon.contentEditable = "false";
for (const attr in HTMLAttributes) {
heading.setAttribute(attr, HTMLAttributes[attr]);
@@ -176,50 +182,46 @@ export const Heading = TiptapHeading.extend({
if (node.attrs.collapsed) heading.dataset.collapsed = "true";
else delete heading.dataset.collapsed;
function onClick(e: MouseEvent | TouchEvent) {
if (e instanceof MouseEvent && e.button !== 0) return;
if (!(e.target instanceof HTMLHeadingElement)) return;
function onIconClick(e: MouseEvent | TouchEvent) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const pos = typeof getPos === "function" ? getPos() : 0;
if (typeof pos !== "number") return;
const resolvedPos = editor.state.doc.resolve(pos);
const calloutAncestor = findParentNodeClosestToPos(
resolvedPos,
(node) => node.type.name === "callout"
);
if (calloutAncestor) return;
const forbiddenParents = ["callout"];
if (
isClickWithinBounds(
e,
resolvedPos,
useToolbarStore.getState().isMobile ? "right" : "left"
findParentNodeClosestToPos(resolvedPos, (node) =>
forbiddenParents.includes(node.type.name)
)
) {
e.preventDefault();
e.stopImmediatePropagation();
editor.commands.command(({ tr }) => {
const currentNode = tr.doc.nodeAt(pos);
if (currentNode && currentNode.type.name === "heading") {
const shouldCollapse = !currentNode.attrs.collapsed;
const headingLevel = currentNode.attrs.level;
tr.setNodeAttribute(pos, "collapsed", shouldCollapse);
toggleNodesUnderHeading(tr, pos, headingLevel, shouldCollapse);
}
return true;
});
return;
}
editor.commands.command(({ tr }) => {
const currentNode = tr.doc.nodeAt(pos);
if (currentNode && currentNode.type.name === "heading") {
const shouldCollapse = !currentNode.attrs.collapsed;
const headingLevel = currentNode.attrs.level;
tr.setNodeAttribute(pos, "collapsed", shouldCollapse);
toggleNodesUnderPos(tr, pos, headingLevel, shouldCollapse);
}
return true;
});
}
heading.onmousedown = onClick;
heading.ontouchstart = onClick;
icon.onmousedown = onIconClick;
icon.ontouchend = onIconClick;
heading.appendChild(contentWrapper);
heading.appendChild(icon);
return {
dom: heading,
contentDOM: heading,
contentDOM: contentWrapper,
update: (updatedNode) => {
if (updatedNode.type !== this.type) {
return false;
@@ -253,17 +255,17 @@ export const Heading = TiptapHeading.extend({
}
});
function toggleNodesUnderHeading(
function toggleNodesUnderPos(
tr: Transaction,
headingPos: number,
pos: number,
headingLevel: number,
isCollapsing: boolean
) {
const { doc } = tr;
const headingNode = doc.nodeAt(headingPos);
if (!headingNode || headingNode.type.name !== "heading") return;
const node = doc.nodeAt(pos);
if (!node) return;
let nextPos = headingPos + headingNode.nodeSize;
let nextPos = pos + node.nodeSize;
const cursorPos = tr.selection.from;
let shouldMoveCursor = false;
let insideCollapsedHeading = false;
@@ -318,8 +320,8 @@ function toggleNodesUnderHeading(
}
if (shouldMoveCursor) {
const headingEndPos = headingPos + headingNode.nodeSize - 1;
tr.setSelection(Selection.near(tr.doc.resolve(headingEndPos)));
const endPos = pos + node.nodeSize - 1;
tr.setSelection(Selection.near(tr.doc.resolve(endPos)));
}
}
@@ -364,26 +366,27 @@ const headingUpdatePlugin = new Plugin({
let modified = false;
newDoc.descendants((newNode, pos) => {
if (newNode.type.name === "heading") {
if (pos >= oldDoc.content.size) return;
if (pos >= oldDoc.content.size) return;
const oldNode = oldDoc.nodeAt(pos);
if (
oldNode &&
oldNode.type.name === "heading" &&
oldNode.attrs.level !== newNode.attrs.level
) {
/**
* if the level of a collapsed heading is changed,
* we need to reset visibility of all the nodes under it as there
* might be a heading of same or higher level previously
* hidden under this heading
*/
if (newNode.attrs.collapsed) {
toggleNodesUnderHeading(tr, pos, oldNode.attrs.level, false);
toggleNodesUnderHeading(tr, pos, newNode.attrs.level, true);
modified = true;
}
const oldNode = oldDoc.nodeAt(pos);
if (
oldNode &&
oldNode.type.name === "heading" &&
oldNode.attrs.level !== newNode.attrs.level
) {
/**
* if the level of a collapsed heading is changed,
* we need to reset visibility of all the nodes under it as there
* might be a heading of same or higher level previously
* hidden under this heading
*/
if (newNode.type.name === "heading" && newNode.attrs.collapsed) {
toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false);
toggleNodesUnderPos(tr, pos, newNode.attrs.level, true);
modified = true;
} else if (newNode.type.name !== "heading" && oldNode.attrs.collapsed) {
toggleNodesUnderPos(tr, pos, oldNode.attrs.level, false);
modified = true;
}
}
});

View File

@@ -316,6 +316,31 @@ img.ProseMirror-separator {
.ProseMirror table p {
margin: 0;
}
.ProseMirror td > h1:first-child,
.ProseMirror td > h2:first-child,
.ProseMirror td > h3:first-child,
.ProseMirror td > h4:first-child,
.ProseMirror td > h5:first-child,
.ProseMirror td > h6:first-child {
margin-top: 0;
}
.ProseMirror td > ol,
.ProseMirror td > ul {
padding-left: 20px;
margin-top: 0;
}
.ProseMirror td > blockquote {
margin-left: 0;
margin-top: 0;
}
.ProseMirror td > blockquote > :first-child {
margin-top: 0;
}
/*
.resize-cursor {
@@ -739,6 +764,10 @@ p > *::selection {
mask-size: cover;
}
.simple-checklist > li.checked p {
opacity: 0.8;
}
/* Callout */
.ProseMirror div.callout {
padding: 15px;
@@ -866,150 +895,133 @@ del.diffdel {
text-decoration: none;
}
.ProseMirror h1,
.ProseMirror h2,
.ProseMirror h3,
.ProseMirror h4,
.ProseMirror h5,
.ProseMirror h1 ,
.ProseMirror h2 ,
.ProseMirror h3 ,
.ProseMirror h4 ,
.ProseMirror h5 ,
.ProseMirror h6 {
position: relative;
display: flex;
align-items: center;
}
.ProseMirror h1::before,
.ProseMirror h2::before,
.ProseMirror h3::before,
.ProseMirror h4::before,
.ProseMirror h5::before,
.ProseMirror h6::before {
position: absolute;
.ProseMirror h1 .heading-collapse-icon,
.ProseMirror h2 .heading-collapse-icon,
.ProseMirror h3 .heading-collapse-icon,
.ProseMirror h4 .heading-collapse-icon,
.ProseMirror h5 .heading-collapse-icon,
.ProseMirror h6 .heading-collapse-icon {
cursor: pointer;
content: "";
background-size: 18px;
width: 18px;
height: 18px;
margin-inline-start: 8px;
background-color: var(--icon);
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
no-repeat 50% 50%;
mask-size: cover;
border: 1px solid var(--background);
transform: rotate(0);
transition: transform 250ms ease, opacity 200ms ease;
left: -22px;
opacity: 0;
user-select: none;
}
.ProseMirror h1[dir="rtl"]::before,
.ProseMirror h2[dir="rtl"]::before,
.ProseMirror h3[dir="rtl"]::before,
.ProseMirror h4[dir="rtl"]::before,
.ProseMirror h5[dir="rtl"]::before,
.ProseMirror h6[dir="rtl"]::before {
display: none;
}
.ProseMirror h1[dir="rtl"]::after,
.ProseMirror h2[dir="rtl"]::after,
.ProseMirror h3[dir="rtl"]::after,
.ProseMirror h4[dir="rtl"]::after,
.ProseMirror h5[dir="rtl"]::after,
.ProseMirror h6[dir="rtl"]::after {
position: absolute;
cursor: pointer;
content: "";
background-size: 18px;
.ProseMirror h1 .heading-collapse-icon {
margin-top: 3.5px;
width: 18px;
height: 18px;
background-color: var(--icon);
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiM4ODg4ODgiIGQ9Ik03LjQxIDguNThMMTIgMTMuMTdsNC41OS00LjU5TDE4IDEwbC02IDZsLTYtNmwxLjQxLTEuNDJaIi8+PC9zdmc+)
no-repeat 50% 50%;
mask-size: cover;
border: 1px solid var(--background);
transform: rotate(0deg);
transition: transform 250ms ease, opacity 200ms ease;
right: -22px;
opacity: 0;
}
.ProseMirror h1::before,
.ProseMirror h1::after {
top: 8px;
.ProseMirror h2 .heading-collapse-icon {
margin-top: 3px;
width: 16px;
height: 16px;
}
.ProseMirror h2::before,
.ProseMirror h2::after
{
top: 3px;
.ProseMirror h3 .heading-collapse-icon {
margin-top: 2.3px;
width: 15px;
height: 15px;
}
.ProseMirror h3::before,
.ProseMirror h3::after {
top: 0px;
.ProseMirror h4 .heading-collapse-icon {
margin-top: 1.8px;
width: 14px;
height: 14px;
}
.ProseMirror h4::before,
.ProseMirror h4::after {
top: -1px;
.ProseMirror h5 .heading-collapse-icon {
margin-top: 1.3px;
width: 13px;
height: 13px;
}
.ProseMirror h5::before,
.ProseMirror h5::after {
top: -2px;
.ProseMirror h6 .heading-collapse-icon {
margin-top: 0.3px;
width: 12px;
height: 12px;
}
.ProseMirror h6::before,
.ProseMirror h6::after {
top: -4px;
}
.ProseMirror h1[data-collapsed="true"]::before,
.ProseMirror h2[data-collapsed="true"]::before,
.ProseMirror h3[data-collapsed="true"]::before,
.ProseMirror h4[data-collapsed="true"]::before,
.ProseMirror h5[data-collapsed="true"]::before,
.ProseMirror h6[data-collapsed="true"]::before {
.ProseMirror h1[data-collapsed="true"] .heading-collapse-icon,
.ProseMirror h2[data-collapsed="true"] .heading-collapse-icon,
.ProseMirror h3[data-collapsed="true"] .heading-collapse-icon,
.ProseMirror h4[data-collapsed="true"] .heading-collapse-icon,
.ProseMirror h5[data-collapsed="true"] .heading-collapse-icon,
.ProseMirror h6[data-collapsed="true"] .heading-collapse-icon {
transform: rotate(-90deg);
opacity: 1;
}
.ProseMirror h1[data-collapsed="true"]::after,
.ProseMirror h2[data-collapsed="true"]::after,
.ProseMirror h3[data-collapsed="true"]::after,
.ProseMirror h4[data-collapsed="true"]::after,
.ProseMirror h5[data-collapsed="true"]::after,
.ProseMirror h6[data-collapsed="true"]::after {
.ProseMirror h1[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
.ProseMirror h2[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
.ProseMirror h3[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
.ProseMirror h4[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
.ProseMirror h5[data-collapsed="true"][dir="rtl"] .heading-collapse-icon,
.ProseMirror h6[data-collapsed="true"][dir="rtl"] .heading-collapse-icon {
transform: rotate(90deg);
opacity: 1;
}
.ProseMirror h1:hover::before,
.ProseMirror h2:hover::before,
.ProseMirror h3:hover::before,
.ProseMirror h4:hover::before,
.ProseMirror h5:hover::before,
.ProseMirror h6:hover::before,
.ProseMirror h1:hover::after,
.ProseMirror h2:hover::after,
.ProseMirror h3:hover::after,
.ProseMirror h4:hover::after,
.ProseMirror h5:hover::after,
.ProseMirror h6:hover::after {
.ProseMirror h1:hover .heading-collapse-icon,
.ProseMirror h2:hover .heading-collapse-icon,
.ProseMirror h3:hover .heading-collapse-icon,
.ProseMirror h4:hover .heading-collapse-icon,
.ProseMirror h5:hover .heading-collapse-icon,
.ProseMirror h6:hover .heading-collapse-icon {
opacity: 1;
}
.ProseMirror div.callout h1::before,
.ProseMirror div.callout h2::before,
.ProseMirror div.callout h3::before,
.ProseMirror div.callout h4::before,
.ProseMirror div.callout h5::before,
.ProseMirror div.callout h6::before {
.ProseMirror div.callout h1 .heading-collapse-icon,
.ProseMirror div.callout h2 .heading-collapse-icon,
.ProseMirror div.callout h3 .heading-collapse-icon,
.ProseMirror div.callout h4 .heading-collapse-icon,
.ProseMirror div.callout h5 .heading-collapse-icon,
.ProseMirror div.callout h6 .heading-collapse-icon {
display: none;
}
/* hide collapse icon when heading is empty (only contains trailing break) */
.ProseMirror h1:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
.ProseMirror h2:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
.ProseMirror h3:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
.ProseMirror h4:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
.ProseMirror h5:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon,
.ProseMirror h6:has(> div > br.ProseMirror-trailingBreak:only-child) .heading-collapse-icon {
display: none !important;
}
@media screen and (max-width: 768px) {
.ProseMirror h1 .heading-collapse-icon,
.ProseMirror h2 .heading-collapse-icon,
.ProseMirror h3 .heading-collapse-icon,
.ProseMirror h4 .heading-collapse-icon,
.ProseMirror h5 .heading-collapse-icon,
.ProseMirror h6 .heading-collapse-icon {
opacity: 1 !important;
}
}
[data-hidden="true"] {
display: none !important;
}
@@ -1028,3 +1040,4 @@ del.diffdel {
pre[class*="language-"] {
overflow: initial !important;
}