Compare commits

...

92 Commits

Author SHA1 Message Date
Ammar Ahmed
2fa7a6ae8a editor: cleanup 2026-08-29 12:55:00 +05:00
Ammar Ahmed
240220277e editor: window long containers however deeply they are nested 2026-08-29 12:37:26 +05:00
Ammar Ahmed
123709b0a5 editor: keep the running height totals per editor, not per module 2026-08-29 12:13:05 +05:00
Ammar Ahmed
3699606e4e editor: time each plugin and record heap use in the profiler 2026-08-29 11:55:34 +05:00
Ammar Ahmed
19524c1a8a editor: build column resize handles only for the rows on screen 2026-08-29 11:55:34 +05:00
Ammar Ahmed
e639c77e1f editor: skip the table repair when the table's shape has not moved 2026-08-29 11:55:33 +05:00
Ammar Ahmed
be10af7d4f editor: let list items with their own views be left out of a window 2026-08-29 11:55:33 +05:00
Ammar Ahmed
ab1a88edec editor: render only the container children that are on screen 2026-08-29 11:55:26 +05:00
Ammar Ahmed
4daeb33fa2 editor: keep the scroll steady with the pin alone 2026-08-27 14:16:43 +05:00
Ammar Ahmed
0236b9e4fe web: restore the scroll position once and let the viewport keep it 2026-08-27 14:13:03 +05:00
Ammar Ahmed
537231a901 editor: size the note without rendering pages it will scroll past 2026-08-27 14:05:47 +05:00
Ammar Ahmed
297ea7bc16 editor: size the note before scrolling to the saved position 2026-08-27 14:01:08 +05:00
Ammar Ahmed
5220a1704e editor: stop focus dragging the view back to the caret on load 2026-08-27 13:58:31 +05:00
Ammar Ahmed
b2856374a3 web: add a scroll tracer to the editor profiler 2026-08-27 13:10:08 +05:00
Ammar Ahmed
e0aa4f6661 web: stop the caret being moved twice while a note opens 2026-08-27 12:45:54 +05:00
Ammar Ahmed
420722c686 web: have the note open at the saved position instead of jumping to it 2026-08-27 12:39:46 +05:00
Ammar Ahmed
29087ec91f editor: detect printing in browsers that only report it as a media query 2026-08-27 11:45:05 +05:00
Ammar Ahmed
81f2231d5a editor: render the whole note when the browser prints 2026-08-27 11:40:00 +05:00
Ammar Ahmed
a15f029e43 editor: strip pages from copied and dragged content 2026-08-27 11:08:28 +05:00
Ammar Ahmed
bad8c2c216 editor: simplify paging internals and drop dead code 2026-08-27 09:27:54 +05:00
Ammar Ahmed
532d609b61 editor: use plainer names and wording throughout paging 2026-08-27 09:03:35 +05:00
Ammar Ahmed
6ac09c5478 editor: drop block virtualization and fold paging into one extension 2026-08-27 08:47:53 +05:00
Ammar Ahmed
ac5b749299 mobile: choose between off, blocks and pages in editor settings 2026-08-26 19:51:51 +05:00
Ammar Ahmed
af2369c4ae editor: re-measure the viewport when the editor is re-laid out 2026-08-26 14:30:14 +05:00
Ammar Ahmed
ca7907a627 web: fetch note content eagerly when saving 2026-08-26 14:03:13 +05:00
Ammar Ahmed
5e5f18c94b editor: default pages to 50 blocks and let callers set the size 2026-08-26 13:20:35 +05:00
Ammar Ahmed
b8ea866177 editor: estimate text from the layout it will wrap into 2026-08-26 13:05:15 +05:00
Ammar Ahmed
df82a0466f editor: size placeholders from each block type's own structure 2026-08-26 13:03:32 +05:00
Ammar Ahmed
c3a62c486f editor: estimate placeholder heights from the content they stand in for 2026-08-26 12:47:44 +05:00
Ammar Ahmed
0fab302d8c editor: hold the reader's position steady while pages render 2026-08-25 23:26:16 +05:00
Ammar Ahmed
09ba20e009 web: mark a note unsaved only once an edit is dropped 2026-08-25 23:04:22 +05:00
Ammar Ahmed
5959364de5 editor: anchor the scroll position even on pages that have not rendered 2026-08-25 22:51:19 +05:00
Ammar Ahmed
78cdc390b3 web: stop the pixel scroll fallback fighting the block anchor 2026-08-25 22:44:19 +05:00
Ammar Ahmed
6f6b93af31 web: restore the scroll position before the editor is painted 2026-08-25 22:35:52 +05:00
Ammar Ahmed
872987de0b editor: restore scroll position by block instead of pixel offset 2026-08-25 22:30:19 +05:00
Ammar Ahmed
1af46dff79 editor: serialize only the pages that changed 2026-08-25 22:22:54 +05:00
Ammar Ahmed
63b42550a7 web: skip serialization entirely when auto-save is off 2026-08-25 22:22:43 +05:00
Ammar Ahmed
af1687c366 editor: render block ids on page elements so the viewport can track them 2026-08-25 22:13:17 +05:00
Ammar Ahmed
131583097c web: store editor selections in page-independent positions 2026-08-25 20:35:08 +05:00
Ammar Ahmed
f41c231e69 editor: page the document while parsing instead of after the first render 2026-08-25 20:35:07 +05:00
Ammar Ahmed
f4ffa44a69 editor: virtualize pages with the same machinery as blocks 2026-08-25 19:16:39 +05:00
Ammar Ahmed
18e836d987 editor: keep block ids and the table of contents working inside pages 2026-08-25 19:16:38 +05:00
Ammar Ahmed
33074112fb editor: choose virtualization mode between blocks and pages 2026-08-25 18:53:17 +05:00
Ammar Ahmed
6a9bdce999 editor: add page grouping that is stripped on serialization 2026-08-25 18:53:16 +05:00
Ammar Ahmed
21606dc43e editor: disable the profiler by default 2026-08-25 18:37:12 +05:00
Ammar Ahmed
2d3a4f544d editor: make virtualization work proportional to the viewport 2026-08-25 18:35:41 +05:00
Ammar Ahmed
0c039bb1cd editor: index top-level positions instead of resolving every block 2026-08-25 18:35:41 +05:00
Ammar Ahmed
b086251f00 editor: render the document once per mount 2026-08-25 18:35:40 +05:00
Ammar Ahmed
2a2b53d3ae web: serialize note content only after edits settle 2026-08-25 18:35:40 +05:00
Ammar Ahmed
d39bdf35d1 web: expose the editor profiler and its benchmarks 2026-08-25 18:35:40 +05:00
Ammar Ahmed
82614b4ce3 editor: instrument block ids, table of contents and height map 2026-08-25 18:35:39 +05:00
Ammar Ahmed
53553d859e editor: add a profiler for measuring editor performance 2026-08-25 18:35:39 +05:00
Ammar Ahmed
5074ee65d2 web: fix crash on launch 2026-08-25 12:18:21 +05:00
Ammar Ahmed
fee2285795 editor: cleanup comments 2026-08-25 11:47:19 +05:00
Ammar Ahmed
15f5d87717 web: add editor virtualization toggle in settings 2026-08-25 11:40:12 +05:00
Ammar Ahmed
b88a5bdac8 mobile: add editor virtualization toggle in settings 2026-08-25 11:39:20 +05:00
Ammar Ahmed
3b9a35a185 editor: init virtualization plugin before document load 2026-08-25 11:25:00 +05:00
Ammar Ahmed
0415d8a284 editor: guarantee virtualization never triggers a save or mutates data 2026-08-25 09:55:02 +05:00
Ammar Ahmed
3d1ca86a89 editor: stabilize virtualization scrolling (no blank gaps, no jump) 2026-08-25 09:55:02 +05:00
Ammar Ahmed
0d2caed4b9 editor: only page notes past a size threshold 2026-08-25 09:55:02 +05:00
Ammar Ahmed
ed1a6d2e87 editor: build table of contents from document state 2026-08-25 09:55:02 +05:00
Ammar Ahmed
d8fe38dc01 editor: resolve virtualization scroll container lazily 2026-08-25 09:55:02 +05:00
Ammar Ahmed
ae6dd0009b editor: add localStorage toggle for block virtualization on mobile 2026-08-25 09:55:02 +05:00
Ammar Ahmed
5978d9119e editor: fix virtualization re-observe + add browser test harness 2026-08-25 09:55:02 +05:00
Ammar Ahmed
d789b4e65a editor: add opt-in block virtualization (hidden paging) 2026-08-25 09:55:02 +05:00
Ammar Ahmed
5d03d3033f Merge pull request #10281 from streetwriters/mobile-release/3.4.10
mobile: release v3.4.10
2026-08-25 08:48:03 +05:00
Ammar Ahmed
db8f02a585 mobile: release v3.4.10 2026-08-24 13:39:23 +05:00
Ammar Ahmed
0fd305a259 Merge pull request #10220 from streetwriters/mobile-release/3.4.9
mobile: release v3.4.9
2026-08-24 13:26:15 +05:00
Ammar Ahmed
b855266293 Merge pull request #10208 from kashaf-ansari-dev/fix-compress-checkbox-not-toggling
mobile: re-check fullQualityImages availability on every compress tog…
2026-08-24 13:23:48 +05:00
Ammar Ahmed
b4227a8c43 Merge pull request #10222 from kashaf-ansari-dev/fix-9053-restore-editor-scroll-position
Fix 9053 restore editor scroll position
2026-08-24 13:23:02 +05:00
Ammar Ahmed
39887f81a2 Merge pull request #10257 from streetwriters/fix/performance
mobile: fix groupOptions causes unnecessary rerenders on first load
2026-08-24 13:22:15 +05:00
Ammar Ahmed
e47ef061fd Merge pull request #10240 from kashaf-ansari-dev/fix-9014-respect-reduce-animations-setting
mobile: respect device reduce animations system setting for sheets
2026-08-24 13:20:52 +05:00
Ammar Ahmed
360cd5431d Merge pull request #10258 from streetwriters/cleanup/master
global: cleanup
2026-08-19 13:25:40 +05:00
Ammar Ahmed
c3aff365f1 global: cleanup 2026-08-19 13:24:34 +05:00
kashaf-ansari-dev
dd3ced17de mobile: re-check fullQualityImages availability on every compress toggle tap
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-18 18:56:58 +05:00
Abdullah Atta
ee419e514c web: fix Requiring @playwright/test second time when running tests (#10252) 2026-08-18 10:29:06 +05:00
01zulfi
db72716234 Merge pull request #10248 from streetwriters/release/3.4.6
web: bump version to 3.4.6
2026-08-17 17:17:05 +05:00
01zulfi
72351960fa web: fix trailing slash when saving cors proxy url
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 14:44:58 +05:00
01zulfi
85458932de web: bump version to 3.4.6
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 14:07:02 +05:00
Abdullah Atta
4e1acc292d web: Improve auth ui on web/desktop (#10218)
* web: improve auth ui

* web: fix password show/hide icons

* web: fix auth form width

* web: fix minor ui issues with back button & recovery view
2026-08-17 11:41:18 +05:00
01zulfi
20b4585361 editor: fix multiple link hover popups from opening (#10233)
* also fix the popup not opening when quickly hovering one link after another
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 10:11:46 +05:00
01zulfi
dc3f38f67d ui: truncate long menu titles (#10237)
* On web, long tags in menu items in the editor header would take a lot of width. The fix in the ui package will fix it for all usages of long menu items on web

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 10:11:17 +05:00
01zulfi
a40e11f2d4 web: preserve port in cors proxy url (#10234)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 10:10:54 +05:00
Abdullah Atta
ccc152b561 docs(help): fix links ignoring baseUrl on docs index page (#10243) 2026-08-16 21:46:39 +05:00
kashaf-ansari-dev
279c776e45 mobile: respect device reduce animations system setting for sheets
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-14 17:56:22 +05:00
Abdullah Atta
87aa98b85e global: move everything to notesnook.com/help/* (#10238) 2026-08-14 12:34:07 +05:00
Ammar Ahmed
b7777a2a19 mobile: fix groupOptions causes unnecessary rerenders on first load 2026-08-14 10:55:53 +05:00
Ammar Ahmed
52d6044e73 docs(help): rebuild the help site with VitePress (#10169)
* docs: new help built with vitepress.

* docs: improve docs home ui

Added a Go to Docs button on homepage
Added a searchbar for directly searching what you are looking for in the docs

* docs(help): wire up with build system & setup proper ci

* Clean up new documentation (#10174)

* Docs: Cleanup pass 1

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: cleanup pass 2

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: Fix vscode's manglement that I missed.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* Update docs/help/contents/plans-and-limits.md

Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* Update docs/help/contents/rich-text-editor/outline-lists.md

Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: Remove self-hosting guide from sidebar, and comments for reviewer.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

---------

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>

* docs: some fixes

* ci: do not publish help on push

* docs(help): improve regional pricing & free trials

* docs: improve sidebar

* docs: some more fixes after re-review

* docs: improve search and nav

* docs: a few more fixes

* docs: fix tabs formatting compat with prettier

* docs: fix tabs formatting in various places

* docs: show correct plus button image for mobile

* docs: disable notesnook self hosting docs

* docs: update help docs with fixes

---------

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
Co-authored-by: Chloe Oletto <NeedsChloesure@riseup.net>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
2026-08-14 08:57:51 +05:00
01zulfi
8d56564221 web: fix notes not exporting from archive list multiple selection (#10230)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-14 08:28:42 +05:00
01zulfi
d647eb8187 web: show error when unlock note password is empty (#10231)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-13 08:36:12 +05:00
kashaf-ansari-dev
316951c2bf editor: guard scroll persistence until note session is restored
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-11 09:36:50 +05:00
kashaf-ansari-dev
9ce5b76265 mobile: restore latest tab scroll position
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-10 20:03:22 +05:00
471 changed files with 22424 additions and 2658 deletions

79
.github/workflows/help.preview.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Notesnook Help PR Preview
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "docs/help/**"
# re-run workflow if workflow file changes
- ".github/workflows/help.preview.yml"
jobs:
build-and-deploy:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install dependencies
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=help
- name: Build help
run: npm run build:help
- name: Deploy to Cloudflare
id: deploy
working-directory: ./docs/help
run: |
set -euo pipefail
DEPLOY_OUT=$(npx --yes wrangler versions upload 2>&1) || { echo "$DEPLOY_OUT"; exit 1; }
echo "$DEPLOY_OUT"
PREVIEW_URL=$(printf "%s" "$DEPLOY_OUT" | grep -Eo 'https?://[^ ]+' | head -1 || true)
if [ -z "$PREVIEW_URL" ]; then
echo "WARNING: could not parse preview URL from wrangler output"
fi
echo "preview_url=$PREVIEW_URL" >> $GITHUB_ENV
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ env.preview_url }}
with:
script: |
const marker = '<!-- docs-pages-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**Cloudflare Pages Docs Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

View File

@@ -2,11 +2,6 @@ name: Publish Notesnook Help
on:
workflow_dispatch:
push:
branches:
- "master"
paths:
- "docs/help/**"
jobs:
build:
@@ -14,17 +9,21 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: actions-rs/toolchain@v1
uses: actions/checkout@v4
with:
toolchain: stable
# VitePress reads git history to show the last updated date per page.
fetch-depth: 0
- name: Install docgen
run: cargo install --git https://github.com/thecodrr/docgen
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Build site
run: docgen build --release
working-directory: docs/help
- name: Install dependencies
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=help
- name: Build help
run: npm run build:help
- name: Setup environment
run: |
@@ -32,4 +31,4 @@ jobs:
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> $GITHUB_ENV
- name: Publish on Cloudflare Pages
run: npx --yes wrangler pages deploy --project-name notesnook-help ./docs/help/site/ --branch main
run: npx --yes wrangler deploy

View File

@@ -27,7 +27,9 @@ jobs:
uses: ./.github/actions/setup-node-with-cache
- name: Install dependencies
run: npm ci
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
- name: Build web
run: npm run build:web

View File

@@ -65,7 +65,7 @@ We take all queries, issues and bug reports that you might have. Feel free to as
## Additional Resources
- [Migrating & Importing your data from other apps — Importer](https://help.notesnook.com/importing-notes)
- [Migrating & Importing your data from other apps — Importer](https://notesnook.com/help/importing-notes)
- [Privacy policy](https://notesnook.com/privacy) & [Terms of service](https://notesnook.com/terms)
- [Verify Notesnook encryption claims yourself — Vericrypt](https://vericrypt.notesnook.com/)
- [Why Notesnook requires an email address?](https://blog.notesnook.com/why-notesnook-requires-an-email-address/)

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/desktop",
"version": "3.4.5",
"version": "3.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.4.5",
"version": "3.4.6",
"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.4.5",
"version": "3.4.6",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3115
versionCode 3116
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')

View File

@@ -42,3 +42,12 @@ allprojects {
maven { url 'https://www.jitpack.io' }
}
}
// Pin the ndkVersion for all subprojects
subprojects { subproject ->
["com.android.application", "com.android.library"].each { pluginId ->
subproject.plugins.withId(pluginId) {
subproject.android.ndkVersion = rootProject.ext.ndkVersion
}
}
}

View File

@@ -1,4 +1,3 @@
- Fix drag/drop issues in task-lists and improved UX
- Minor bug fixes and improvements
Thank you for using Notesnook!

View File

@@ -249,7 +249,7 @@ export default function NoteHistory({
<Text
onPress={() => {
openLinkInBrowser(
"https://help.notesnook.com/note-version-history"
"https://notesnook.com/help/note-version-history"
);
}}
style={{

View File

@@ -50,7 +50,7 @@ export const Synced = ({ item, close }) => {
close();
await sleep(300);
await openLinkInBrowser(
"https://help.notesnook.com/how-is-my-data-encrypted",
"https://notesnook.com/help/how-is-my-data-encrypted",
colors
);
} catch (e) {

View File

@@ -509,7 +509,7 @@ const PublishNoteSheet = ({
onPress={async () => {
try {
await openLinkInBrowser(
"https://help.notesnook.com/publish-notes-with-monographs"
"https://notesnook.com/help/publish-notes-with-monographs"
);
} catch (e) {
console.error(e);

View File

@@ -33,7 +33,6 @@ import {
setGroupOptionsById
} from "../../../hooks/use-group-options";
import { eSendEvent } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { RouteName } from "../../../stores/use-navigation-store";
import { useNotebookStore } from "../../../stores/use-notebook-store";
import { useTagStore } from "../../../stores/use-tag-store";
@@ -46,6 +45,7 @@ import { Button } from "../../ui/button";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import Navigation from "../../../services/navigation";
const Sort = ({
dataType,
screen,
@@ -87,8 +87,7 @@ const Sort = ({
};
const updateGroupOptions = async (_groupOptions: GroupOptions) => {
console.log(groupId, type);
setGroupOptionsById(groupType, _groupOptions, groupId, type);
await setGroupOptionsById(groupType, _groupOptions, groupId, type);
setGroupOptions(_groupOptions);
setTimeout(() => {
if (screen) Navigation.queueRoutesForUpdate(screen);

View File

@@ -27,7 +27,7 @@ import { useUserStore } from "../../../stores/use-user-store";
import { getContainerBorder } from "../../../utils/colors";
import { NotesnookModule } from "../../../utils/notesnook-module";
import { Toast } from "../../toast";
import { useReduceMotion } from "../../../hooks/use-reduce-motion";
/**
*
* @param {any} param0
@@ -53,6 +53,9 @@ const SheetWrapper = ({
const sheetKeyboardHandler = useSettingStore(
(state) => state.sheetKeyboardHandler
);
const isReduceMotionEnabled = useReduceMotion();
const isAnimated = !isReduceMotionEnabled;
const largeTablet = deviceMode === "tablet";
const smallTablet = deviceMode === "smallTablet";
const dimensions = useSettingStore((state) => state.dimensions);
@@ -125,6 +128,7 @@ const SheetWrapper = ({
<ScopedThemeProvider value="sheet">
<ActionSheet
ref={fwdRef || localRef}
animated={isAnimated}
testIDs={{
backdrop: "sheet-backdrop"
}}

View File

@@ -54,15 +54,13 @@ export function useGroupOptions(
const [groupOptions, setGroupOptions] = useState(
getGroupOptions(groupingKey, id, type)
);
console.log(groupingKey, id, type, groupOptions, "options");
const groupOptionsRef = useRef(groupOptions);
groupOptionsRef.current = groupOptions;
useEffect(() => {
const onUpdate = (_groupingKey: string, _id?: string, _type?: string) => {
if (_groupingKey !== groupingKey) return;
if (_id && _type && _id !== id && _type !== type) return;
if (_groupingKey !== groupingKey || _type !== type) return;
if (_id && _type && _id !== id) return;
const options = getGroupOptions(groupingKey, id, type);
if (!options) return;
if (
@@ -70,9 +68,7 @@ export function useGroupOptions(
groupOptionsRef.current?.sortBy !== options.sortBy ||
groupOptionsRef.current?.sortDirection !== options?.sortDirection
) {
console.log("onUpdate", _id, _type);
setGroupOptions({ ...options });
Navigation.queueRoutesForUpdate();
}
};

View File

@@ -0,0 +1,50 @@
/*
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 { useEffect, useState } from "react";
import { AccessibilityInfo } from "react-native";
export function useReduceMotion(): boolean {
const [isReduceMotionEnabled, setIsReduceMotionEnabled] =
useState<boolean>(false);
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled()
.then(setIsReduceMotionEnabled)
.catch(() => {});
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
setIsReduceMotionEnabled
);
return () => {
if (subscription?.remove) {
subscription.remove();
} else if ((AccessibilityInfo as any).removeEventListener) {
(AccessibilityInfo as any).removeEventListener(
"reduceMotionChanged",
setIsReduceMotionEnabled
);
}
};
}, []);
return isReduceMotionEnabled;
}

View File

@@ -58,6 +58,7 @@ export type Settings = {
timeFormat: string;
fontScale: number;
markdownShortcuts: boolean;
virtualization?: boolean;
features: Record<any, any>;
loggedIn: boolean;
defaultLineHeight: number;

View File

@@ -190,6 +190,9 @@ export const useEditorEvents = (
const markdownShortcuts = useSettingStore(
(state) => state.settings.markdownShortcuts
);
const editorVirtualization = useSettingStore(
(state) => state.settings?.editorVirtualization
);
const tools = useDragState((state) => state.data);
useEffect(() => {
@@ -236,6 +239,7 @@ export const useEditorEvents = (
dayFormat: db.settings?.getDayFormat(),
fontScale,
markdownShortcuts,
virtualization: editorVirtualization,
features,
loggedIn,
defaultLineHeight
@@ -258,6 +262,7 @@ export const useEditorEvents = (
loading,
fontScale,
markdownShortcuts,
editorVirtualization,
loggedIn,
defaultLineHeight,
features

View File

@@ -749,12 +749,13 @@ export const useEditor = (
await postMessage(NativeEvents.title, item.title, tabId);
overlay(false);
const updatedTab = useTabStore.getState().getTab(tabId!);
await postMessage(
NativeEvents.html,
{
data: currentContents.current[item.id]?.data || "",
scrollTop: tab?.session?.scrollTop,
selection: tab?.session?.selection,
scrollTop: updatedTab?.session?.scrollTop,
selection: updatedTab?.session?.selection,
searchResultIndex: event.searchResultIndex
},
tabId,

View File

@@ -46,7 +46,7 @@ export function toCamelCase(title: string) {
export function openMonographsWebpage() {
try {
openLinkInBrowser(
"https://help.notesnook.com/publish-notes-with-monographs"
"https://notesnook.com/help/publish-notes-with-monographs"
);
} catch (e) {
console.error(e);

View File

@@ -137,8 +137,8 @@ export const TrashIntervalPicker = createSettingsPicker({
return item === -1
? strings.never()
: item === 1
? strings.reminderRecurringMode.day()
: strings.days(item);
? strings.reminderRecurringMode.day()
: strings.days(item);
},
getItemKey: (item) => item.toString(),
options: [-1, 1, 7, 30, 365] as TrashCleanupInterval[],
@@ -294,10 +294,10 @@ export const ApplockTimerPicker = createSettingsPicker({
return item === -1
? strings.never()
: item === 0 || item === undefined
? strings.immediately()
: item === 1
? strings.minutes(1)
: strings.minutes(item);
? strings.immediately()
: item === 1
? strings.minutes(1)
: strings.minutes(item);
},
getItemKey: (item) => item.toString(),
options: [-1, 0, 1, 5, 15, 30],
@@ -321,8 +321,8 @@ export const VaultLockTimerPicker = createSettingsPicker({
return item === -1
? strings.never()
: item < 1000 * 60 * 60
? strings.minutes(item / (1000 * 60))
: strings.hours(item / (1000 * 60 * 60));
? strings.minutes(item / (1000 * 60))
: strings.hours(item / (1000 * 60 * 60));
},
getItemKey: (item) => item.toString(),
options: [
@@ -349,8 +349,8 @@ export const ImageCompressionPicker = createSettingsPicker({
return item === "ask-every-time"
? strings.askEveryTime()
: item === "enabled"
? strings.enableRecommended()
: strings.disable();
? strings.enableRecommended()
: strings.disable();
},
getItemKey: (item) => item,
options: [

View File

@@ -208,15 +208,15 @@ export const settingsGroups: SettingSection[] = [
return status === SubscriptionStatus.TRIAL
? strings.trialOnGoing(trialEndDate)
: status === SubscriptionStatus.ACTIVE
? strings.subRenewOn(expiryDate)
: status === SubscriptionStatus.CANCELED ||
status === SubscriptionStatus.PAUSED
? strings.subEndsOn(expiryDate)
: status === SubscriptionStatus.EXPIRED
? subscriptionDaysLeft.time < -3
? strings.subEnded()
: strings.accountDowngradedIn(3)
: strings.neverHesitate();
? strings.subRenewOn(expiryDate)
: status === SubscriptionStatus.CANCELED ||
status === SubscriptionStatus.PAUSED
? strings.subEndsOn(expiryDate)
: status === SubscriptionStatus.EXPIRED
? subscriptionDaysLeft.time < -3
? strings.subEnded()
: strings.accountDowngradedIn(3)
: strings.neverHesitate();
}
return strings.neverHesitate();
@@ -971,7 +971,7 @@ export const settingsGroups: SettingSection[] = [
name: strings.keepScreenOn(),
description: strings.keepScreenOnDesc(),
property: "keepScreenOn",
icon: "cellphone-screenshot",
icon: "cellphone-screenshot"
},
{
id: "image-compression",
@@ -1023,6 +1023,14 @@ export const settingsGroups: SettingSection[] = [
});
}
},
{
id: "editor-virtualization",
name: strings.editorVirtualization(),
description: strings.editorVirtualizationDesc(),
type: "switch",
property: "editorVirtualization",
icon: "page-next-outline"
},
{
id: "default-font-size",
name: strings.defaultFontSize(),
@@ -1679,7 +1687,7 @@ export const settingsGroups: SettingSection[] = [
id: "docs-link",
name: strings.documentation(),
modifer: async () => {
Linking.openURL("https://help.notesnook.com/");
Linking.openURL("https://notesnook.com/help/");
},
description: strings.documentationDesc(),
icon: "file-document"

View File

@@ -472,7 +472,7 @@ function ThemeSelector() {
actionText: strings.learnMore(),
func: () => {
openLinkInBrowser(
"https://help.notesnook.com/custom-themes/introduction"
"https://notesnook.com/help/custom-themes/introduction"
);
}
});

View File

@@ -711,9 +711,10 @@ const ShareView = () => {
width: "100%",
marginTop: 6
}}
onPress={() => {
if (fullQualityImages?.isAllowed) {
setCompress(!compress);
onPress={async () => {
const feature = await isFeatureAvailable("fullQualityImages");
if (feature?.isAllowed) {
setCompress((prev) => !prev);
}
}}
>

View File

@@ -78,6 +78,7 @@ export type Settings = {
sessionExpired: boolean;
version: string | null;
doubleSpacedLines?: boolean;
editorVirtualization?: boolean;
disableAutoSync?: boolean;
disableSync?: boolean;
reminderNotifications?: boolean;
@@ -196,6 +197,7 @@ export const defaultSettings: SettingStore["settings"] = {
sessionExpired: false,
version: null,
doubleSpacedLines: true,
editorVirtualization: false,
reminderNotifications: true,
defaultSnoozeTime: "5",
corsProxy: "https://cors.notesnook.com",

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2193
IOS_MARKETING_VERSION = 3.4.9
IOS_CURRENT_PROJECT_VERSION = 2194
IOS_MARKETING_VERSION = 3.4.10
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2193
IOS_MARKETING_VERSION = 3.4.9
IOS_CURRENT_PROJECT_VERSION = 2194
IOS_MARKETING_VERSION = 3.4.10
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Staging iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2193
IOS_MARKETING_VERSION = 3.4.9
IOS_CURRENT_PROJECT_VERSION = 2194
IOS_MARKETING_VERSION = 3.4.10
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
"version": "3.4.5",
"version": "3.4.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
"version": "3.4.5",
"version": "3.4.10",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -191,6 +191,7 @@
"version": "2.1.3",
"license": "GPL-3.0-or-later",
"dependencies": {
"@notesnook/common": "^2.1.3",
"@notesnook/core": "file:../core",
"@readme/data-urls": "^3.0.0",
"dayjs": "1.11.13",
@@ -333,7 +334,10 @@
"papaparse": "^5.5.3",
"prism-themes": "^1.9.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-view": "1.34.2",
"prosemirror-model": "1.25.11",
"prosemirror-state": "1.4.4",
"prosemirror-transform": "1.12.0",
"prosemirror-view": "1.42.2",
"re-resizable": "^6.9.18",
"react-colorful": "^5.6.1",
"redent": "^4.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@notesnook/mobile",
"version": "3.4.9",
"version": "3.4.10",
"private": true,
"license": "GPL-3.0-or-later",
"scripts": {

View File

@@ -141,7 +141,7 @@ export default function Monograph() {
bg: "background-secondary",
border: "1px solid var(--border)"
}}
href="https://help.notesnook.com/publish-notes-with-monographs"
href="https://notesnook.com/help/publish-notes-with-monographs"
target="_blank"
>
How it works

View File

@@ -33,7 +33,6 @@ import {
} from "./utils";
import { NavigationMenuModel } from "./navigation-menu.model";
import { AppModel } from "./app.model";
import { getAppFromPage } from "../../../desktop/__tests__/electron-test/utils";
import { readFile } from "node:fs/promises";
export class SettingsViewModel {
@@ -135,6 +134,10 @@ export class SettingsViewModel {
};
if (IS_DESKTOP_TESTS) {
const { getAppFromPage } = await import(
"../../../desktop/__tests__/electron-test/utils"
);
await saveBackup();
const toast = new AppModel(this.page).toasts.toasts.locator(
getTestId("toast-message")

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/web",
"version": "3.4.5",
"version": "3.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.4.5",
"version": "3.4.6",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@notesnook/web",
"description": "Your private note taking space",
"version": "3.4.5",
"version": "3.4.6",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useMemo } from "react";
import { Box, Button, Flex, Image, Link, Text } from "@theme-ui/components";
import { Button, Flex, Image, Text } from "@theme-ui/components";
import { getRandom, usePromise } from "@notesnook/common";
import Holenstein from "../../assets/testimonials/holenstein.jpg";
import Jason from "../../assets/testimonials/jason.jpg";
@@ -26,6 +26,7 @@ import Cameron from "../../assets/testimonials/cameron.jpg";
import { hosts } from "@notesnook/core";
import { SettingsDialog } from "../../dialogs/settings";
import { strings } from "@notesnook/intl";
import { FixedColorSchemeThemeProvider } from "../theme-provider";
const testimonials = [
{
@@ -80,62 +81,18 @@ function AuthContainer(props) {
bg: "background"
}}
>
<Box
<FixedColorSchemeThemeProvider
colorScheme="dark"
sx={{
position: "relative",
overflow: "hidden",
flexDirection: "column",
display: ["none", "none", "flex"],
flex: 1
flex: 1,
background:
"radial-gradient(1200px 700px at 82% 18%, color-mix(in srgb, var(--accent) 14%, transparent) 0%, transparent 62%), var(--background-secondary)"
}}
>
<Box
as="svg"
version="1.1"
viewBox="0 0 1920 1080"
preserveAspectRatio="xMinYMin slice"
sx={{
position: "absolute",
top: -100,
left: 0,
height: "100%"
// opacity: 0.7,
}}
>
<g mask='url("#SvgjsMask1017")' fill="none">
<path
d="M1184.21-85.14C1033.8-60.27 964.89 302.42 717.38 307.22 469.87 312.02 483.97 244.72 250.55 244.72 17.13 244.72-98.53 307.08-216.28 307.22"
stroke="var(--icon)"
strokeWidth="2"
></path>
<path
d="M641.38-10.43C534.57 43 590.55 387.5 384.53 392.38 178.52 397.26 2.17 282.99-129.16 282.38"
stroke="var(--icon)"
strokeWidth="2"
></path>
<path
d="M1136.18-29.24C957.53-5.77 852.26 404.49 561.01 405.07 269.76 405.65 142.54 160.4-14.16 155.07"
stroke="var(--icon)"
strokeWidth="2"
></path>
<path
d="M508.47-71.88C398.16-66.29 333.42 117.75 114.38 127.84-104.65 137.93-170.96 308.31-279.7 312.84"
stroke="var(--icon)"
strokeWidth="2"
></path>
<path
d="M1104.88-26.74C976.63-19.04 883.5 217.2 653.03 218.11 422.55 219.02 427.1 155.61 201.17 155.61-24.75 155.61-136.64 217.96-250.68 218.11"
stroke="var(--icon)"
strokeWidth="2"
></path>
</g>
<defs>
<mask id="SvgjsMask1017">
<rect width="1440" height="500" fill="#ffffff"></rect>
</mask>
</defs>
</Box>
<Flex
p={50}
sx={{
@@ -162,17 +119,9 @@ function AuthContainer(props) {
<Text
variant="body"
mt={10}
sx={{ fontSize: 14, color: "paragraph-secondary" }}
sx={{ fontSize: 16, color: "paragraph-secondary" }}
>
{testimonial.text} {" "}
<Link
sx={{ fontStyle: "italic", color: "paragraph-secondary" }}
href={testimonial.link}
target="_blank"
rel="noopener noreferrer"
>
source
</Link>
{testimonial.text}
</Text>
<Flex mt={2} sx={{ alignItems: "center", justifyContent: "center" }}>
<Image
@@ -180,10 +129,12 @@ function AuthContainer(props) {
sx={{ borderRadius: 50, width: 40 }}
/>
<Flex ml={2} sx={{ flexDirection: "column" }}>
<Text variant="body" sx={{ fontSize: 14, fontWeight: "bold" }}>
<Text variant="body" sx={{ fontSize: 16, fontWeight: "bold" }}>
{testimonial.name}
</Text>
<Text variant="subBody">@{testimonial.username}</Text>
<Text variant="subBody" sx={{ fontSize: 13 }}>
@{testimonial.username}
</Text>
</Flex>
</Flex>
@@ -218,52 +169,18 @@ function AuthContainer(props) {
</Button>
</Flex>
</Flex>
</Box>
<Flex
</FixedColorSchemeThemeProvider>
<FixedColorSchemeThemeProvider
colorScheme="light"
sx={{
display: "flex",
position: "relative",
flex: 1.5,
flexDirection: "column"
background: "var(--background-secondary)"
}}
>
<Box
as="svg"
version="1.1"
viewBox="0 0 1920 1080"
preserveAspectRatio="xMinYMin slice"
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
width: "130%",
height: "100%"
}}
>
<path
d="M0 336L29.2 316.2C58.3 296.3 116.7 256.7 174.8 267.5C233 278.3 291 339.7 349.2 361.3C407.3 383 465.7 365 523.8 359.5C582 354 640 361 698.2 346.5C756.3 332 814.7 296 872.8 267.2C931 238.3 989 216.7 1047.2 202.3C1105.3 188 1163.7 181 1221.8 202.7C1280 224.3 1338 274.7 1396.2 298C1454.3 321.3 1512.7 317.7 1570.8 332C1629 346.3 1687 378.7 1745.2 366.2C1803.3 353.7 1861.7 296.3 1890.8 267.7L1920 239L1920 0L1890.8 0C1861.7 0 1803.3 0 1745.2 0C1687 0 1629 0 1570.8 0C1512.7 0 1454.3 0 1396.2 0C1338 0 1280 0 1221.8 0C1163.7 0 1105.3 0 1047.2 0C989 0 931 0 872.8 0C814.7 0 756.3 0 698.2 0C640 0 582 0 523.8 0C465.7 0 407.3 0 349.2 0C291 0 233 0 174.8 0C116.7 0 58.3 0 29.2 0L0 0Z"
fill="var(--background-secondary)"
></path>
<path
d="M0 627L29.2 607.3C58.3 587.7 116.7 548.3 174.8 564.7C233 581 291 653 349.2 683.5C407.3 714 465.7 703 523.8 703C582 703 640 714 698.2 724.8C756.3 735.7 814.7 746.3 872.8 742.7C931 739 989 721 1047.2 670.7C1105.3 620.3 1163.7 537.7 1221.8 528.7C1280 519.7 1338 584.3 1396.2 623.8C1454.3 663.3 1512.7 677.7 1570.8 666.8C1629 656 1687 620 1745.2 602C1803.3 584 1861.7 584 1890.8 584L1920 584L1920 237L1890.8 265.7C1861.7 294.3 1803.3 351.7 1745.2 364.2C1687 376.7 1629 344.3 1570.8 330C1512.7 315.7 1454.3 319.3 1396.2 296C1338 272.7 1280 222.3 1221.8 200.7C1163.7 179 1105.3 186 1047.2 200.3C989 214.7 931 236.3 872.8 265.2C814.7 294 756.3 330 698.2 344.5C640 359 582 352 523.8 357.5C465.7 363 407.3 381 349.2 359.3C291 337.7 233 276.3 174.8 265.5C116.7 254.7 58.3 294.3 29.2 314.2L0 334Z"
fill="var(--hover)"
></path>
<path
d="M0 735L29.2 731.5C58.3 728 116.7 721 174.8 739C233 757 291 800 349.2 832.3C407.3 864.7 465.7 886.3 523.8 886.3C582 886.3 640 864.7 698.2 859.3C756.3 854 814.7 865 872.8 870.5C931 876 989 876 1047.2 845.3C1105.3 814.7 1163.7 753.3 1221.8 729.8C1280 706.3 1338 720.7 1396.2 738.7C1454.3 756.7 1512.7 778.3 1570.8 789.2C1629 800 1687 800 1745.2 814.5C1803.3 829 1861.7 858 1890.8 872.5L1920 887L1920 582L1890.8 582C1861.7 582 1803.3 582 1745.2 600C1687 618 1629 654 1570.8 664.8C1512.7 675.7 1454.3 661.3 1396.2 621.8C1338 582.3 1280 517.7 1221.8 526.7C1163.7 535.7 1105.3 618.3 1047.2 668.7C989 719 931 737 872.8 740.7C814.7 744.3 756.3 733.7 698.2 722.8C640 712 582 701 523.8 701C465.7 701 407.3 712 349.2 681.5C291 651 233 579 174.8 562.7C116.7 546.3 58.3 585.7 29.2 605.3L0 625Z"
fill="var(--border)"
></path>
<path
d="M0 897L29.2 895.3C58.3 893.7 116.7 890.3 174.8 908.3C233 926.3 291 965.7 349.2 985.3C407.3 1005 465.7 1005 523.8 1003.3C582 1001.7 640 998.3 698.2 996.7C756.3 995 814.7 995 872.8 986C931 977 989 959 1047.2 939.2C1105.3 919.3 1163.7 897.7 1221.8 894C1280 890.3 1338 904.7 1396.2 911.8C1454.3 919 1512.7 919 1570.8 928C1629 937 1687 955 1745.2 960.3C1803.3 965.7 1861.7 958.3 1890.8 954.7L1920 951L1920 885L1890.8 870.5C1861.7 856 1803.3 827 1745.2 812.5C1687 798 1629 798 1570.8 787.2C1512.7 776.3 1454.3 754.7 1396.2 736.7C1338 718.7 1280 704.3 1221.8 727.8C1163.7 751.3 1105.3 812.7 1047.2 843.3C989 874 931 874 872.8 868.5C814.7 863 756.3 852 698.2 857.3C640 862.7 582 884.3 523.8 884.3C465.7 884.3 407.3 862.7 349.2 830.3C291 798 233 755 174.8 737C116.7 719 58.3 726 29.2 729.5L0 733Z"
fill="var(--hover)"
></path>
<path
d="M0 1081L29.2 1081C58.3 1081 116.7 1081 174.8 1081C233 1081 291 1081 349.2 1081C407.3 1081 465.7 1081 523.8 1081C582 1081 640 1081 698.2 1081C756.3 1081 814.7 1081 872.8 1081C931 1081 989 1081 1047.2 1081C1105.3 1081 1163.7 1081 1221.8 1081C1280 1081 1338 1081 1396.2 1081C1454.3 1081 1512.7 1081 1570.8 1081C1629 1081 1687 1081 1745.2 1081C1803.3 1081 1861.7 1081 1890.8 1081L1920 1081L1920 949L1890.8 952.7C1861.7 956.3 1803.3 963.7 1745.2 958.3C1687 953 1629 935 1570.8 926C1512.7 917 1454.3 917 1396.2 909.8C1338 902.7 1280 888.3 1221.8 892C1163.7 895.7 1105.3 917.3 1047.2 937.2C989 957 931 975 872.8 984C814.7 993 756.3 993 698.2 994.7C640 996.3 582 999.7 523.8 1001.3C465.7 1003 407.3 1003 349.2 983.3C291 963.7 233 924.3 174.8 906.3C116.7 888.3 58.3 891.7 29.2 893.3L0 895Z"
fill="var(--border)"
></path>
</Box>
{props.children}
</Flex>
</FixedColorSchemeThemeProvider>
</Flex>
);
}

View File

@@ -47,6 +47,7 @@ import { useStore as useUserStore } from "../../stores/user-store";
import { useStore as useSearchStore } from "../../stores/search-store";
import { AppEventManager, AppEvents } from "../../common/app-events";
import { FlexScrollContainer } from "../scroll-container";
import { ScrollAnchor } from "@notesnook/editor";
import Tiptap, { OnChangeHandler } from "./tiptap";
import Header from "./header";
import { Attachment } from "../icons";
@@ -508,6 +509,7 @@ export function Editor(props: EditorProps) {
(store) => store.saveSessionContentIfNotSaved
);
const setEditorSaveState = useEditorStore((store) => store.setSaveState);
const restoredScroll = useRef(false);
useScrollToBlock(session);
useScrollToSearchResult(session);
@@ -546,6 +548,10 @@ export function Editor(props: EditorProps) {
const editor = useEditorManager.getState().getEditor(id)?.editor;
const selection = editor?.getSelection();
if (selection) Config.set(`${id}:selection`, selection);
// the scroll handler is debounced, so a reload right after scrolling
// would otherwise lose the position
const anchor = editor?.getScrollAnchor();
if (anchor) Config.set(`${id}:scroll-anchor`, anchor);
};
}, [id]);
@@ -562,9 +568,19 @@ export function Editor(props: EditorProps) {
corsHost: Config.get("corsProxy", "https://cors.notesnook.com")
}}
onLoad={(editor) => {
editor = editor || useEditorManager.getState().getEditor(id)?.editor;
// Only once: the viewport corrects the scroll itself as pages are
// measured, and restoring again would undo those corrections.
if (!restoredScroll.current) {
restoredScroll.current = restoreScrollPosition(
session,
editor || useEditorManager.getState().getEditor(id)?.editor,
() => (restoredScroll.current = true)
);
}
// The caret is only put back once the editor is fully created. Doing
// it earlier as well means it is moved twice, and the editor scrolls
// to follow the caret each time.
if (editor) restoreSelection(editor, id);
restoreScrollPosition(session);
}}
onSelectionChange={({ from, to }) => {
Config.set(`${id}:selection`, { from, to });
@@ -760,6 +776,11 @@ function EditorChrome(props: PropsWithChildren<EditorProps>) {
const scrollTop = e.target.scrollTop;
Config.set(`${id}:scroll-position`, scrollTop);
}
const anchor = useEditorManager
.getState()
.getEditor(id)
?.editor?.getScrollAnchor();
Config.set(`${id}:scroll-anchor`, anchor ?? null);
}, 500)}
>
<Flex
@@ -944,8 +965,50 @@ function isFile(e: DragEvent) {
);
}
function restoreScrollPosition(session: EditorSession) {
if (session?.activeBlockId) return scrollIntoViewById(session.activeBlockId);
/** The editor is not always registered yet when a note first loads. */
function retryScrollAnchor(
sessionId: string,
anchor: ScrollAnchor,
done: () => void,
tries = 5
) {
if (tries <= 0) return;
requestAnimationFrame(() => {
const editor = useEditorManager.getState().getEditor(sessionId)?.editor;
if (editor?.restoreScrollAnchor(anchor)) return done();
retryScrollAnchor(sessionId, anchor, done, tries - 1);
});
}
/** Returns whether the position was restored, so it is only attempted once. */
function restoreScrollPosition(
session: EditorSession,
editor?: IEditor,
onRestored: () => void = () => undefined
): boolean {
if (session?.activeBlockId) {
scrollIntoViewById(session.activeBlockId);
return true;
}
// Restoring by block avoids the guesswork: a saved pixel offset was measured
// against a fully rendered document, and a paged one only knows estimated
// heights until it renders. The editor reveals the page holding the block
// before scrolling, so the position it lands on is the real one.
//
// Once there is an anchor the pixel path must not run at all, not even as a
// fallback: it scrolls to an offset that means something else now, and its
// ResizeObserver fires again every time a page renders and changes the
// document's height.
const anchor = Config.get<ScrollAnchor | null>(
`${session.id}:scroll-anchor`,
null
);
if (anchor) {
if (editor?.restoreScrollAnchor(anchor)) return true;
retryScrollAnchor(session.id, anchor, onRestored);
return false;
}
const scrollContainer = document.getElementById(`editorScroll_${session.id}`);
const scrollPosition = Config.get(`${session.id}:scroll-position`, 0);
@@ -968,12 +1031,14 @@ function restoreScrollPosition(session: EditorSession) {
} else
requestAnimationFrame(() => (scrollContainer.scrollTop = scrollPosition));
}
return true;
}
function restoreSelection(editor: IEditor, id: string) {
setTimeout(() => {
editor.focus({
position: Config.get(`${id}:selection`)
position: Config.get(`${id}:selection`),
scrollIntoView: false
});
});
}

View File

@@ -0,0 +1,278 @@
/*
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 { Editor, getHTMLFromFragment, profiler } from "@notesnook/editor";
const TRACE_KEY = "nn:profiler:trace-scroll";
let activeEditor: Editor | undefined;
function isEditorScroller(element: unknown): element is HTMLElement {
return (
element instanceof HTMLElement && element.id.startsWith("editorScroll_")
);
}
function stack(): string {
const lines = (new Error().stack || "").split("\n").slice(3, 8);
return lines.map((line) => line.trim()).join("\n ");
}
/**
* Logs every scroll of the editor's container and who caused it. Installed at
* import time so it catches whatever happens while a note is opening.
*/
function traceScrolling() {
const proto = Element.prototype as unknown as Record<string, unknown>;
if (proto.__nnScrollTraced) return;
proto.__nnScrollTraced = true;
const descriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
"scrollTop"
);
if (descriptor?.set && descriptor.get) {
const { get, set } = descriptor;
Object.defineProperty(Element.prototype, "scrollTop", {
...descriptor,
set(this: Element, value: number) {
if (isEditorScroller(this))
console.log(
`[scroll] scrollTop ${Math.round(
get.call(this) as number
)} -> ${Math.round(value)}\n ${stack()}`
);
set.call(this, value);
}
});
}
for (const name of ["scrollBy", "scrollTo", "scrollIntoView"] as const) {
const original = Element.prototype[name] as (...args: unknown[]) => void;
Element.prototype[name] = function (this: Element, ...args: unknown[]) {
if (isEditorScroller(this) || name === "scrollIntoView")
console.log(
`[scroll] ${name}(${JSON.stringify(args[0])})\n ${stack()}`
);
return original.apply(this, args);
} as typeof original;
}
}
try {
if (globalThis.localStorage?.getItem(TRACE_KEY) === "1") traceScrolling();
} catch (e) {
/* storage unavailable */
}
export function setProfiledEditor(editor: Editor | undefined) {
activeEditor = editor;
}
function requireEditor(): Editor | undefined {
if (!activeEditor || activeEditor.isDestroyed) {
console.warn("[profiler] no active editor. open a note first.");
return undefined;
}
return activeEditor;
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
function afterPaint(): Promise<void> {
return new Promise((resolve) =>
requestAnimationFrame(() => setTimeout(() => resolve(), 0))
);
}
function findScrollParent(node: HTMLElement): HTMLElement | undefined {
let current: HTMLElement | null = node.parentElement;
while (current) {
const overflowY = getComputedStyle(current).overflowY;
if (
(overflowY === "auto" || overflowY === "scroll") &&
current.scrollHeight > current.clientHeight
)
return current;
current = current.parentElement;
}
return undefined;
}
function documentStats(editor: Editor) {
const dom = editor.view.dom;
return {
topLevelBlocks: editor.state.doc.childCount,
docNodeSize: editor.state.doc.nodeSize,
characters: editor.state.doc.textContent.length,
domTopLevelElements: dom.children.length,
domTotalElements: dom.getElementsByTagName("*").length,
placeholders: dom.querySelectorAll("[data-page-placeholder]").length,
virtualization: !!(
editor.storage.virtualization as { enabled?: boolean } | undefined
)?.enabled
};
}
async function typeTest(count = 200, delayMs = 0) {
const editor = requireEditor();
if (!editor) return;
const dom = editor.view.dom as HTMLElement;
editor.commands.focus();
profiler.event("bench.type.start", { count });
const start = performance.now();
for (let i = 0; i < count; i++) {
dom.dispatchEvent(
new KeyboardEvent("keydown", {
key: "a",
bubbles: true,
cancelable: true
})
);
editor.view.dispatch(editor.state.tr.insertText("a"));
await afterPaint();
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
}
const elapsed = performance.now() - start;
profiler.event("bench.type.end", { count, elapsed });
console.log(
`[profiler] typed ${count} characters in ${Math.round(
elapsed
)}ms (${Math.round(elapsed / count)}ms/char incl. paint)`
);
return profiler.print(`type:${count}`);
}
async function scrollTest(steps = 40) {
const editor = requireEditor();
if (!editor) return;
const container = findScrollParent(editor.view.dom);
if (!container) {
console.warn("[profiler] no scroll container found.");
return;
}
const max = container.scrollHeight - container.clientHeight;
const step = max / steps;
profiler.event("bench.scroll.start", { steps, max });
const frames: number[] = [];
const start = performance.now();
for (let i = 0; i <= steps; i++) {
const stepStart = performance.now();
container.scrollTop = step * i;
await afterPaint();
const duration = performance.now() - stepStart;
frames.push(duration);
profiler.record("bench.scrollStep", duration);
}
const elapsed = performance.now() - start;
const slowest = Math.max(...frames);
profiler.event("bench.scroll.end", { elapsed, slowest });
console.log(
`[profiler] scrolled ${steps} steps in ${Math.round(
elapsed
)}ms (slowest step ${Math.round(slowest)}ms)`
);
container.scrollTop = 0;
await nextFrame();
return profiler.print(`scroll:${steps}`);
}
function serializeTest(runs = 5) {
const editor = requireEditor();
if (!editor) return;
for (let i = 0; i < runs; i++)
profiler.time("bench.getHTML", () =>
getHTMLFromFragment(editor.state.doc.content, editor.schema)
);
return profiler.print(`serialize:${runs}`);
}
export function installProfilerGlobals() {
const api = {
profiler,
enable(options?: { timeline?: boolean }) {
profiler.enable(options);
console.log(
"[profiler] enabled and persisted. reload to profile editor startup."
);
return api;
},
disable() {
profiler.disable();
console.log("[profiler] disabled.");
return api;
},
reset() {
profiler.reset();
return api;
},
report: (label?: string) => profiler.report(label),
print: (label?: string) => profiler.print(label),
snapshot: (label: string) => profiler.snapshot(label),
compare: (baseline: string, candidate: string) =>
profiler.compare(baseline, candidate),
snapshots: () => profiler.listSnapshots(),
stats() {
const editor = requireEditor();
if (!editor) return;
const stats = documentStats(editor);
console.table(stats);
return stats;
},
copy(label?: string) {
const json = JSON.stringify(profiler.report(label), null, 2);
navigator.clipboard?.writeText(json);
console.log("[profiler] report copied to clipboard.");
return json;
},
typeTest,
scrollTest,
serializeTest,
traceScroll(on = true) {
try {
if (on) globalThis.localStorage?.setItem(TRACE_KEY, "1");
else globalThis.localStorage?.removeItem(TRACE_KEY);
} catch (e) {
/* storage unavailable */
}
console.log(
on
? "[profiler] scroll tracing on. reload, then open the note."
: "[profiler] scroll tracing off. reload to stop."
);
}
};
(globalThis as unknown as Record<string, unknown>).editorProfiler = api;
if (profiler.enabled)
console.log(
"[profiler] active. window.editorProfiler.print() for a report, .disable() to turn off."
);
return api;
}

View File

@@ -27,7 +27,6 @@ import {
Editor,
AttachmentType,
usePermissionHandler,
getHTMLFromFragment,
Fragment,
type DownloadOptions,
getTotalWords,
@@ -38,8 +37,15 @@ import {
getTableOfContents,
getChangedNodes,
LinkAttributes,
fromFlatPosition,
getScrollAnchor,
profiler,
restoreScrollAnchor,
serializeDocumentHTML,
toFlatPosition,
type Selection
} from "@notesnook/editor";
import { installProfilerGlobals, setProfiledEditor } from "./profiling";
import { Box, Flex } from "@theme-ui/components";
import {
PropsWithChildren,
@@ -115,6 +121,7 @@ type TipTapProps = {
dayFormat: DayFormat;
markdownShortcuts: boolean;
fontLigatures: boolean;
virtualization: boolean;
};
function countCharacters(text: string) {
@@ -137,6 +144,7 @@ function countSpaces(text: string) {
}
function updateNoteStatistics(id: string, content: () => Fragment) {
const end = profiler.start("statistics.update");
const fragment = content();
const documentText = fragment.textBetween(0, fragment.size, "\n", " ");
useEditorManager.getState().updateEditor(id, {
@@ -159,10 +167,14 @@ function updateNoteStatistics(id: string, content: () => Fragment) {
}
}
});
end();
profiler.count("statistics.updates");
}
const deferredUpdateNoteStatistics = debounce(updateNoteStatistics, 1000);
installProfilerGlobals();
function TipTap(props: TipTapProps) {
const {
id,
@@ -190,10 +202,17 @@ function TipTap(props: TipTapProps) {
timeFormat,
dayFormat,
markdownShortcuts,
fontLigatures
fontLigatures,
virtualization
} = props;
const autoSave = useRef(true);
useEffect(() => {
profiler.count("editor.mounts");
profiler.event("editor.mount", { id });
return () => profiler.count("editor.unmounts");
}, []);
const { toolbarConfig } = useToolbarConfig();
const features = useAreFeaturesAvailable([
"callout",
@@ -245,7 +264,9 @@ function TipTap(props: TipTapProps) {
event.preventDefault();
onChange?.(
() =>
getHTMLFromFragment(editor.state.doc.content, editor.schema),
profiler.time("serialize.manualSave", () =>
serializeDocumentHTML(editor.state.doc, editor.schema)
),
false
);
}
@@ -268,6 +289,7 @@ function TipTap(props: TipTapProps) {
},
enableInputRules: markdownShortcuts,
enableFontLigatures: fontLigatures,
virtualization,
downloadOptions,
doubleSpacedLines,
dateFormat,
@@ -276,19 +298,31 @@ function TipTap(props: TipTapProps) {
element: editorContainer(),
editable: !readonly,
content: content?.(),
autofocus: "start",
// Tiptap's autofocus scrolls to the caret a frame after the editor is
// created, which would undo the restored scroll position. The caret is
// placed by the selection restore instead.
autofocus: false,
onFocus,
onCreate: async ({ editor }) => {
setProfiledEditor(editor as Editor);
profiler.setContext("virtualization", virtualization);
profiler.setContext("noteId", id);
profiler.setContext("topLevelBlocks", editor.state.doc.childCount);
profiler.setContext("characters", editor.state.doc.textContent.length);
profiler.event("editor.created");
if (oldNonce.current !== nonce)
editor.commands.focus("start", { scrollIntoView: true });
editor.commands.focus("start", { scrollIntoView: false });
oldNonce.current = nonce;
const instance = toIEditor(editor as Editor);
if (onLoad) onLoad(instance);
const editorInstance = instance.current ?? toIEditor(editor as Editor);
if (onLoad) onLoad(editorInstance);
const totalWords = getTotalWords(editor as Editor);
const totalWords = profiler.time("statistics.totalWords", () =>
getTotalWords(editor as Editor)
);
useEditorManager.getState().setEditor(id, {
editor: instance,
editor: editorInstance,
canRedo: editor.can().redo(),
canUndo: editor.can().undo(),
statistics: {
@@ -309,7 +343,7 @@ function TipTap(props: TipTapProps) {
selected: 0
}
},
tableOfContents: getTableOfContents(editor.view.dom)
tableOfContents: getTableOfContents(editor.state.doc, editor.view.dom)
});
},
onUpdate: ({ editor, transaction }) => {
@@ -319,7 +353,10 @@ function TipTap(props: TipTapProps) {
});
if (changedHeadings.length > 0) {
useEditorManager.getState().updateEditor(id, {
tableOfContents: getTableOfContents(editor.view.dom)
tableOfContents: getTableOfContents(
editor.state.doc,
editor.view.dom
)
});
}
@@ -332,14 +369,21 @@ function TipTap(props: TipTapProps) {
if (ignoreEdit || preventSave || !editor.isEditable || !onChange)
return;
if (!autoSave.current) return;
if (!autoSave.current) {
onAutoSaveDisabled();
return;
}
onChange(
() => getHTMLFromFragment(editor.state.doc.content, editor.schema),
() =>
profiler.time("serialize.autoSave", () =>
serializeDocumentHTML(editor.state.doc, editor.schema)
),
ignoreEdit
);
},
onDestroy: () => {
setProfiledEditor(undefined);
useEditorManager.getState().setEditor(id);
},
onTransaction: ({ editor, transaction }) => {
@@ -347,7 +391,7 @@ function TipTap(props: TipTapProps) {
canRedo: editor.can().redo(),
canUndo: editor.can().undo(),
tableOfContents: transaction.getMeta("isUpdatingContent")
? getTableOfContents(editor.view.dom)
? getTableOfContents(editor.state.doc, editor.view.dom)
: useEditorManager.getState().getEditor(id)?.tableOfContents
});
},
@@ -356,7 +400,11 @@ function TipTap(props: TipTapProps) {
},
onSelectionUpdate: debounce(({ editor, transaction }) => {
const isEmptySelection = transaction.selection.empty;
if (onSelectionChange) onSelectionChange(transaction.selection);
if (onSelectionChange)
onSelectionChange({
from: toFlatPosition(editor.state.doc, transaction.selection.from),
to: toFlatPosition(editor.state.doc, transaction.selection.to)
});
useEditorManager.getState().updateEditor(id, (old) => {
const oldSelected = old.statistics?.words?.selected;
const oldWords = old.statistics?.words.total || 0;
@@ -493,7 +541,8 @@ function TipTap(props: TipTapProps) {
timeFormat,
dayFormat,
markdownShortcuts,
fontLigatures
fontLigatures,
virtualization
]);
const editor = useTiptap(
@@ -502,6 +551,15 @@ function TipTap(props: TipTapProps) {
[tiptapOptions]
);
// Registered before the browser paints. `onCreate` runs a task later, and
// whoever restores the scroll position needs the editor in the frame the note
// first appears in, or the note paints at the top and then jumps.
const instance = useRef<IEditor>();
useLayoutEffect(() => {
instance.current = toIEditor(editor as Editor);
useEditorManager.getState().setEditor(id, { editor: instance.current });
}, [editor, id]);
useEffect(() => {
function onClick(e: MouseEvent) {
if (e.target !== editor.view.dom || !editor.state.selection.empty) return;
@@ -525,14 +583,18 @@ function TipTap(props: TipTapProps) {
}, [editor]);
useEffect(() => {
const update = (totalWords?: number) => {
autoSave.current = !totalWords || totalWords < MAX_AUTO_SAVEABLE_WORDS;
};
// The editor's statistics are set from `onCreate`, which runs before this
// effect, and a store subscription only fires on later changes. Without
// seeding from the current value auto-save stays on for large notes.
update(useEditorManager.getState().editors[id]?.statistics?.words.total);
const unsubscribe = useEditorManager.subscribe(
(s) => s.editors[id]?.statistics?.words.total,
(totalWords) => {
autoSave.current = !totalWords || totalWords < MAX_AUTO_SAVEABLE_WORDS;
if (!autoSave.current) {
onAutoSaveDisabled();
}
}
update
);
return () => {
unsubscribe();
@@ -600,6 +662,7 @@ function TiptapWrapper(
| "dayFormat"
| "markdownShortcuts"
| "fontLigatures"
| "virtualization"
>
> & {
isHydrating?: boolean;
@@ -619,6 +682,9 @@ function TiptapWrapper(
(store) => store.markdownShortcuts
);
const fontLigatures = useSettingsStore((store) => store.fontLigatures);
const virtualization = useSettingsStore(
(store) => store.editorVirtualization
);
const containerRef = useRef<HTMLDivElement>(null);
const editorContainerRef = useRef<HTMLDivElement>();
const { editorConfig, setEditorConfig } = useEditorConfig();
@@ -642,7 +708,10 @@ function TiptapWrapper(
theme.scopes.base.primary.paragraph;
}, [theme]);
useEffect(() => {
// Runs before the browser paints: `onLoad` restores the scroll position, and
// doing that in a passive effect paints the note at the top for a frame
// before it jumps to where the reader left off.
useLayoutEffect(() => {
if (!isHydrating) {
onLoad?.();
containerRef.current
@@ -706,7 +775,11 @@ function TiptapWrapper(
}}
>
<TipTap
key={`tiptap-${props.id}-${doubleSpacedLines}-${dateFormat}-${timeFormat}-${dayFormat}-${markdownShortcuts}-${fontLigatures}`}
// `virtualization` must stay in this key. useEditor creates the
// Editor instance once and only rebuilds its view afterwards, so
// extension options are frozen at construction — toggling paging only
// takes effect when the whole component remounts.
key={`tiptap-${props.id}-${doubleSpacedLines}-${dateFormat}-${timeFormat}-${dayFormat}-${markdownShortcuts}-${fontLigatures}-${virtualization}`}
{...props}
isMobile={isMobile}
isTablet={isTablet}
@@ -716,6 +789,7 @@ function TiptapWrapper(
dayFormat={dayFormat}
markdownShortcuts={markdownShortcuts}
fontLigatures={fontLigatures}
virtualization={virtualization}
onLoad={(editor) => {
if (!isHydrating) {
onLoad?.(editor);
@@ -768,7 +842,14 @@ function toIEditor(editor: Editor): IEditor {
return {
focus: ({ position, scrollIntoView } = {}) => {
if (typeof position === "object")
editor.chain().focus().setTextSelection(position).run();
editor
.chain()
.focus(null, { scrollIntoView: scrollIntoView ?? true })
.setTextSelection({
from: fromFlatPosition(editor.state.doc, position.from),
to: fromFlatPosition(editor.state.doc, position.to)
})
.run();
else
editor.commands.focus(position, {
scrollIntoView
@@ -803,11 +884,18 @@ function toIEditor(editor: Editor): IEditor {
),
startSearch: () => editor.commands.startSearch(),
getContent: () =>
getHTMLFromFragment(editor.state.doc.content, editor.schema),
profiler.time("serialize.getContent", () =>
serializeDocumentHTML(editor.state.doc, editor.schema)
),
getSelection: () => {
const { from, to } = editor.state.selection;
return { from, to };
}
return {
from: toFlatPosition(editor.state.doc, from),
to: toFlatPosition(editor.state.doc, to)
};
},
getScrollAnchor: () => getScrollAnchor(editor.view),
restoreScrollAnchor: (anchor) => restoreScrollAnchor(editor.view, anchor)
};
}

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Attachment } from "@notesnook/editor";
import { ScrollAnchor } from "@notesnook/editor";
export const MAX_AUTO_SAVEABLE_WORDS = IS_TESTING ? 100 : 100_000;
@@ -53,4 +54,6 @@ export interface IEditor {
startSearch: () => void;
getContent: () => string;
getSelection: () => { from: number; to: number };
getScrollAnchor: () => ScrollAnchor | undefined;
restoreScrollAnchor: (anchor: ScrollAnchor) => boolean;
}

View File

@@ -918,7 +918,7 @@ function NavigationDropdown() {
icon: Documentation.path,
key: "help-and-support",
onClick: () => {
window.open("https://help.notesnook.com/", "_blank");
window.open("https://notesnook.com/help/", "_blank");
}
},
{

View File

@@ -550,7 +550,7 @@ export const noteMenuItems: (
await exportNotes(
format.type,
db.notes.all.where((eb) => eb("id", "in", ids))
db.notes.exportable.where((eb) => eb("id", "in", ids))
);
}
}))

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
EmotionThemeProvider,
FixedThemeProvider,
ThemeScopes,
themeToCSS,
useThemeEngineStore
@@ -94,4 +95,25 @@ export function BaseThemeProvider(
);
}
export function FixedColorSchemeThemeProvider(
props: PropsWithChildren<
{
injectCssVars?: boolean;
scope?: keyof ThemeScopes;
colorScheme: "light" | "dark";
} & Omit<BoxProps, "variant">
>
) {
const { children, scope = "base", ...restProps } = props;
const theme = useThemeStore((store) =>
props.colorScheme === "dark" ? store.darkTheme : store.lightTheme
);
return (
<FixedThemeProvider {...restProps} scope={scope} theme={theme}>
{children}
</FixedThemeProvider>
);
}
export { EmotionThemeProvider as ScopedThemeProvider };

View File

@@ -33,15 +33,21 @@ type UnlockViewProps = {
};
export function UnlockView(props: UnlockViewProps) {
const { title, subtitle, buttonTitle, unlock } = props;
const [isWrong, setIsWrong] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
);
const [isUnlocking, setIsUnlocking] = useState(false);
const passwordRef = useRef<HTMLInputElement>(null);
const submit = useCallback(async () => {
if (!passwordRef.current?.value) return;
const password = passwordRef?.current?.value;
if (!password) {
setErrorMessage(strings.passwordRequired());
return;
}
setIsUnlocking(true);
const password = passwordRef.current.value;
try {
await unlock(password);
} catch (e) {
@@ -49,7 +55,7 @@ export function UnlockView(props: UnlockViewProps) {
e instanceof Error &&
e.message.includes("ciphertext cannot be decrypted using that key")
) {
setIsWrong(true);
setErrorMessage(strings.passwordIncorrect());
} else {
showToast("error", `${strings.couldNotUnlock()}: ` + e);
console.error(e);
@@ -57,7 +63,7 @@ export function UnlockView(props: UnlockViewProps) {
} finally {
setIsUnlocking(false);
}
}, [setIsWrong, unlock]);
}, [setErrorMessage, unlock]);
return (
<Flex
@@ -110,12 +116,12 @@ export function UnlockView(props: UnlockViewProps) {
onKeyUp={async (e) => {
if (e.key === "Enter") {
await submit();
} else if (isWrong) {
setIsWrong(false);
} else if (errorMessage) {
setErrorMessage(undefined);
}
}}
/>
{isWrong && <ErrorText sx={{ mt: 1 }} error="Wrong password" />}
{errorMessage && <ErrorText sx={{ mt: 1 }} error={errorMessage} />}
<Button
mt={3}
variant="accent"

View File

@@ -179,7 +179,7 @@ const staticCommands: Command[] = [
id: "help",
title: strings.helpAndSupport(),
icon: ArrowTopRight,
action: () => (window.location.href = "https://help.notesnook.com"),
action: () => (window.location.href = "https://notesnook.com/help"),
group: strings.navigate(),
type: "command"
},

View File

@@ -156,6 +156,21 @@ export const EditorSettings: SettingsGroup[] = [
toggle: () => useSettingStore.getState().toggleFontLigatures()
}
]
},
{
key: "editor-virtualization",
title: strings.editorVirtualization(),
description: strings.editorVirtualizationDesc(),
onStateChange: (listener) =>
useSettingStore.subscribe((c) => c.editorVirtualization, listener),
components: [
{
type: "toggle",
isToggled: () => useSettingStore.getState().editorVirtualization,
toggle: () =>
useSettingStore.getState().toggleEditorVirtualization()
}
]
}
]
},

View File

@@ -386,7 +386,7 @@ export const SupportSettings: SettingsGroup[] = [
{
type: "button",
action: () =>
void window.open("https://help.notesnook.com/", "_blank"),
void window.open("https://notesnook.com/help/", "_blank"),
title: strings.open(),
variant: "secondary"
}

View File

@@ -124,7 +124,7 @@ export const PrivacySettings: SettingsGroup[] = [
if (!result) return;
try {
const url = new URL(result);
Config.set("corsProxy", `${url.protocol}//${url.hostname}`);
Config.set("corsProxy", url.href.replace(/\/$/, ""));
} catch (e) {
console.error(e);
showToast("error", strings.invalidCors());

View File

@@ -181,7 +181,7 @@ const DEFAULT_TIPS: Record<TipContext, Omit<Tip, "contexts">> = {
icon: ArrowTopRight,
onClick() {
window.open(
"https://help.notesnook.com/publish-notes-with-monographs",
"https://notesnook.com/help/publish-notes-with-monographs",
"_blank"
);
}

View File

@@ -57,6 +57,7 @@ class SettingStore extends BaseStore<SettingStore> {
doubleSpacedParagraphs = Config.get("doubleSpacedLines", true);
markdownShortcuts = Config.get("markdownShortcuts", false);
fontLigatures = Config.get("fontLigatures", false);
editorVirtualization = Config.get("editorVirtualization", false);
notificationsSettings = Config.get("notifications", { reminder: true });
isFullOfflineMode = Config.get("fullOfflineMode", false);
serverUrls: Partial<Record<HostId, string>> = Config.get("serverUrls", {});
@@ -250,6 +251,12 @@ class SettingStore extends BaseStore<SettingStore> {
Config.set("fontLigatures", !fontLigatures);
};
toggleEditorVirtualization = (toggleState?: boolean) => {
const next = toggleState ?? !this.get().editorVirtualization;
this.set((state) => (state.editorVirtualization = next));
Config.set("editorVirtualization", next);
};
togglePrivacyMode = async () => {
const privacyMode = this.get().privacyMode;
this.set({ privacyMode: !privacyMode });

View File

@@ -389,7 +389,7 @@ function Signup(props: BaseAuthComponentProps<"signup">) {
<Text
mt={4}
variant="subBody"
sx={{ fontSize: 13, textAlign: "center" }}
sx={{ fontSize: "subBody", textAlign: "center" }}
>
{strings.signupAgreement[0]()}{" "}
<Link
@@ -843,9 +843,6 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState<AuthFormData[T] | undefined>();
if (isSubmitting)
return <Loader title={props.loading.title} text={props.loading.subtitle} />;
return (
<Flex
ref={formRef}
@@ -876,57 +873,74 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
}
}}
sx={{
flex: 1,
flexDirection: "column",
size: "100%",
alignItems: "center",
justifyContent: "center",
width: ["95%", "95%", "45%"],
alignSelf: "center"
justifyContent: "center"
}}
>
<Text variant={"heading"} sx={{ fontSize: 32, textAlign: "center" }}>
{title}
</Text>
<Text
variant="body"
mt={2}
mb={35}
<Flex
sx={{
fontSize: "title",
textAlign: "center",
color: "var(--paragraph-secondary)"
flexDirection: "column",
width: ["95%", "95%", "550px"],
background: "var(--background)",
p: 6,
my: 10,
borderRadius: "15px",
border: "1px solid var(--border)",
boxShadow: "0px 0px 10px 0px #00000019"
}}
>
{subtitle}
</Text>
{typeof children === "function" ? children(form) : children}
{canSkip && (
<Button
type="button"
variant="anchor"
<Text variant={"heading"} sx={{ fontSize: 32 }}>
{title}
</Text>
<Text
variant="body"
mt={2}
mb={2}
sx={{
mt: 5,
color: "paragraph",
textDecoration: "none",
position: "absolute",
top: 0,
right: 5
}}
onClick={async () => {
const result = await ConfirmDialog.show({
title: strings.offlineMode(),
message: strings.offlineModeDesc(),
negativeButtonText: strings.cancel(),
positiveButtonText: strings.understand()
});
if (result) openURL("/notes/", { authenticated: false });
fontSize: "title",
color: "var(--paragraph-secondary)"
}}
>
{strings.skipAndGoToApp()}
</Button>
)}
{subtitle}
</Text>
{canSkip && (
<Button
type="button"
variant="secondary"
sx={{
position: "absolute",
top: 4,
right: 4,
bg: "transparent",
border: "2px solid var(--border)",
borderRadius: "default",
px: 2
}}
onClick={async () => {
const result = await ConfirmDialog.show({
title: strings.offlineMode(),
message: strings.offlineModeDesc(),
negativeButtonText: strings.cancel(),
positiveButtonText: strings.understand()
});
if (result) openURL("/notes/", { authenticated: false });
}}
>
{strings.skipAndGoToApp()}
</Button>
)}
{isSubmitting ? (
<Loader title={props.loading.title} text={props.loading.subtitle} />
) : typeof children === "function" ? (
children(form)
) : (
children
)}
<ErrorText error={error} mt={5} />
<ErrorText error={error} mt={5} />
</Flex>
</Flex>
);
}
@@ -948,8 +962,7 @@ function SubtitleWithAction(props: SubtitleWithActionProps) {
sx={{
textDecoration: "underline",
fontWeight: "bold",
fontSize: "subtitle",
color: "paragraph",
fontSize: "title",
cursor: "pointer"
}}
onClick={props.action.onClick}
@@ -969,7 +982,11 @@ export function AuthField(props: FieldProps) {
data-test-id={props["data-test-id"] || props.id}
sx={{ mt: 2, width: "100%" }}
styles={{
// label: { fontWeight: "normal" },
label: { fontWeight: "normal", fontSize: "subtitle" },
helpText: {
fontSize: "body",
my: "2px"
},
input: {
p: "12px",
borderRadius: "default",
@@ -992,21 +1009,20 @@ type SubmitButtonProps = {
text: string;
disabled?: boolean;
loading?: boolean;
sx?: Record<string, unknown>;
};
export function SubmitButton(props: SubmitButtonProps) {
return (
<Button
data-test-id="submitButton"
type="submit"
mt={50}
variant="accent"
px={50}
sx={{
borderRadius: 50,
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
display: "flex"
alignSelf: "stretch",
py: 2,
mt: 3,
fontSize: "subtitle",
...props.sx
}}
disabled={props.disabled}
>

View File

@@ -356,16 +356,16 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
autoFocus
defaultValue={formData?.recoveryKey || ""}
/>
<Flex sx={{ gap: 1 }}>
<Flex sx={{ gap: 1, mt: 3 }}>
<Button
variant="secondary"
type="button"
sx={{ mt: 50, borderRadius: 50 }}
sx={{ flex: 1, py: 2, fontSize: "subtitle" }}
onClick={() => navigate("methods")}
>
{strings.back()}
</Button>
<SubmitButton text={strings.startAccountRecovery()} />
<SubmitButton text={strings.startAccountRecovery()} sx={{ flex: 1, mt: 0 }} />
</Flex>
<Button
@@ -447,11 +447,11 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
label={strings.confirmPassword()}
defaultValue={form?.confirmPassword}
/>
<Flex sx={{ gap: 1 }}>
<Flex sx={{ gap: 1, mt: 3 }}>
<Button
variant="secondary"
type="button"
sx={{ mt: 50, borderRadius: 50 }}
sx={{ flex: 1, py: 2, fontSize: "subtitle" }}
onClick={() =>
navigate(
formData?.userResetRequired ? "methods" : "method:key",
@@ -461,7 +461,7 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
>
{strings.back()}
</Button>
<SubmitButton text={strings.continue()} />
<SubmitButton text={strings.continue()} sx={{ flex: 1, mt: 0 }} />
</Flex>
</>
)}
@@ -539,31 +539,41 @@ export function RecoveryForm<T extends RecoveryRoutes>(
}
}}
sx={{
flex: 1,
flexDirection: "column",
size: "100%",
alignItems: "center",
justifyContent: "center",
width: ["95%", 420],
alignSelf: "center"
justifyContent: "center"
}}
>
<Text variant={"heading"} sx={{ fontSize: 32, textAlign: "center" }}>
{title}
</Text>
<Text
variant="body"
mt={2}
mb={35}
<Flex
sx={{
fontSize: "title",
textAlign: "center",
color: "var(--paragraph-secondary)"
flexDirection: "column",
width: ["95%", "95%", "550px"],
background: "var(--background)",
p: 6,
my: 10,
borderRadius: "15px",
border: "1px solid var(--border)",
boxShadow: "0px 0px 10px 0px #00000019"
}}
>
{subtitle}
</Text>
{typeof children === "function" ? children(form) : children}
<ErrorText error={error} sx={{ mt: 2 }} />
<Text variant={"heading"} sx={{ fontSize: 32 }}>
{title}
</Text>
<Text
variant="body"
mt={2}
mb={2}
sx={{
fontSize: "title",
color: "var(--paragraph-secondary)"
}}
>
{subtitle}
</Text>
{typeof children === "function" ? children(form) : children}
<ErrorText error={error} sx={{ mt: 2 }} />
</Flex>
</Flex>
);
}

5
docs/help/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.vitepress/dist
.vitepress/cache
contents/v*/
.vitepress/sidebars/generated.mjs
.wrangler

View File

@@ -0,0 +1,222 @@
import { defineConfig } from "vitepress";
import { tabsMarkdownPlugin } from "vitepress-plugin-tabs";
import taskLists from "markdown-it-task-lists";
import { sidebar } from "./sidebar.mjs";
import {
LATEST,
isArchivedPath,
versionOfPath,
versionsNavItem
} from "./versions.mjs";
// Latest docs live at the root; the /v<version>/ trees and their sidebars are
// composed from contents/_versions/ by scripts/build-versions.mjs, which runs
// before dev and build.
import { archivedSidebars } from "./sidebars/generated.mjs";
import { seoHead, seoTitle } from "./seo.mjs";
import { stringsMarkdownPlugin } from "./strings.mjs";
export default defineConfig({
title: "Notesnook Help",
description:
"Your complete and free resource to using Notesnook as a daily note taking app to organize your work and life while safeguarding your privacy.",
lang: "en-US",
srcDir: "./contents",
base: "/help/",
outDir: "./.vitepress/dist/help",
// Version overrides are source material for build-versions.mjs, not pages.
// The Standard Notes importer is unpublished for now — the page is kept in
// the repo but is not built, linked or listed in the sitemap. Delete the
// second entry (and restore the sidebar link) to publish it again.
srcExclude: [
"_versions/**",
"importing-notes/import-notes-from-standardnotes.md",
"self-hosting.md"
],
cleanUrls: true,
lastUpdated: true,
metaChunk: true,
sitemap: {
hostname: "https://notesnook.com/help",
// Only the latest docs belong in the sitemap.
transformItems: (items) =>
items.filter(
(i) => !isArchivedPath(`/${i.url}`) && !i.url.startsWith("404")
)
},
transformPageData(pageData, ctx) {
const path = `/${pageData.relativePath}`;
pageData.frontmatter.head ??= [];
// Archived pages are kept out of search engines so they don't compete with
// the latest docs, and are tagged so the layout can show a version banner.
if (isArchivedPath(path)) {
pageData.frontmatter.archivedVersion = versionOfPath(path);
pageData.frontmatter.latestVersion = LATEST;
pageData.frontmatter.head.push([
"meta",
{ name: "robots", content: "noindex,follow" }
]);
return;
}
// Canonical, Open Graph, Twitter cards and JSON-LD for the live docs.
seoTitle(pageData);
pageData.frontmatter.head.push(...seoHead(pageData, ctx));
},
head: [
["link", { rel: "icon", href: "/favicon.ico" }],
// The two weights that render above the fold on every page.
[
"link",
{
rel: "preload",
href: "/help/fonts/Inter-Regular.woff2",
as: "font",
type: "font/woff2",
crossorigin: ""
}
],
[
"link",
{
rel: "preload",
href: "/help/fonts/Inter-SemiBold.woff2",
as: "font",
type: "font/woff2",
crossorigin: ""
}
],
["meta", { name: "theme-color", content: "#008837" }],
["meta", { property: "og:type", content: "website" }],
["meta", { property: "og:site_name", content: "Notesnook Help" }],
["meta", { property: "og:image", content: "/logo.png" }],
[
"script",
{
async: "",
defer: "",
"data-website-id": "676a7449-2151-44f7-a8c7-3b0691cade30",
src: "https://aas.streetwriters.co/script.js",
"data-domains": "notesnook.com"
}
]
],
markdown: {
config(md) {
md.use(tabsMarkdownPlugin);
// `- [x] item` renders as a real checkbox instead of literal "[x]".
md.use(taskLists, { label: true, labelAfter: true });
// `{{archive}}` becomes the live label from packages/intl.
md.use(stringsMarkdownPlugin);
// An image that shares a line with text is a UI glyph ("press the ⋯
// button"), not a figure. Tag those so CSS can keep them in the line —
// :only-child can't be used for this because it ignores text nodes.
md.core.ruler.push("nn_inline_glyphs", (state) => {
for (const token of state.tokens) {
if (token.type !== "inline" || !token.children) continue;
// Line breaks split the inline token into segments. A screenshot on
// its own line inside a numbered step lives in the same inline token
// as the step's text, so "does this token contain text?" would wrongly
// shrink it — the question is whether text sits on *its* line.
let segment: typeof token.children = [];
const segments = [segment];
for (const child of token.children) {
if (child.type === "softbreak" || child.type === "hardbreak") {
segment = [];
segments.push(segment);
} else segment.push(child);
}
for (const line of segments) {
const sharesLineWithText = line.some(
(child) =>
(child.type === "text" && child.content.trim()) ||
child.type === "code_inline"
);
if (!sharesLineWithText) continue;
for (const child of line) {
if (child.type === "image")
child.attrJoin("class", "inline-glyph");
}
}
}
return true;
});
},
image: { lazyLoading: true }
},
themeConfig: {
logo: "/logo.png",
siteTitle: "Help",
nav: [
versionsNavItem,
{ text: "Downloads", link: "https://notesnook.com/downloads" },
{ text: "Pricing", link: "https://notesnook.com/pricing" },
{
text: "More",
items: [
{ text: "Notesnook", link: "https://notesnook.com" },
{ text: "Blog", link: "https://blog.notesnook.com" },
{ text: "Roadmap", link: "https://notesnook.com/roadmap" },
{ text: "Contact us", link: "https://notesnook.com/contact-us" },
{
text: "Report an issue",
link: "https://github.com/streetwriters/notesnook/issues/new/choose"
}
]
}
],
sidebar: { ...archivedSidebars, "/": sidebar },
search: {
provider: "local",
options: {
detailedView: true,
// Archived versions are excluded so a search for "archive a note" does
// not return the same article once per version.
_render(src, env, md) {
if (isArchivedPath(`/${env.relativePath}`)) return "";
return md.render(src, env);
}
}
},
outline: { level: [2, 3], label: "On this page" },
editLink: {
pattern:
"https://github.com/streetwriters/notesnook/edit/master/docs/help/contents/:path",
text: "Suggest an edit to this page"
},
lastUpdated: {
text: "Last updated",
formatOptions: { dateStyle: "medium", forceLocale: false }
},
socialLinks: [
{ icon: "github", link: "https://github.com/streetwriters/notesnook" },
{ icon: "mastodon", link: "https://mastodon.social/@notesnook" },
{ icon: "discord", link: "https://discord.com/invite/zQBK97EE22" },
{ icon: "x", link: "https://x.com/notesnook" }
],
footer: {
message:
'Made with care by <a href="https://streetwriters.co">Streetwriters</a>. Notesnook is <a href="https://github.com/streetwriters/notesnook">open source</a>.',
copyright: "Copyright © 2026 Streetwriters (Private) Limited"
},
docFooter: { prev: "Previous", next: "Next" },
externalLinkIcon: true,
returnToTopLabel: "Back to top",
darkModeSwitchLabel: "Appearance"
}
});

View File

@@ -0,0 +1,206 @@
/**
* Per-page SEO: canonical URL, Open Graph, Twitter cards and JSON-LD.
*
* The help site ranks #1 for high-intent queries like "import enex", so every
* page needs to be individually addressable, individually described, and
* eligible for rich results. Driven from each page's frontmatter:
*
* ---
* title: Import from Evernote # sidebar label
* description: One sentence… # meta description + search snippet
* pageTitle: How to import Evernote… # optional: overrides the <title> only
* keywords: [import enex, evernote…] # optional
* schema: howto | faq | article # optional, default article
* faqs: # required when schema: faq
* - q: …
* a: …
* ---
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { HeadConfig, TransformPageContext, PageData } from "vitepress";
import { resolveString } from "./strings.mjs";
const SITE = "https://notesnook.com/help";
const OG_IMAGE = `${SITE}/logo.png`;
const url = (relativePath: string) =>
`${SITE}/${relativePath
.replace(/(index)?\.md$/, "")
.replace(/\/$/, "")}`.replace(/\/$/, "") || SITE;
/** "organizing-notes/archive-notes.md" -> ["Organizing notes", "Archive notes"] */
function breadcrumbs(relativePath: string, title: string) {
const parts = relativePath.split("/").slice(0, -1);
const crumbs = [{ name: "Notesnook Help", item: SITE }];
let path = "";
for (const part of parts) {
path += `/${part}`;
crumbs.push({
name: part.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase()),
item: `${SITE}${path}`
});
}
crumbs.push({ name: title, item: url(relativePath) });
return crumbs;
}
/**
* The page's markdown. `transformPageData`'s context does not carry the source,
* so it is read back off disk.
*/
function pageSource(relativePath: string) {
try {
return readFileSync(join(process.cwd(), "contents", relativePath), "utf8");
} catch {
return "";
}
}
/** Numbered list items in the first tab of a page become HowTo steps. */
const STRING_TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)(?::(\d+))?\s*\}\}/g;
function howToSteps(src: string) {
const steps: { name: string; text: string }[] = [];
for (const line of src.split("\n")) {
const m = line.match(/^\s*\d+\.\s+(.*\S)\s*$/);
if (!m) continue;
const text = m[1]
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
// These steps come from the raw markdown, before the markdown-it plugin
// has swapped `{{key}}` for the app's label — resolve them here too, or
// the structured data Google reads ships the raw tokens.
.replace(STRING_TOKEN, (_m, key: string, count?: string) =>
resolveString(key, count ? Number(count) : undefined)
)
.replace(/[`*_]/g, "")
.trim();
if (text.length > 3) steps.push({ name: text.slice(0, 110), text });
if (steps.length >= 12) break;
}
return steps;
}
function jsonLd(pageData: PageData, ctx: TransformPageContext) {
const fm = pageData.frontmatter;
const title = (fm.pageTitle || fm.title || pageData.title) as string;
const description = (fm.description || "") as string;
const pageUrl = url(pageData.relativePath);
const graph: Record<string, unknown>[] = [];
graph.push({
"@type": "BreadcrumbList",
itemListElement: breadcrumbs(pageData.relativePath, title).map((c, i) => ({
"@type": "ListItem",
position: i + 1,
name: c.name,
item: c.item
}))
});
const publisher = {
"@type": "Organization",
name: "Notesnook",
url: "https://notesnook.com",
logo: OG_IMAGE
};
if (fm.schema === "faq" && Array.isArray(fm.faqs) && fm.faqs.length) {
graph.push({
"@type": "FAQPage",
mainEntity: fm.faqs.map((f: { q: string; a: string }) => ({
"@type": "Question",
name: f.q,
acceptedAnswer: { "@type": "Answer", text: f.a }
}))
});
} else if (fm.schema === "howto") {
const steps = howToSteps(pageSource(pageData.relativePath));
if (steps.length)
graph.push({
"@type": "HowTo",
name: title,
description,
url: pageUrl,
step: steps.map((s, i) => ({
"@type": "HowToStep",
position: i + 1,
name: s.name,
text: s.text,
url: `${pageUrl}#${i + 1}`
})),
tool: [{ "@type": "HowToTool", name: "Notesnook" }],
totalTime: fm.totalTime || undefined
});
}
graph.push({
"@type": "TechArticle",
headline: title,
description,
url: pageUrl,
inLanguage: "en",
isPartOf: {
"@type": "WebSite",
name: "Notesnook Help",
url: SITE
},
about: {
"@type": "SoftwareApplication",
name: "Notesnook",
applicationCategory: "ProductivityApplication",
operatingSystem: "Windows, macOS, Linux, Android, iOS, Web"
},
author: publisher,
publisher,
dateModified: pageData.lastUpdated
? new Date(pageData.lastUpdated).toISOString()
: undefined
});
return JSON.stringify({ "@context": "https://schema.org", "@graph": graph });
}
/**
* Head tags for one page. Returned as frontmatter `head` entries so VitePress
* merges them into the rendered <head>.
*/
export function seoHead(
pageData: PageData,
ctx: TransformPageContext
): HeadConfig[] {
const fm = pageData.frontmatter;
if (fm.layout === "home" && !fm.description) return [];
const title = (fm.pageTitle || fm.title || pageData.title) as string;
const description = (fm.description || "") as string;
const pageUrl = url(pageData.relativePath);
const fullTitle = fm.pageTitle
? `${fm.pageTitle} | Notesnook Help`
: `${title} | Notesnook Help`;
const head: HeadConfig[] = [
["link", { rel: "canonical", href: pageUrl }],
["meta", { property: "og:title", content: fullTitle }],
["meta", { property: "og:description", content: description }],
["meta", { property: "og:url", content: pageUrl }],
["meta", { property: "og:image", content: OG_IMAGE }],
["meta", { name: "twitter:card", content: "summary" }],
["meta", { name: "twitter:title", content: fullTitle }],
["meta", { name: "twitter:description", content: description }]
];
if (Array.isArray(fm.keywords) && fm.keywords.length)
head.push(["meta", { name: "keywords", content: fm.keywords.join(", ") }]);
head.push(["script", { type: "application/ld+json" }, jsonLd(pageData, ctx)]);
return head;
}
/** The <title> tag: prefer an SEO-shaped `pageTitle` when the page defines one. */
export function seoTitle(pageData: PageData) {
if (pageData.frontmatter.pageTitle)
pageData.title = pageData.frontmatter.pageTitle as string;
}

View File

@@ -0,0 +1,320 @@
/**
* Help site navigation.
*
* A page that is not listed here is unreachable from the sidebar, so every new
* article needs an entry. `link` values are extensionless and root-absolute —
* they mirror the file path under `contents/`, which is also the public URL.
*/
export const sidebar = [
{
text: "Getting started",
collapsed: false,
items: [
{ text: "All help topics", link: "/docs" },
{ text: "Create your first note", link: "/create-a-note-in-notesnook" },
{ text: "Search & navigation", link: "/search-and-navigation" },
{ text: "Keyboard shortcuts", link: "/keyboard-shortcuts" },
{ text: "Plans & limits", link: "/plans-and-limits" }
]
},
{
text: "Organizing notes",
collapsed: false,
items: [
{
text: "Notebooks",
link: "/organizing-notes/organize-notes-using-notebooks"
},
{ text: "Tags", link: "/organizing-notes/organize-notes-using-tags" },
{ text: "Colors", link: "/organizing-notes/organize-notes-using-colors" },
{
text: "Favorites",
link: "/organizing-notes/organize-notes-using-favorites"
},
{ text: "Pins", link: "/organizing-notes/pin-notes" },
{ text: "Archive", link: "/organizing-notes/archive-notes" },
{
text: "Side menu shortcuts",
link: "/organizing-notes/side-menu-shortcuts"
},
{ text: "Reminders", link: "/reminders" }
]
},
{
text: "Working with notes",
collapsed: false,
items: [
{ text: "Note actions", link: "/notes/note-actions" },
{ text: "Note links", link: "/note-links-and-backlinks" },
{ text: "Expiring notes", link: "/notes/note-expiry" },
{ text: "Version history", link: "/note-version-history" },
{ text: "Trash", link: "/trash" }
]
},
{
text: "Editor",
collapsed: false,
items: [
{
text: "Editor toolbar",
link: "/rich-text-editor/rich-text-editor-toolbar"
},
{ text: "Tabs & panes", link: "/rich-text-editor/editor-tabs-and-panes" },
{
text: "Personalizing the editor",
link: "/rich-text-editor/personalizing-rich-text-editor"
},
{
text: "Markdown shortcuts",
link: "/rich-text-editor/markdown-notes-editing"
},
{
text: "Headings",
link: "/rich-text-editor/headings-and-collapsible-sections"
},
{ text: "Tables", link: "/rich-text-editor/tables" },
{ text: "Task lists", link: "/rich-text-editor/task-and-todo-lists" },
{ text: "Outline lists", link: "/rich-text-editor/outline-lists" },
{ text: "Callouts", link: "/rich-text-editor/callouts" },
{ text: "Code blocks", link: "/rich-text-editor/code-blocks" },
{ text: "Math & formulas", link: "/rich-text-editor/math-and-formulas" },
{
text: "Images & embeds",
link: "/rich-text-editor/images-attachments-and-embeds"
},
{ text: "Find & replace", link: "/rich-text-editor/search-and-replace" }
]
},
{
text: "Importing notes",
collapsed: false,
items: [
{ text: "Overview", link: "/importing-notes/" },
{ text: "Evernote", link: "/importing-notes/import-notes-from-evernote" },
{
text: "Google Keep",
link: "/importing-notes/import-notes-from-googlekeep"
},
{ text: "Joplin", link: "/importing-notes/import-notes-from-joplin" },
{ text: "Obsidian", link: "/importing-notes/import-notes-from-obsidian" },
{
text: "Simplenote",
link: "/importing-notes/import-notes-from-simplenote"
},
// Standard Notes is unpublished for now; the page is excluded from the
// build in config.mts. Restore this entry when it goes live again.
// {
// text: "Standard Notes",
// link: "/importing-notes/import-notes-from-standardnotes"
// },
{
text: "ColorNote",
link: "/importing-notes/import-notes-from-colornote"
},
{ text: "UpNote", link: "/importing-notes/import-notes-from-upnote" },
{
text: "Skiff Pages",
link: "/importing-notes/import-notes-from-skiff-pages"
},
{
text: "Zoho Notebook",
link: "/importing-notes/import-notes-from-zoho-notebook"
},
{
text: "Fusebase (Nimbus Note)",
link: "/importing-notes/import-notes-from-fusebase"
},
{
text: "Markdown files",
link: "/importing-notes/import-notes-from-markdown-files"
},
{
text: "HTML files",
link: "/importing-notes/import-notes-from-html-files"
},
{
text: "Plaintext files",
link: "/importing-notes/import-notes-from-plaintext-files"
},
{
text: "TextBundle files",
link: "/importing-notes/import-notes-from-textbundle-files"
}
]
},
{
text: "Backup & export",
collapsed: false,
items: [
{
text: "Backup and restore",
link: "/backup-and-restore-notes-in-notesnook"
},
{ text: "Exporting notes", link: "/export-notes-from-notesnook" },
{ text: "Attachments & files", link: "/attachments-and-files" }
]
},
{
text: "Sync",
collapsed: false,
items: [
{ text: "How sync works", link: "/sync/how-sync-works" },
{ text: "Sync settings", link: "/sync/sync-settings" },
{ text: "Troubleshooting sync", link: "/sync/troubleshooting-sync" }
]
},
{
text: "Privacy & security",
collapsed: false,
items: [
{ text: "How is my data encrypted?", link: "/how-is-my-data-encrypted" },
{ text: "Private vault", link: "/lock-notes-with-private-vault" },
{ text: "App lock", link: "/app-lock" },
{ text: "Two-factor authentication", link: "/two-factor-authentication" },
{ text: "Privacy mode", link: "/privacy-mode" }
]
},
{
text: "Publishing",
collapsed: false,
items: [{ text: "Monographs", link: "/publish-notes-with-monographs" }]
},
{
text: "Web clipper",
collapsed: false,
items: [
{ text: "Installation", link: "/web-clipper/installation" },
{
text: "Clipping your first page",
link: "/web-clipper/clipping-your-first-web-page-with-web-clipper"
},
{ text: "Troubleshooting", link: "/web-clipper/troubleshooting" }
]
},
{
text: "Mobile",
collapsed: false,
items: [
{
text: "Home screen widgets",
link: "/mobile-integration/home-screen-widgets"
},
{
text: "Android quick actions",
link: "/mobile-integration/android-quick-actions"
},
{
text: "Pin to notifications",
link: "/mobile-integration/pin-notes-to-notifications"
},
{
text: "Quick notes",
link: "/mobile-integration/quick-note-from-notification"
},
{
text: "Share from other apps",
link: "/mobile-integration/share-things-from-other-apps"
}
]
},
{
text: "Desktop",
collapsed: false,
items: [
{
text: "Auto start",
link: "/desktop-integration/auto-start-on-system-startup"
},
{
text: "System tray menu",
link: "/desktop-integration/system-tray-menu"
},
{
text: "Jumplist & dock menu",
link: "/desktop-integration/jumplist-and-dock-menu"
},
{ text: "Spell checker", link: "/desktop-integration/spell-checker" },
{
text: "Updates & advanced",
link: "/desktop-integration/updates-and-advanced-settings"
}
]
},
{
text: "Appearance & themes",
collapsed: false,
items: [
{ text: "Customizing the app", link: "/customizing-notesnook" },
{
text: "Using themes",
link: "/custom-themes/using-themes",
items: [
{ text: "How themes work", link: "/custom-themes/introduction" },
{
text: "Theme Builder",
link: "/custom-themes/create-a-theme-with-theme-builder"
},
{
text: "Install from file",
link: "/custom-themes/install-a-theme-from-file"
},
{
text: "Publish a new theme",
link: "/custom-themes/publish-a-theme"
}
]
}
]
},
{
text: "Your account",
collapsed: false,
items: [
{ text: "Account settings", link: "/account-settings" },
{ text: "Notesnook Circle", link: "/notesnook-circle" },
{ text: "Recovering your account", link: "/recovering-your-account" },
{ text: "Deleting your account", link: "/deleting-your-account" },
{ text: "Gift cards", link: "/gift-cards" },
{ text: "Notesnook Wrapped", link: "/notesnook-wrapped" }
]
},
{
text: "Advanced",
collapsed: false,
items: [
{
text: "Inbox API",
items: [
{
text: "Getting started",
link: "/inbox-api/getting-started"
},
{
text: "Self-hosting the Inbox API",
link: "/inbox-api/self-hosting-inbox-api"
}
]
}
// { text: "Self-hosting Notesnook", link: "/self-hosting" }
]
},
{
text: "FAQs",
collapsed: false,
items: [
{
text: "What are merge conflicts?",
link: "/faqs/what-are-merge-conflicts"
},
{ text: "Is there an ETA for X feature?", link: "/faqs/is-there-an-eta" },
{
text: "Why login is needed to upload attachments",
link: "/faqs/login-to-upload-attachments"
},
{
text: "Why login is needed to restore attachments",
link: "/faqs/login-to-restore-attachments-in-backup"
}
]
}
];

View File

@@ -0,0 +1,131 @@
/**
* Live UI strings, straight from the app.
*
* The docs quote hundreds of button and menu labels. Typing them by hand means
* they rot the moment someone renames a string, so pages write a key instead:
*
* Click on `{{archive}}` -> Click on `Archive`
*
* The key is resolved at build time from `@notesnook/intl` — the same catalogue
* the apps render from — so renaming a string in the app updates every page that
* quotes it on the next build. An unknown key fails the build rather than
* shipping a placeholder.
*
* This only *reads* the catalogue. Never add strings to `packages/intl` for the
* docs' sake: if a label has no string, write it as plain text and say why.
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { i18n } from "@lingui/core";
import { strings, setI18nGlobal } from "@notesnook/intl";
const require = createRequire(import.meta.url);
// The compiled English catalogue lives beside the package's dist output.
const localePath = require.resolve("@notesnook/intl/locales/$en.json");
const locale = JSON.parse(readFileSync(localePath, "utf8"));
i18n.load({ en: locale.messages });
i18n.activate("en");
setI18nGlobal(i18n);
export type StringKey = keyof typeof strings;
const cache = new Map<string, string>();
/**
* Resolve one key to the English text the app shows.
*
* A few catalogue entries are plural forms that take a count — quote those as
* `{{notebooks:2}}` and the number is passed through.
*/
export function resolveString(key: string, count?: number): string {
const cacheKey = count === undefined ? key : `${key}:${count}`;
const cached = cache.get(cacheKey);
if (cached !== undefined) return cached;
const entry = (strings as Record<string, unknown>)[key];
if (typeof entry !== "function")
throw new Error(
`Unknown UI string "${key}". It must be an existing key in packages/intl ` +
`(see strings.ts). Do not invent one — write the label as plain text instead.`
);
let value: unknown;
try {
value =
count === undefined
? (entry as () => unknown)()
: (entry as (n: number) => unknown)(count);
} catch {
throw new Error(
`UI string "${key}" needs arguments. If it is a plural, quote it as ` +
`{{${key}:2}}; otherwise write the label as plain text.`
);
}
if (typeof value !== "string" || !value.trim())
throw new Error(`UI string "${key}" did not resolve to text.`);
cache.set(cacheKey, value);
return value;
}
/**
* Reverse index: rendered text -> the key(s) that produce it. Used by
* `scripts/check-strings.mjs` to find hardcoded labels that could be keys.
*/
export function buildReverseIndex(): Map<string, string[]> {
const index = new Map<string, string[]>();
for (const key of Object.keys(strings)) {
let value: unknown;
try {
value = (strings as Record<string, () => unknown>)[key]();
} catch {
continue; // needs arguments
}
if (typeof value !== "string" || !value.trim()) continue;
const existing = index.get(value);
if (existing) existing.push(key);
else index.set(value, [key]);
}
return index;
}
/** Every key used across the docs this build, for reporting. */
export const usedKeys = new Set<string>();
const TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)(?::(\d+))?\s*\}\}/g;
/**
* markdown-it rule: swap `{{key}}` for the live string while parsing, so the
* rendered HTML contains real text and Vue never sees a moustache.
*/
export function stringsMarkdownPlugin(md: any) {
md.core.ruler.push("nn_ui_strings", (state: any) => {
const where = state.env?.relativePath ? ` in ${state.env.relativePath}` : "";
const swap = (text: string) =>
text.replace(TOKEN, (_match: string, key: string, count?: string) => {
try {
const value = resolveString(key, count ? Number(count) : undefined);
usedKeys.add(key);
return value;
} catch (error) {
throw new Error((error as Error).message + where);
}
});
for (const token of state.tokens) {
if (token.type === "inline" && token.children) {
for (const child of token.children) {
if (child.type === "text" || child.type === "code_inline")
child.content = swap(child.content);
}
} else if (token.type === "fence" || token.type === "html_block") {
// Leave code fences alone; a doc may legitimately show `{{ }}` syntax.
continue;
}
if (token.type === "inline") token.content = swap(token.content);
}
return true;
});
}

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* Every page on the site, grouped exactly as the sidebar groups them.
*
* The home page has no sidebar, so without this there is no way to see what the
* documentation actually covers. Reads the same sidebar module the site is
* built from, so it can never drift from the navigation.
*/
import { sidebar } from "../../sidebar.mjs";
import { withBase } from "vitepress";
type Item = { text: string; link?: string; items?: Item[] };
// Drop this page's own entry — listing the index inside the index is noise.
const groups = (sidebar as Item[]).map((group) => ({
...group,
items: group.items?.filter((item) => item.link !== "/docs")
}));
const pageCount = groups.reduce(
(total, group) => total + (group.items?.filter((i) => i.link).length ?? 0),
0
);
</script>
<template>
<div class="nn-index">
<p class="nn-index__count">
{{ pageCount }} pages, grouped by what you're trying to do.
</p>
<div class="nn-index__grid">
<section
v-for="group in groups"
:key="group.text"
class="nn-index__group"
>
<h2 class="nn-index__heading">{{ group.text }}</h2>
<ul class="nn-index__list">
<li v-for="item in group.items" :key="item.link || item.text">
<a v-if="item.link" :href="withBase(item.link)">{{ item.text }}</a>
<span v-else>{{ item.text }}</span>
</li>
</ul>
</section>
</div>
</div>
</template>
<style scoped>
.nn-index__count {
margin: 0 0 28px;
color: var(--vp-c-text-2);
}
.nn-index__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 28px 32px;
}
.nn-index__group {
break-inside: avoid;
}
.nn-index__heading {
margin: 0 0 10px;
padding: 0 0 8px;
border: none;
border-bottom: 1px solid var(--vp-c-divider);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--vp-c-text-3);
}
.nn-index__list {
margin: 0;
padding: 0;
list-style: none;
}
.nn-index__list li {
margin: 0 0 6px;
line-height: 1.5;
}
.nn-index__list a {
font-weight: 400;
text-decoration: none;
}
.nn-index__list a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* Conversion block for high-intent pages (importers, comparisons, "how do I…"
* pages that people land on from search). Renders real anchors so crawlers and
* no-JS clients follow them.
*/
withDefaults(
defineProps<{
title?: string;
text?: string;
/** Primary link target: "download" | "pricing" */
action?: string;
}>(),
{
title: "Ready to move your notes?",
text: "Notesnook is free to use, end-to-end encrypted by default, and open source. Install it on every device you own and your notes stay in sync — readable only by you.",
action: "download"
}
);
</script>
<template>
<aside class="nn-cta">
<p class="nn-cta__title">{{ title }}</p>
<p class="nn-cta__text">{{ text }}</p>
<p class="nn-cta__actions">
<a
v-if="action === 'download'"
class="nn-cta__button"
href="https://notesnook.com/downloads"
>Download Notesnook</a
>
<a
v-else
class="nn-cta__button"
href="https://notesnook.com/pricing"
>See plans and pricing</a
>
<a class="nn-cta__link" href="/plans-and-limits">What's included in each plan</a>
</p>
</aside>
</template>
<style scoped>
.nn-cta {
margin: 32px 0;
padding: 20px 24px;
border: 1px solid var(--vp-c-divider);
border-left: 3px solid var(--nn-accent);
border-radius: var(--nn-radius-large);
background-color: var(--vp-c-bg-alt);
}
.nn-cta__title {
margin: 0 0 6px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.nn-cta__text {
margin: 0 0 14px;
font-size: 15px;
line-height: 1.6;
color: var(--vp-c-text-2);
}
.nn-cta__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin: 0;
}
.nn-cta__button {
display: inline-block;
padding: 8px 16px;
border-radius: var(--nn-radius-button);
background-color: var(--nn-accent);
color: var(--nn-accent-foreground) !important;
font-size: 14px;
font-weight: 600;
text-decoration: none !important;
transition: background-color 100ms ease-out;
}
.nn-cta__button:hover {
background-color: #008837e6;
}
.nn-cta__link {
font-size: 14px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
const isMac = ref(false);
onMounted(() => {
isMac.value = /mac/i.test(navigator.platform || navigator.userAgent);
});
/**
* Open the site's own search modal. VitePress listens for a Cmd/Ctrl+K keydown
* on `window` and its nav button triggers search by dispatching exactly this
* synthetic event, so we reuse that path rather than reimplementing search.
*/
function openSearch() {
const event = new Event("keydown") as Event & { key: string; metaKey: boolean };
event.key = "k";
event.metaKey = true;
window.dispatchEvent(event);
}
</script>
<template>
<div class="nn-home-search">
<button
type="button"
class="nn-home-search__button"
aria-label="Search the documentation"
@click="openSearch"
>
<span class="nn-home-search__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path
d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"
/>
</svg>
</span>
<span class="nn-home-search__placeholder">Search the docs</span>
<kbd class="nn-home-search__key">{{ isMac ? "⌘" : "Ctrl" }} K</kbd>
</button>
<p class="nn-home-search__hint">
Try import from Evernote, app lock, or why is my note not syncing.
</p>
</div>
</template>
<style scoped>
.nn-home-search {
max-width: 640px;
/* The hero's own bottom padding stops here, so the space below the search box
has to come from this margin — without it the features grid rides up over
the hint text. */
margin: 16px auto 56px;
padding: 0 24px;
}
@media (max-width: 640px) {
.nn-home-search {
margin: 8px auto 40px;
}
}
.nn-home-search__button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 14px 16px;
border: 1.5px solid var(--vp-c-divider);
border-radius: var(--nn-radius-button, 10px);
background-color: var(--vp-c-bg);
color: var(--vp-c-text-3);
font-size: 16px;
text-align: left;
cursor: text;
transition: border-color 120ms ease-out, box-shadow 120ms ease-out;
}
.nn-home-search__button:hover,
.nn-home-search__button:focus-visible {
border-color: var(--nn-accent);
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
outline: none;
}
.nn-home-search__icon {
display: flex;
color: var(--vp-c-text-3);
}
.nn-home-search__placeholder {
flex: 1;
}
.nn-home-search__key {
flex-shrink: 0;
padding: 2px 6px;
border: 1px solid var(--vp-c-divider);
border-bottom-width: 2px;
border-radius: var(--nn-radius-default, 5px);
background-color: var(--vp-c-bg-alt);
font-family: var(--vp-font-family-mono);
font-size: 11px;
line-height: 1.6;
color: var(--vp-c-text-3);
}
.nn-home-search__hint {
margin: 10px 2px 0;
font-size: 13px;
color: var(--vp-c-text-3);
}
@media (max-width: 640px) {
.nn-home-search__key {
display: none;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
/** essential | pro | believer | free — the LOWEST plan that unlocks the feature. */
plan: string;
/** Set when the feature is limited to one platform, e.g. "Android only". */
note?: string;
}>();
const PLANS: Record<string, { label: string; title: string }> = {
free: {
label: "Free",
title: "Available on every plan, including Free"
},
essential: {
label: "Essential",
title: "Requires the Essential plan or higher (Essential, Pro, Believer)"
},
pro: {
label: "Pro",
title: "Requires the Pro plan or higher (Pro, Believer)"
},
believer: {
label: "Believer",
title: "Requires the Believer plan"
}
};
const tier = computed(() => PLANS[props.plan.toLowerCase()] ?? PLANS.pro);
</script>
<template>
<span class="nn-plan-tag ignore-header" :class="`nn-plan-tag--${plan.toLowerCase()}`" :title="tier.title">
{{ tier.label }}
<span v-if="note" class="nn-plan-tag__note">· {{ note }}</span>
</span>
</template>
<style scoped>
.nn-plan-tag {
display: inline-block;
vertical-align: middle;
margin-left: 6px;
padding: 1px 8px;
border-radius: 100px;
border: 1px solid transparent;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.7;
white-space: nowrap;
text-transform: uppercase;
cursor: help;
}
.nn-plan-tag__note {
font-weight: 500;
text-transform: none;
opacity: 0.85;
}
.nn-plan-tag--free {
background-color: var(--vp-c-bg-alt);
border-color: var(--vp-c-divider);
color: var(--vp-c-text-2);
}
.nn-plan-tag--essential,
.nn-plan-tag--pro,
.nn-plan-tag--believer {
background-color: var(--vp-c-brand-soft);
border-color: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
}
.nn-plan-tag--believer {
background-color: transparent;
border-color: var(--nn-accent);
}
h1 .nn-plan-tag,
h2 .nn-plan-tag,
h3 .nn-plan-tag {
position: relative;
top: -2px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from "vue";
import { useData } from "vitepress";
const { frontmatter, page } = useData();
const archived = computed(() => frontmatter.value.archivedVersion as string | undefined);
const latest = computed(() => frontmatter.value.latestVersion as string | undefined);
// The same article in the latest docs, if it still exists there.
const latestLink = computed(() => {
const path = page.value.relativePath
.replace(/^v[\d.]+\//, "/")
.replace(/(index)?\.md$/, "");
return path.startsWith("/") ? path : `/${path}`;
});
</script>
<template>
<div v-if="archived" class="nn-version-banner">
<p>
You are reading the documentation for <strong>Notesnook v{{ archived }}</strong>.
The current version is v{{ latest }}.
</p>
<a :href="latestLink">Read the latest version of this page </a>
</div>
</template>
<style scoped>
.nn-version-banner {
margin-bottom: 24px;
padding: 15px 20px;
border: 1px solid var(--vp-c-warning-soft);
border-left: 3px solid var(--vp-c-warning-1);
border-radius: var(--nn-radius-large);
background-color: var(--vp-custom-block-warning-bg);
font-size: 14px;
line-height: 1.6;
}
.nn-version-banner p {
margin: 0;
color: var(--vp-c-text-1);
}
.nn-version-banner a {
display: inline-block;
margin-top: 6px;
color: var(--vp-c-brand-1);
font-weight: 500;
text-decoration: none;
}
.nn-version-banner a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,77 @@
/**
* Self-hosted webfonts, matching the Notesnook app.
* Inter is the app's UI font (apps/web/src/app.css), Fira Code its code font.
*/
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: local(""), url("/fonts/Inter-Regular.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: local(""), url("/fonts/Inter-Medium.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: local(""), url("/fonts/Inter-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: swap;
src: local(""), url("/fonts/Inter-Bold.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 400;
font-display: swap;
src: local(""), url("/fonts/Inter-Italic.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 500;
font-display: swap;
src: local(""), url("/fonts/Inter-MediumItalic.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 600;
font-display: swap;
src: local(""), url("/fonts/Inter-SemiBoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 700;
font-display: swap;
src: local(""), url("/fonts/Inter-BoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "Fira Code";
font-style: normal;
font-weight: 400;
font-display: swap;
src: local(""), url("/fonts/fira-code-v21-latin-regular.woff2")
format("woff2");
}

View File

@@ -0,0 +1,31 @@
import type { Theme } from "vitepress";
// theme-without-fonts skips the default theme's own bundled Inter — we ship the
// exact Inter files the Notesnook app uses instead (see fonts.css).
import DefaultTheme from "vitepress/theme-without-fonts";
import { enhanceAppWithTabs } from "vitepress-plugin-tabs/client";
import { h } from "vue";
import VersionBanner from "./components/VersionBanner.vue";
import PlanTag from "./components/PlanTag.vue";
import GetNotesnook from "./components/GetNotesnook.vue";
import HomeSearch from "./components/HomeSearch.vue";
import DocsIndex from "./components/DocsIndex.vue";
import "./fonts.css";
import "./notesnook.css";
export default {
extends: DefaultTheme,
Layout: () =>
h(DefaultTheme.Layout, null, {
// Renders only on pages under an archived /v<version>/ tree.
"doc-before": () => h(VersionBanner),
// The home page has no sidebar, so search is the primary way in.
"home-hero-after": () => h(HomeSearch)
}),
enhanceApp({ app }) {
enhanceAppWithTabs(app);
// Usable directly in markdown, no per-page import.
app.component("PlanTag", PlanTag);
app.component("GetNotesnook", GetNotesnook);
app.component("DocsIndex", DocsIndex);
}
} satisfies Theme;

View File

@@ -0,0 +1,628 @@
/**
* Notesnook design tokens applied to VitePress.
*
* Values come from @notesnook/theme (default-light / default-dark v2.1) and
* apps/web:
* accent #008837 packages/theme default themes
* radii 2.5 / 5 / 7 / 10 packages/theme/src/theme/index.ts
* space scale 6 / 10 / 15 / 20 packages/theme/src/theme/index.ts
* shadows menu / dialog packages/theme/src/theme/index.ts
* fonts Inter, Fira Code apps/web/src/app.css
*
* Notesnook's UI is flat: 5px radii, hairline borders, hover fills instead of
* cards, and a single accent used sparingly. This file keeps VitePress in that
* register rather than layering a second design language on top of it.
*/
/* ========================================================================== */
/* Tokens */
/* ========================================================================== */
:root {
/* Notesnook primitives */
--nn-accent: #008837;
--nn-accent-foreground: #ffffff;
--nn-shade: #0088371a; /* alpha(accent, .1) */
--nn-radius-small: 2.5px;
--nn-radius-default: 5px;
--nn-radius-large: 7px;
--nn-radius-dialog: 10px;
--nn-radius-button: 10px;
--nn-space-1: 6px;
--nn-space-2: 10px;
--nn-space-3: 15px;
--nn-space-4: 20px;
--nn-space-5: 25px;
--nn-static-orange: #ff9800;
/* Typography */
--vp-font-family-base: "Inter", "Noto Sans", Frutiger, Calibri, Myriad, Arial,
Ubuntu, Helvetica, -apple-system, BlinkMacSystemFont, sans-serif;
--vp-font-family-mono: "Fira Code", "Fira Mono", Hack, Menlo, Consolas,
"Liberation Mono", "Courier New", monospace;
/* Layout — a help site reads better slightly narrower than VitePress' default */
--vp-layout-max-width: 1440px;
--vp-sidebar-width: 288px;
--vp-nav-height: 60px;
}
/* ------------------------------- light ---------------------------------- */
:root {
--vp-c-brand-1: #008837;
--vp-c-brand-2: #008837e6; /* app button hover — alpha(accent, .9) */
--vp-c-brand-3: #008837cc; /* app button active — alpha(accent, .8) */
--vp-c-brand-soft: var(--nn-shade);
--vp-c-bg: #ffffff;
--vp-c-bg-alt: #f7f7f7;
--vp-c-bg-soft: #f7f7f7;
--vp-c-bg-elv: #ffffff;
--vp-c-divider: #e8e8e8;
--vp-c-border: #e8e8e8;
--vp-c-gutter: #e8e8e8;
--vp-c-text-1: #202020; /* heading */
--vp-c-text-2: #505050; /* paragraph */
--vp-c-text-3: #777777; /* paragraph-secondary */
--nn-hover: #eeeeee;
--nn-selected-bg: #eeeeee;
--nn-selected-fg: #212121;
--nn-code-bg: #f7f7f7;
--nn-shadow-menu: 0px 0px 10px 0px #00000022;
--nn-shadow-dialog: 0px 0px 25px 5px #0000004e;
--vp-c-tip-1: #4f8a10;
--vp-c-tip-soft: #4f8a101a;
--vp-c-danger-1: #f54b42;
--vp-c-danger-soft: #f54b421a;
--vp-c-warning-1: #b26a00; /* darkened static orange for AA on white */
--vp-c-warning-soft: #ff98001f;
}
/* -------------------------------- dark ---------------------------------- */
.dark {
/* #008837 is only 3.06:1 on the app's #181818, so text-bearing brand steps
are lightened for readability. Solid accent fills stay exactly #008837. */
--vp-c-brand-1: #00b34a;
--vp-c-brand-2: #00c853;
--vp-c-brand-3: #008837;
--vp-c-brand-soft: #00883733;
--vp-c-bg: #181818;
--vp-c-bg-alt: #202020;
--vp-c-bg-soft: #202020;
--vp-c-bg-elv: #202020;
--vp-c-divider: #383838;
--vp-c-border: #2b2b2b;
--vp-c-gutter: #2b2b2b;
--vp-c-text-1: #e3e3e3;
--vp-c-text-2: #d3d3d3;
--vp-c-text-3: #818589;
--nn-hover: #2b2b2b;
--nn-selected-bg: #494949;
--nn-selected-fg: #fbfbfb;
--nn-code-bg: #202020;
--nn-shadow-menu: 0px 0px 10px 0px #00000078;
--nn-shadow-dialog: 0px 0px 25px 5px #000000aa;
--vp-c-tip-1: #7bb32e;
--vp-c-tip-soft: #4f8a1033;
--vp-c-danger-1: #f76b64;
--vp-c-danger-soft: #f54b4226;
--vp-c-warning-1: #ffa726;
--vp-c-warning-soft: #ff980026;
}
/* --------------------------- derived bindings ---------------------------- */
:root,
.dark {
--vp-button-brand-bg: var(--nn-accent);
--vp-button-brand-text: var(--nn-accent-foreground);
--vp-button-brand-border: transparent;
--vp-button-brand-hover-bg: #008837e6;
--vp-button-brand-hover-text: var(--nn-accent-foreground);
--vp-button-brand-hover-border: transparent;
--vp-button-brand-active-bg: #008837cc;
--vp-button-brand-active-text: var(--nn-accent-foreground);
--vp-code-bg: var(--nn-code-bg);
--vp-code-block-bg: var(--nn-code-bg);
--vp-code-copy-code-bg: var(--nn-code-bg);
--vp-code-copy-code-hover-bg: var(--nn-hover);
--vp-shadow-1: var(--nn-shadow-menu);
--vp-shadow-2: var(--nn-shadow-menu);
--vp-shadow-3: var(--nn-shadow-menu);
--vp-shadow-4: var(--nn-shadow-dialog);
--vp-shadow-5: var(--nn-shadow-dialog);
--vp-custom-block-tip-bg: var(--vp-c-tip-soft);
--vp-custom-block-warning-bg: var(--vp-c-warning-soft);
--vp-custom-block-danger-bg: var(--vp-c-danger-soft);
--vp-custom-block-info-bg: var(--vp-c-bg-alt);
--vp-custom-block-details-bg: var(--vp-c-bg-alt);
}
/* ========================================================================== */
/* Base */
/* ========================================================================== */
html {
scroll-behavior: smooth;
}
::selection {
background-color: #00883766;
color: var(--nn-selected-fg);
}
.vp-doc h1,
.vp-doc h2,
.vp-doc h3,
.vp-doc h4 {
letter-spacing: -0.015em;
font-weight: 600;
}
.vp-doc h1 {
font-size: 32px;
line-height: 1.25;
margin-bottom: var(--nn-space-3);
}
/* The app never underlines its own separators heavily — keep section rules
hairline and give sections room to breathe. */
.vp-doc h2 {
margin-top: 44px;
padding-top: var(--nn-space-4);
border-top: 1px solid var(--vp-c-divider);
font-size: 22px;
letter-spacing: -0.01em;
}
.vp-doc h3 {
margin-top: 28px;
font-size: 17px;
}
.vp-doc p,
.vp-doc li {
line-height: 1.7;
}
.vp-doc a {
font-weight: 500;
text-decoration: none;
text-underline-offset: 3px;
}
.vp-doc a:hover {
text-decoration: underline;
}
/* ========================================================================== */
/* Navigation & sidebar — modelled on the app's navigation menu */
/* ========================================================================== */
.VPNav {
backdrop-filter: saturate(160%) blur(12px);
}
.VPNavBar:not(.home.top) {
background-color: color-mix(in srgb, var(--vp-c-bg) 88%, transparent);
}
.VPSidebar {
background-color: var(--vp-c-bg-alt) !important;
padding-top: var(--nn-space-3);
}
.VPSidebarItem.level-0 > .item > .text {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--vp-c-text-3);
}
/* Flat rows with a hover fill — same treatment as list items in the app. */
.VPSidebarItem.is-link > .item .link,
.VPSidebarItem .item > .link {
border-radius: var(--nn-radius-default);
}
/* The hover fill bleeds left of the label so item text keeps the same left edge
as the group headers and the nav logo (32px), instead of sitting 10px in. */
.VPSidebarItem.level-1 .item,
.VPSidebarItem.level-2 .item,
.VPSidebarItem.level-3 .item {
border-radius: var(--nn-radius-default);
margin-left: calc(var(--nn-space-2) * -1);
padding-left: var(--nn-space-2);
padding-right: var(--nn-space-1);
transition: background-color 100ms ease-out;
}
.VPSidebarItem.level-1:not(.is-active) .item:hover,
.VPSidebarItem.level-2:not(.is-active) .item:hover,
.VPSidebarItem.level-3:not(.is-active) .item:hover {
background-color: var(--nn-hover);
}
.VPSidebarItem.is-active > .item {
background-color: var(--nn-selected-bg);
}
.VPSidebarItem.is-active > .item > .indicator {
background-color: var(--nn-accent);
}
.VPSidebarItem.is-active > .item .link .text {
color: var(--nn-selected-fg);
font-weight: 600;
}
.VPSidebarItem .text {
font-size: 14px;
}
/* ========================================================================== */
/* Buttons */
/* ========================================================================== */
/* One radius for every button on the site. `!important` is needed because the
default theme's own button rules carry a scoped [data-v-*] attribute, which
outranks a plain class selector. */
.VPButton,
.VPButton.small,
.VPButton.medium,
.VPButton.big,
.VPNavBarSearchButton,
.DocSearch-Button,
.VPNavBarHamburger,
.VPLocalNav button,
.VPBackToTop,
.VPSidebarItem .caret,
.VPLocalSearchBox button,
.VPDocFooter button,
[class*="language-"] > button.copy,
[class*="language-"] > span.lang + button,
.vp-doc button {
border-radius: var(--nn-radius-button) !important;
}
/* ========================================================================== */
/* Home page */
/* ========================================================================== */
.VPHero .image-bg {
display: none;
}
/* The default theme nudges the hero image up and left by 32px to optically
centre it against the blurred `image-bg` circle behind it. We hide that
circle, so the nudge has nothing left to compensate for — it just lifts the
logo above the middle of the text column, leaving it centred on the heading
and tagline with the action buttons hanging below. Dropping the transform
centres it against the whole column: heading, tagline and actions. */
@media (min-width: 960px) {
.VPHero .image-container {
transform: none;
}
}
.VPHero .image-container img {
max-width: 208px;
max-height: 208px;
border-radius: 44px;
box-shadow: var(--nn-shadow-dialog);
}
.VPHome .VPFeature {
border-radius: var(--nn-radius-large);
border-color: var(--vp-c-divider);
transition: border-color 120ms ease-out, background-color 120ms ease-out;
}
.VPHome .VPFeature:hover {
border-color: var(--nn-accent);
background-color: var(--vp-c-bg-alt);
}
/* ========================================================================== */
/* Custom blocks */
/* ========================================================================== */
.vp-doc .custom-block {
border-radius: var(--nn-radius-large);
border: 1px solid var(--vp-c-divider);
border-left-width: 3px;
padding: var(--nn-space-3) var(--nn-space-4);
font-size: 15px;
}
.vp-doc .custom-block .custom-block-title {
font-weight: 600;
letter-spacing: -0.01em;
}
.vp-doc .custom-block p {
line-height: 1.65;
}
.vp-doc .custom-block.info {
border-color: var(--vp-c-divider);
border-left-color: var(--vp-c-text-3);
}
.vp-doc .custom-block.tip {
border-color: var(--vp-c-tip-soft);
border-left-color: var(--vp-c-tip-1);
}
.vp-doc .custom-block.warning {
border-color: var(--vp-c-warning-soft);
border-left-color: var(--vp-c-warning-1);
}
.vp-doc .custom-block.danger {
border-color: var(--vp-c-danger-soft);
border-left-color: var(--vp-c-danger-1);
}
.vp-doc .custom-block.details {
border-left-color: var(--vp-c-text-3);
}
.vp-doc .custom-block img {
margin-top: var(--nn-space-2);
}
/* ========================================================================== */
/* Platform tabs (vitepress-plugin-tabs) */
/* ========================================================================== */
.vp-doc .plugin-tabs {
border-radius: var(--nn-radius-large);
border: 1px solid var(--vp-c-divider);
background-color: transparent;
box-shadow: none;
overflow: hidden;
}
.vp-doc .plugin-tabs--tab-list {
background-color: var(--vp-c-bg-alt);
border-bottom: 1px solid var(--vp-c-divider);
padding: var(--nn-space-1) var(--nn-space-1) 0;
}
.vp-doc .plugin-tabs--tab {
font-size: 13px;
font-weight: 500;
color: var(--vp-c-text-2);
padding: var(--nn-space-1) var(--nn-space-2);
border-radius: var(--nn-radius-default) var(--nn-radius-default) 0 0;
transition: color 100ms ease-out, background-color 100ms ease-out;
}
.vp-doc .plugin-tabs--tab:hover {
background-color: var(--nn-hover);
color: var(--vp-c-text-1);
}
.vp-doc .plugin-tabs--tab[aria-selected="true"] {
color: var(--vp-c-brand-1);
font-weight: 600;
background-color: var(--vp-c-bg);
}
.vp-doc .plugin-tabs--tab::after {
background-color: transparent;
height: 2px;
}
.vp-doc .plugin-tabs--tab[aria-selected="true"]::after {
background-color: var(--nn-accent);
}
.vp-doc .plugin-tabs--content {
padding: var(--nn-space-4);
}
.vp-doc .plugin-tabs--content > :first-child {
margin-top: 0;
}
.vp-doc .plugin-tabs--content > :last-child {
margin-bottom: 0;
}
/* Nested tabs inside a custom block should not double up on chrome. */
.vp-doc .custom-block .plugin-tabs {
background-color: var(--vp-c-bg);
}
/* ========================================================================== */
/* Media, code, tables */
/* ========================================================================== */
.vp-doc img {
border-radius: var(--nn-radius-large);
border: 1px solid var(--vp-c-divider);
max-width: 100%;
}
/* The CSS reset makes every image `display: block`, which breaks the line when a
UI glyph is referenced mid-sentence ("press the ⋯ button"). An image sharing
its *line* with text is treated as a glyph — inline, sized to the text,
unframed. An image on its own line stays a block, even when it sits inside a
numbered step whose text is on the line above. */
.vp-doc img.inline-glyph {
display: inline-block;
vertical-align: text-bottom;
max-height: 1.5em;
width: auto;
margin: 0 2px;
border: none;
border-radius: var(--nn-radius-small);
}
/* ---------------------------- task lists -------------------------------- */
.vp-doc .contains-task-list {
padding-left: 0;
list-style: none;
}
.vp-doc .task-list-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.vp-doc .task-list-item-checkbox {
appearance: none;
flex-shrink: 0;
width: 16px;
height: 16px;
margin: 6px 0 0;
border: 1.5px solid var(--vp-c-divider);
border-radius: var(--nn-radius-small);
background-color: var(--vp-c-bg);
}
.vp-doc .task-list-item-checkbox:checked {
border-color: var(--nn-accent);
background-color: var(--nn-accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
background-size: 12px;
background-position: center;
background-repeat: no-repeat;
}
.vp-doc .task-list-item label {
cursor: default;
}
.vp-doc img[src$=".svg"] {
border: none;
}
.vp-doc [class*="language-"] {
border-radius: var(--nn-radius-large);
border: 1px solid var(--vp-c-divider);
}
.vp-doc code {
font-size: 0.875em;
border-radius: var(--nn-radius-small);
padding: 3px 5px;
}
.vp-doc :not(pre) > code {
color: var(--vp-c-text-1);
background-color: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
}
.vp-doc kbd {
display: inline-block;
padding: 2px 6px;
border-radius: var(--nn-radius-default);
border: 1px solid var(--vp-c-divider);
border-bottom-width: 2px;
background-color: var(--vp-c-bg-alt);
font-family: var(--vp-font-family-mono);
font-size: 12px;
line-height: 1.6;
}
.vp-doc table {
display: table;
width: 100%;
border-radius: var(--nn-radius-large);
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-collapse: separate;
border-spacing: 0;
}
.vp-doc th,
.vp-doc td {
border: none;
border-bottom: 1px solid var(--vp-c-divider);
}
.vp-doc tr:last-child td {
border-bottom: none;
}
.vp-doc tr:nth-child(2n) {
background-color: transparent;
}
.vp-doc tr:hover td {
background-color: var(--nn-hover);
}
.vp-doc th {
background-color: var(--vp-c-bg-alt);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.01em;
}
/* ========================================================================== */
/* Search, footer nav */
/* ========================================================================== */
.VPLocalSearchBox .search-bar {
border-radius: var(--nn-radius-default);
border: 1.5px solid var(--vp-c-divider);
}
.VPLocalSearchBox .search-bar:focus-within {
border-color: var(--nn-accent);
border-width: 2px;
}
.VPLocalSearchBox .result {
border-radius: var(--nn-radius-default);
}
.pager-link {
border-radius: var(--nn-radius-large);
border-color: var(--vp-c-divider);
transition: background-color 100ms ease-out, border-color 100ms ease-out;
}
.pager-link:hover {
background-color: var(--vp-c-bg-alt);
border-color: var(--nn-accent);
}
.VPDocFooter .edit-link-button {
font-weight: 500;
}
@media (max-width: 640px) {
.vp-doc h1 {
font-size: 27px;
}
.vp-doc h2 {
font-size: 20px;
}
.vp-doc .plugin-tabs--content,
.vp-doc .custom-block {
padding: var(--nn-space-3);
}
}

View File

@@ -0,0 +1,36 @@
/**
* Documentation versions.
*
* The **latest** version is served from the site root (`/create-a-note`,
* `/organizing-notes/...`) so canonical URLs never move. Older versions are
* served from `/v<version>/`.
*
* Older versions are stored as *differences*, not copies. `contents/_versions/`
* holds only the pages whose content actually differs from the current docs;
* every other page is shared, and the full `/v<version>/` tree is composed at
* build time by `scripts/build-versions.mjs`.
*
* Cutting a new version: npm run version -- 3.5
* Changing a page afterwards: npm run fork -- 3.4 <page> (before editing)
*/
/** The version the docs at the site root describe. */
export const LATEST = "3.4";
/** Older versions, newest first. */
export const ARCHIVED = [];
export const isArchivedPath = (path) =>
ARCHIVED.some((v) => path.startsWith(`/v${v}/`));
export const versionOfPath = (path) =>
ARCHIVED.find((v) => path.startsWith(`/v${v}/`)) ?? LATEST;
/** The version picker shown in the nav bar. */
export const versionsNavItem = {
text: `v${LATEST}`,
items: [
{ text: `v${LATEST} (latest)`, link: "/" },
...ARCHIVED.map((v) => ({ text: `v${v}`, link: `/v${v}/` }))
]
};

85
docs/help/README.md Normal file
View File

@@ -0,0 +1,85 @@
# Notesnook Help
The source of [notesnook.com/help](https://notesnook.com/help), built with [VitePress](https://vitepress.dev).
```bash
npm install
npm run dev # http://localhost:5173
npm run build # production build; fails on dead internal links
npm run preview # serve the built site
```
## Where things are
| | |
| ------------------------ | --------------------------------------------------- |
| `contents/` | the articles — a file's path here is its public URL |
| `contents/public/` | images and fonts, served from `/` |
| `.vitepress/config.mts` | site config, nav, head |
| `.vitepress/sidebar.mjs` | the sidebar — **add every new article here** |
| `.vitepress/theme/` | Notesnook design tokens and self-hosted fonts |
## UI labels come from the app
Button and menu labels are written as string keys and resolved at build time from `@notesnook/intl`:
```md
Click on `{{archive}}` to archive the note.
```
Rename that string in the app and this page updates on the next build. An unknown key fails the build. Run `npm run strings` to see which hardcoded labels could become keys (`-- --fix` rewrites them). Don't add strings to `packages/intl` for the docs — if there's no key, write plain text.
## Writing an article
1. Create `contents/<section>/<slug>.md` with `title` (short, used in the sidebar) and `description` (one sentence, used as the search snippet) frontmatter.
2. Add it to the right group in `.vitepress/sidebar.mjs`.
3. Run `npm run build` before opening a PR.
Steps that differ per platform go in tabs, which stay in sync across the whole site via `key:platform`:
```md
:::tabs key:platform
== Desktop/Web
1. Right click on a note to open the `Note properties` menu.
== Mobile
1. Press the three dot button on a note.
:::
```
Callouts use VitePress containers — `::: info`, `::: tip`, `::: warning`, `::: danger`, `::: details`.
Renaming or moving a file changes a live URL that the apps and support replies link to. Don't, unless a 301 goes into `contents/public/_redirects` with it. Some pages are linked from inside the app via `packages/intl/src/strings.ts`, and the importer package links to the `importing-notes/*` slugs — grep both before touching a slug.
## Versioning
The docs are versioned by Notesnook version. The **latest** version lives at the site root, so canonical URLs never move; older versions are served from `/v<version>/` and reachable from the version picker in the nav bar.
Older versions are stored as **differences, not copies**. A page is shared by every version until it actually changes; only then does the old text get its own file. `.vitepress/versions.mjs` holds `LATEST` and the list of older versions.
**When Notesnook ships a new version:**
```bash
npm run version -- <next-version>
```
Nothing is copied — the outgoing version becomes an older version whose pages are all still shared with the root.
**When you change a page in a way that doesn't apply to the old version**, preserve the old text first, then edit the root copy as usual:
```bash
npm run fork -- <old-version> organizing-notes/archive-notes
```
That writes `contents/_versions/<old-version>/organizing-notes/archive-notes.md` — the only file that version needs. For a page that didn't exist in an older version, add its path to `contents/_versions/<version>/_excluded.txt` instead.
`npm run versions` (run automatically before dev and build) composes the full `/v<version>/` trees from the shared pages plus those overrides. The composed trees live in `contents/v<version>/` and are gitignored — never edit them.
Archived pages carry a banner linking to the current version of the same page, are excluded from search and the sitemap, and are `noindex` so they don't compete with the latest docs. Images are shared across versions.
## Deployment
`.github/workflows/help.publish.yml` builds and deploys `.vitepress/dist/` to Cloudflare Pages on every push to `master` that touches `docs/help/**`.

138
docs/help/STYLE.md Normal file
View File

@@ -0,0 +1,138 @@
# Help docs — writing conventions
Every page on notesnook.com/help follows these. They cover accuracy, plan tags, SEO and linking.
## 1. Accuracy is non-negotiable
Never describe UI from memory. Every menu label, settings path, limit, default and plan gate must be traced to source in this monorepo before it is written:
- user-facing strings — `packages/intl/src/strings.ts`
- web/desktop UI — `apps/web/src/`, `apps/desktop/src/`
- mobile UI — `apps/mobile/app/`
- limits and plan gates — `packages/common/src/utils/is-feature-available.ts`
- behaviour, sync, encryption, retention — `packages/core/src/`
### Quote labels by key, not by hand
Don't type a label and hope it stays true. Write the **string key** and the build resolves it from the app's own catalogue:
```md
Click on `{{archive}}` renders: Click on `Archive`
Open `{{privacyAndSecurity}}` renders: Open `Privacy & security`
```
Keys come from `packages/intl/src/strings.ts` — the same catalogue the apps render from — so when someone renames a string in the app, every page quoting it updates on the next build. An unknown key **fails the build**; it never ships as a placeholder.
- Plural entries take a count: `{{notebooks:2}}`.
- `npm run strings` lists labels that could be keys but aren't; `-- --fix` rewrites the unambiguous ones.
- **Never add a string to `packages/intl` for the docs' sake.** If a label has no key — third-party UI, native OS text, a screen that isn't localized — write it as plain text.
If web and mobile differ, both go in the platform tabs. If you cannot verify something, leave it out and flag it — never guess.
## 2. Frontmatter
```yaml
---
title: Archive # short sidebar label, 23 words
pageTitle: How to archive notes… # optional: SEO <title>, ~60 chars
description: One sentence… # required, <160 chars, becomes the search snippet
keywords: # optional, real search phrases
- archive notes notesnook
schema: howto # optional: howto | faq | article (default article)
faqs: # required when schema: faq
- q:
a:
---
```
`schema: howto` turns the page's numbered steps into HowTo structured data automatically. `schema: faq` emits FAQPage structured data from the `faqs` list — the same Q&As must also appear in the page body.
## 3. Plan tags
Any feature that needs a paid plan is tagged inline, on the heading that introduces it:
```md
## Set a note to expire <PlanTag plan="pro" />
```
`plan` is the **lowest** plan that unlocks it: `essential`, `pro` or `believer`. Plans are cumulative — a `pro` tag means Pro and Believer. For platform-limited features add a note:
```md
## Pin a note to your notifications <PlanTag plan="pro" note="Android only" />
```
Verify the tier in `is-feature-available.ts` before tagging. Also state the consequence in prose where it matters ("free plans keep 100 versions per note"), and link to [Plans & limits](/plans-and-limits).
## 4. Article shape
```md
# Full human title
One or two sentences: what this is and why someone would want it.
## Task in imperative form
:::tabs key:platform
== Desktop/Web
1.
== Mobile
1.
:::
```
- One `# H1`, first line of the body.
- `##` per task, phrased as an action: "Archive a note", not "Archiving".
- Numbered steps, one action each, ideally ≤ 6.
- Close with the confirmation state — what the user should now see.
- `key:platform` is mandatory on platform tabs so the choice syncs sitewide. Labels: `Desktop/Web`, `Desktop`, `Web`, `Mobile`, `Android`, `iOS`, `Windows`, `macOS`, `Linux`.
- If a feature is missing on a platform, say so in that tab rather than omitting it.
## 5. Callouts
`::: info` context · `::: tip` shortcut · `::: warning` data loss or something irreversible · `::: danger` unrecoverable · `::: details` folded tangent.
Anything touching the vault, encryption, recovery or deletion must state plainly that **Notesnook cannot recover data or passwords for you**.
## 6. SEO
The help site already ranks #1 for high-intent queries like `import enex`, so each page is a landing page:
- **Write the H1 as the question a person types.** "How do I import notes from Evernote?" beats "Evernote importing".
- **Use real phrasings in `##` headings** — "Can I use it offline?", "Why is my note not syncing?" — they win featured snippets.
- **Answer in the first 40 words** after the H1. That paragraph is what Google quotes.
- **Never leave alt text empty.** Describe what the reader should look for: `![The Archive item in the note context menu](/img.png)`.
- **Every page ends with a `## Related pages` list** of 36 links with descriptive anchors (the home page, `/docs` and `/404` are exempt — they are already link lists) — "[backing up your notes](/backup-and-restore-notes-in-notesnook)", never "click here". This is what builds the internal link graph.
- **Link the first mention** of any concept that has its own page, in body text, with the concept as the anchor.
- **Add `<GetNotesnook />`** to pages people arrive at from search with buying intent — importers, comparisons, "how do I move from X" — placed after the instructions, never before them.
- Prefer one page that fully answers a question over three thin pages.
## 7. Internal linking clusters
Pages are grouped into clusters, each with a hub that links to every member and members that link back to the hub and sideways to siblings:
| Cluster | Hub |
| ------------------ | ------------------------------------------------------------- |
| Importing | [Importing notes](/importing-notes/) |
| Editor | [Editor toolbar](/rich-text-editor/rich-text-editor-toolbar) |
| Organization | [Notebooks](/organizing-notes/organize-notes-using-notebooks) |
| Privacy & security | [How is my data encrypted?](/how-is-my-data-encrypted) |
| Sync | [How sync works](/sync/how-sync-works) |
| Plans | [Plans & limits](/plans-and-limits) |
Any page that mentions a paid feature links to the plans hub. Any page that mentions encryption links to the encryption hub.
## 8. Things that are not allowed
- Undocumented guesses about UI, limits or plan gates.
- Version numbers in body copy ("as of v3.2").
- "Simply", "just", "easily", "seamlessly", "powerful".
- Telling the reader to contact support before the documented steps.
- Image paths that don't exist — leave `<!-- TODO: screenshot — … -->` instead.
- Renaming or moving an existing page (its URL is live and linked from the apps). Some pages are linked from inside the app itself via `packages/intl/src/strings.ts` — grep it before touching a slug.
- More than one `# H1` on a page, and `##`/`###` headings inside a `:::tabs` block. Headings in tabs are emitted once per tab, so they show up twice in the page outline with duplicate anchors.
- Unquoted frontmatter values containing `: ` — the YAML parser fails the build. Quote them.
- Alt text that describes nothing: `drawing`, a filename, or an unfilled template. Inline UI glyphs ("press the ⋯ button") are the one case where a short label is correct.

View File

@@ -1,5 +1,6 @@
---
title: Oops!
description: The page you're looking for could not be found. Here are some helpful links to get you back on track.
---
# Oops!
@@ -8,6 +9,8 @@ Unfortunately, we cannot find what you are looking for.
Here are some other pages you might be interested in:
1. [Installing Notesnook](organizing-notes/organize-notes-using-notebooks)
2. [Creating your first note](create-a-note-in-notesnook)
3. [How is my data encrypted?](how-is-my-data-encrypted)
1. [Creating your first note](/create-a-note-in-notesnook)
2. [Organizing notes with notebooks](/organizing-notes/organize-notes-using-notebooks)
3. [How is my data encrypted?](/how-is-my-data-encrypted)
Still stuck? [Contact us](https://notesnook.com/contact-us).

View File

@@ -1,16 +0,0 @@
---
title: Notesnook Help
description: Your complete and free resource to using Notesnook as a daily note taking app to organize your work and life while safeguarding your privacy.
---
# Welcome to Notesnook Help
Notesnook is a free and open source note taking app focused on user privacy & ease of use. To ensure zero knowledge principles, Notesnook encrypts everything on your device using `XChaCha20-Poly1305` & `Argon2`.
Notesnook is our **proof** that privacy does _not_ (always) have to come at the cost of convenience. Our goal is to provide users peace of mind & 100% confidence that their notes are safe and secure. The decision to go fully open source is one of the most crucial steps towards that.
And with that convenience in mind, we believe that it is equally important to put together a simple & useful help website about everything Notesnook. Our help is a complete walkthrough of the Notesnook app and it's features.
Before we get started, let's [download & install Notesnook](https://notesnook.com/downloads) on all your devices.
Once that's done, let's learn how to [create your first note](/create-a-note-in-notesnook) in Notesnook.

View File

@@ -1,8 +0,0 @@
<link rel="stylesheet" type="text/css" href="/custom.css" />
<script
async
defer
data-website-id="ad34576b-2721-436c-b36a-47a614009d2b"
src="https://aas.streetwriters.co/script.js"
data-domains="help.notesnook.com"
></script>

View File

@@ -1,194 +0,0 @@
/* open-sans-regular - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-display: swap;
font-weight: 400;
src: local(""),
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2")
format("woff2"),
/* Super Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff")
format("woff"),
/* Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-regular.ttf")
format("truetype");
}
/* open-sans-600 - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: local(""),
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600.woff2")
format("woff2"),
/* Super Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600.woff")
format("woff"),
/* Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600.ttf")
format("truetype");
}
/* open-sans-700 - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: local(""),
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700.woff2")
format("woff2"),
/* Super Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700.woff")
format("woff"),
/* Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700.ttf")
format("truetype");
}
/* open-sans-italic - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400;
font-display: swap;
src: local(""),
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff2")
format("woff2"),
/* Super Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff")
format("woff"),
/* Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-italic.ttf")
format("truetype");
}
/* open-sans-600italic - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 600;
font-display: swap;
src: local(""),
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600italic.woff2")
format("woff2"),
/* Super Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600italic.woff")
format("woff"),
/* Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600italic.ttf")
format("truetype");
}
/* open-sans-700italic - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 700;
font-display: swap;
src: local(""),
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff2")
format("woff2"),
/* Super Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff")
format("woff"),
/* Modern Browsers */
url("/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700italic.ttf")
format("truetype");
}
/* fira-code-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Fira Code";
font-style: normal;
font-weight: 400;
src: url("/fonts/fira-code-v21-latin-regular.woff2") format("woff2"),
/* Super Modern Browsers */ url("/fonts/fira-code-v21-latin-regular.woff")
format("woff"),
/* Modern Browsers */ url("/fonts/fira-code-v21-latin-regular.ttf")
format("truetype"); /* Safari, Android, iOS */
}
html.light {
--font-family: "Open Sans", sans-serif;
--monospace-font-family: "Fira Code", monospace;
--primary: #008837;
--primary-fg: #fff;
--header-fg: #008837;
--link: #008837;
--heading: #000;
--page-bg: #ffffff;
--document-bg: #f7f7f7;
--selection-bg: #00883766;
--selection-fg: #fff;
--border: #e8e8e8;
--input-border: #e8e8e8;
--hover-bg: #e8e8e8;
--info-dim: #00883720;
--fg: #403f53;
--fg-dim: #403f53cc;
--fg-dimmer: #403f53bb;
--code-bg: #e8e8e8;
}
html.dark {
--font-family: "Open Sans", sans-serif;
--monospace-font-family: "Fira Code", monospace;
--primary: #008837;
--primary-fg: #fff;
--header-fg: #008837;
--link: #008837;
--page-bg: #0d0d0d;
--document-bg: #151515;
--border: #383838;
--info-dim: #00883720;
--button-bg: #3b3b3b;
--hover-bg: #202020;
--button-fg: #fff;
--input-bg: #2b2b2b;
--input-placeholder: #ababab;
--code-bg: #202020;
--selection-bg: #00883766;
--selection-fg: #fff;
--border-dim: #383838;
--input-border: #383838;
--input-bg: #151515;
}
nav li {
line-height: 24px;
}
li.nested {
min-height: 38px !important;
}
nav li li:not(li.nested):hover {
background-color: var(--hover-bg);
}
pre {
border: 1px solid var(--border);
}
.project-title a {
color: var(--fg);
font-weight: 600;
}
.project-subtitle {
color: var(--fg);
font-weight: 600;
}
.docgen-content {
box-shadow: none;
}
nav details[open] {
max-height: max-content;
}

View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Allow: /

View File

@@ -0,0 +1,166 @@
---
title: Your account
pageTitle: Manage your Notesnook account — email, password, profile
description: Change your Notesnook email or password, set a profile name and picture, save your recovery key, and log out of your devices.
keywords:
- change notesnook email
- change notesnook password
- notesnook recovery key
- notesnook log out all devices
schema: howto
---
# How do I manage my Notesnook account?
Everything about your account — email, password, profile name and picture, recovery key and sessions — lives in one place: `{{settings}}``{{profile}}` and `{{settings}}``{{authentication}}` on desktop and web, or `{{settings}}``{{account}}``{{manageAccount}}` on mobile.
## Change your email address
Changing your email is a two-step flow: you confirm your password, then enter a 6-digit code sent to the **new** address.
:::tabs key:platform
== Desktop/Web
1. Go to `{{settings}}``{{profile}}`.
2. Press `{{changeEmail}}`.
3. Fill in `{{newEmail}}` and `{{accountPassword}}`, then press `{{next}}`.
4. Enter the `{{sixDigitCode}}` sent to your new address and press `{{next}}`.
`Resend code in …` on the code field is disabled for 60 seconds after each send.
== Mobile
1. Go to `{{settings}}``{{account}}``{{manageAccount}}`.
2. Tap `{{changeEmail}}`.
3. Fill in the new email and your account password, then tap `{{verify}}`.
4. Enter the 6-digit code sent to your new address and tap `{{changeEmail}}`.
:::
::: warning You will be logged out from all your devices
The dialog says so explicitly. Your subscription and every other setting stay as they are — only the address changes.
:::
## Change your password
:::tabs key:platform
== Desktop/Web
1. Go to `{{settings}}``{{authentication}}`.
2. Press `{{changePassword}}`.
3. Enter `Current password` and `{{newPassword}}`.
A backup is taken automatically before the change goes through. When it finishes you see `{{passwordChangedSuccessfully}}` and the `{{saveRecoveryKey}}` dialog opens — save the new key.
== Mobile
1. Go to `{{settings}}``{{account}}``{{manageAccount}}``{{changePassword}}`.
2. Enter `Current password` and `{{newPassword}}`.
3. Tap `{{changePasswordConfirm}}`.
The screen warns that changing your password logs you out from all your devices, that you should not close the app while it runs, and that you must save the new account recovery key afterwards. A backup runs automatically first.
:::
### Are my notes re-encrypted when I change my password?
**No, and that is why it is fast.** Your notes are encrypted with data keys, and those keys are what your password protects. When you change your password, Notesnook derives a new master key from the new password and re-wraps the existing keys — your attachments key, monograph passwords key, inbox keys and data encryption keys — with it. The notes themselves are never re-encrypted, so the time it takes does not grow with the size of your notes.
::: danger Your password is the only way in
Notesnook never sees your password and cannot reset it for you. If you forget it, your [account recovery key](/recovering-your-account) is the only way back to your data. Your email must be confirmed before you can change your password.
:::
## Set a profile name and picture
Your full name and profile picture are stored **end-to-end encrypted and are only visible to you** — they are personalization, not a public profile.
:::tabs key:platform
== Desktop/Web
1. Go to `{{settings}}``{{profile}}`.
2. Click the pencil next to `{{yourFullName}}` to open `{{editFullName}}`, type a name and confirm. You get a `{{fullNameUpdated}}` toast.
3. Hover the avatar and click `{{edit}}` to open `{{editProfilePicture}}`.
== Mobile
1. Go to `{{settings}}``{{account}}``{{manageAccount}}`.
2. `{{removeFullName}}` and `{{removeProfilePicture}}` appear here once you have set them, each asking for confirmation before clearing the value.
:::
## Save your account recovery key
The recovery key is what gets you back into your data if you forget your password. Save it before you need it.
:::tabs key:platform
== Desktop/Web
1. Go to `{{settings}}``{{profile}}`.
2. Press `{{save}}` next to `{{saveDataRecoveryKey}}` and confirm the `{{verifyItsYou}}` prompt.
3. In the `{{saveRecoveryKey}}` dialog use `{{saveQRCode}}` or `Download`.
4. Press `{{keyBackedUp}}` to close the dialog.
== Mobile
1. Go to `{{settings}}``{{account}}``{{manageAccount}}`.
2. Tap `{{saveDataRecoveryKey}}` and confirm your identity.
3. Save the key from the dialog that opens.
:::
The same dialog opens automatically right after you change your password, because the key changes with it.
## Log out from all other devices
:::tabs key:platform
== Desktop/Web
1. Go to `{{settings}}``{{profile}}`.
2. Under `{{sessions}}`, press `{{logoutAllOtherDevices}}` and confirm.
You get a `{{loggedOutAllOtherDevices}}` toast. The device you are using stays signed in.
== Mobile
This is not available in the mobile app — use the desktop or web app to force a logout on your other devices.
:::
::: info There is no list of active sessions
Notesnook does not show a per-device session list. `{{logoutAllOtherDevices}}` clears every session except the one you are on; changing your email or password logs out every device including this one.
:::
## Log out of this device
:::tabs key:platform
== Desktop/Web
1. Go to `{{settings}}``{{profile}}`.
2. Under `{{sessions}}`, press `{{logout}}`.
3. Leave `{{backupDataBeforeLogout}}` ticked — it is on by default — and confirm.
== Mobile
1. Go to `{{settings}}``{{account}}``{{manageAccount}}``{{logout}}`.
2. Leave `{{backupDataBeforeLogout}}` ticked — it is on by default — and confirm.
:::
If you have unsynced changes, the confirmation adds a warning about them before you continue. If the pre-logout backup fails, Notesnook asks whether you want to log out anyway — answering no cancels the logout so you can fix the problem first.
::: warning Logging out clears local data
Logging out resets the local database on that device. Anything that has not synced is gone, which is exactly what the backup checkbox is there to prevent. See [backup and restore](/backup-and-restore-notes-in-notesnook).
:::
## Related pages
- [Recovering your account](/recovering-your-account) — using your recovery key after a forgotten password
- [Two-factor authentication](/two-factor-authentication) — adding a second step to every login
- [Backup and restore](/backup-and-restore-notes-in-notesnook) — keeping your own copy before you log out
- [Plans & limits](/plans-and-limits) — managing your subscription and billing
- [Deleting your account](/deleting-your-account) — removing your account and data for good
- [How is my data encrypted?](/how-is-my-data-encrypted) — what your password actually protects

View File

@@ -1,51 +1,103 @@
# App lock
---
title: App lock
pageTitle: Lock the Notesnook app with a PIN, password or biometrics
description: Turn on app lock in Notesnook to require a PIN, password, biometrics or a security key before your notes open, even when your device is already unlocked.
keywords:
- lock notes app
- password protect notes app
- fingerprint lock notes
---
You can use app lock to restrict access to your app even when your system is unlocked.
# App lock <PlanTag plan="pro" />
## [Desktop](#/tab/desktop)
You can use app lock to restrict access to your app even when your system is unlocked. It works on desktop, web and mobile, and it is separate from the [private vault](/lock-notes-with-private-vault) — app lock covers the whole app, the vault covers individual notes.
### Turn on App Lock
App lock is part of the [Pro plan and above](/plans-and-limits). If a paid plan expires, app lock is switched off automatically, so set up your device's own lock screen if you rely on it.
1. Go to Settings and Click on App lock. Then turn on the App lock switch. You will be prompted to enter your App Lock Password. When it is successful App Lock will be turned on.
## Ways to unlock
<img src="/desktop-enable-app-lock.png" alt="drawing" height="500"/>
| Method | Where |
| --------------------------------- | ----------------------- |
| PIN or password | Desktop, web and mobile |
| Biometrics (fingerprint, Face ID) | Mobile |
| Security key | Desktop and web |
### Setting App Lock Time Out
## Turn on app lock
2. You can set the time out for your App Lock from one minute to an hour or you can turn it off by setting it to **Never**.
:::tabs key:platform
== Desktop/Web
<img src="/desktop-lock-app-after.png" alt="drawing" height="500"/>
1. Go to `{{settings}}``{{appLock}}`.
2. Turn on `{{enableAppLock}}`.
3. Enter a password or PIN when prompted, and confirm it.
### Change Password
![The App lock section of Notesnook desktop settings, with the Enable app lock switch turned on](/desktop-enable-app-lock.png)
3. You can also change the pin or password or you can set a security key if you want a more secure app.
== Mobile
<img src="/desktop-password-key.png" alt="drawing" height="500"/>
1. Go to `{{settings}}``{{appLock}}`.
## [Mobile/IOS](#/tab/mobile)
![The App lock entry in the Notesnook mobile settings list](/app-lock-setting.png)
### Turn on App Lock
2. Turn on `{{enableAppLock}}` and enter a PIN, or authenticate with your fingerprint or face.
1. Go to Settings and Tap on App lock.
![The App lock switch turned on in Notesnook mobile settings](/app-lock-setting-on-off.png)
<img src="/app-lock-setting.png" alt="drawing" height="500"/>
:::
2. Then turn on the App lock switch. You will be prompted to enter a pin or fingerprint. When it is successful App Lock will be turned on.
Notesnook now asks for your credential every time it starts, and after the timeout you set below.
<img src="/app-lock-setting-on-off.png" alt="drawing" height="500"/>
## Set how long before it locks
### Setting App Lock Time Out
`Lock app after` decides how long the app can sit idle before it locks itself again. `{{never}}` means Notesnook only asks when it starts.
3. You can set the time out for your App Lock
:::tabs key:platform
== Desktop/Web
<img src="/app-lock-setting-time-out.png" alt="drawing" height="500"/>
1. Go to `{{settings}}``{{appLock}}`.
2. Set `Lock app after` to `{{immediately}}`, `1`, `5`, `10`, `15`, `30` or `45` minutes, `1 hour`, or `{{never}}`.
### Set a pin
![The Lock app after dropdown in Notesnook desktop settings, showing the available timeout intervals](/desktop-lock-app-after.png)
4. You can set a pin instead of a fingerprint if you are more comfortable with it (or if your mobile is not fingerprint friendly).
== Mobile
<img src="/setup-app-lock-pin.png" alt="drawing" height="500"/>
1. Go to `{{settings}}``{{appLock}}`.
2. Set `{{appLockTimeout}}` to `{{never}}`, `{{immediately}}`, `1`, `5`, `15` or `30` minutes.
5. You can also change or remove the pin.
![The App lock timeout options in Notesnook mobile settings](/app-lock-setting-time-out.png)
<img src="/change-remove-app-lock-pin.png" alt="drawing" height="500"/>
:::
## Change or remove your PIN, password or security key
:::tabs key:platform
== Desktop/Web
Under `{{credientials}}` on the same screen:
- `{{passwordPin}}` — press `{{change}}` to set a new one, or `{{disable}}` to remove it.
- `{{securityKey}}` — press `{{register}}` to add a hardware security key, or `{{unregister}}` to remove it.
![The Credentials section of Notesnook desktop app lock settings, with the password and security key options](/desktop-password-key.png)
== Mobile
- `{{setupAppLockPin}}` or `{{setupAppLockPassword}}` add a credential; once one exists the entries read `{{changeAppLockPin}}` and `{{changeAppLockPassword}}`.
- `{{removeAppLockPin}}` and `{{removeAppLockPassword}}` take one away. App lock is switched off entirely if you remove the last remaining method.
![Setting an app lock PIN in Notesnook on mobile](/setup-app-lock-pin.png)
![Changing or removing the app lock PIN in Notesnook settings on mobile](/change-remove-app-lock-pin.png)
:::
## Lock the app right now
Rather than waiting for the timeout, you can lock immediately. On desktop and web, click the lock icon in the status bar at the bottom of the window.
## Related pages
- [Private vault](/lock-notes-with-private-vault) — encrypt individual notes behind a separate password
- [Privacy mode](/privacy-mode) — stop screenshots and hide the app from the task switcher
- [How is my data encrypted?](/how-is-my-data-encrypted) — what protects your notes on the server
- [Plans & limits](/plans-and-limits) — what the Pro plan unlocks

View File

@@ -0,0 +1,205 @@
---
title: Attachments & files
pageTitle: How do I attach files and images to a note in Notesnook?
description: Attach images and files to your notes, read PDFs without downloading them, see the per-plan size and storage limits, and manage every attachment.
keywords:
- notesnook attach file to note
- notesnook attachment size limit
- notesnook attachment manager
- notesnook orphaned attachments
- notesnook pdf preview
schema: howto
---
# How do I attach files and images to a note in Notesnook?
Open the insert menu in the editor and pick `{{image}}` or `{{attachment}}`. The file is encrypted on your device before it is uploaded, so nobody — including us — can read it. Attaching files requires a Notesnook account, and how large a file can be depends on your plan.
::: info You need an account to attach files
Trying to insert an attachment while logged out shows `{{notLoggedIn}}` with the message `Login to upload attachments.` Attachments are stored on Notesnook's servers in encrypted form, which is why an account is required. See [why login is needed to upload attachments](/faqs/login-to-upload-attachments).
:::
## Attach a file or an image
:::tabs key:platform
== Desktop/Web
1. Put the cursor where the file should go.
2. Open the insert menu (the `+` button on the toolbar).
3. Choose `{{attachment}}` for any file, or `{{image}}``{{uploadFromDisk}}` for a picture.
4. Pick one or more files.
Shortcuts: `Ctrl/Cmd + Shift + A` for an attachment, `Ctrl/Cmd + Shift + I` for an image. You can also drag files straight into the editor.
== Mobile
1. Put the cursor where the file should go.
2. Open the insert menu (the `+` button on the toolbar).
3. Choose `{{attachment}}` for any file, or `{{image}}` for a picture.
4. Under `{{image}}` you also get `{{takePhotoUsingCamera}}`, which is mobile only.
:::
Notesnook hashes each file first, so attaching the same file twice reuses the copy that is already uploaded instead of consuming your storage again.
## File size and storage limits
| | Free | Essential | Pro | Believer |
| ----------------- | ----- | --------- | ----- | -------- |
| Maximum file size | 10 MB | 100 MB | 1 GB | 5 GB |
| Storage per month | 50 MB | 1 GB | 10 GB | 25 GB |
Storage counts **attachments only** — images, files, audio and web clips. Your notes never count against it. If a file is over your plan's limit, the upload is refused with a message telling you the size you are allowed. Full details are on [Plans & limits](/plans-and-limits).
### Upload images at full quality <PlanTag plan="pro" />
By default Notesnook compresses images before uploading. The `{{imageCompression}}` setting offers `{{askEveryTime}}`, `{{enableRecommended}}` and `{{disable}}` — and `{{disable}}`, which uploads at full quality, needs **Pro** or **Believer**.
:::tabs key:platform
== Desktop/Web
Go to `{{settings}}``{{behaviour}}``{{imageCompression}}`.
== Mobile
Go to `{{settings}}``{{customization}}``{{behavior}}``{{imageCompression}}`.
:::
## Manage your attachments
The attachment manager lists every file in your account with its name, upload status, size and upload date.
:::tabs key:platform
== Desktop/Web
Go to `{{settings}}``{{profile}}``{{attachments}}` and press `{{open}}`.
== Mobile
Go to `{{settings}}``{{account}}``{{manageAccount}}``{{manageAttachments}}`.
:::
### Filter and search
Both apps group attachments by type. On desktop and web the sidebar has `All files`, `Images`, `Documents`, `Videos`, `Audios`, `{{uploads}}` and `{{orphaned}}`, each with a count. Mobile shows `All files`, `Images`, `Audios`, `Videos`, `Documents`, `{{orphaned}}` and `Errors`.
- **`{{uploads}}`** — files that are still waiting to be uploaded.
- **`{{orphaned}}`** — files that are no longer referenced by any note, usually left behind by a note you deleted. These are safe to delete once you are sure you don't want the file itself.
The search box at the top filters the list by filename. On desktop and web you can also sort by clicking the `{{name}}`, `{{size}}` or `{{dateUploaded}}` column headers.
### Act on an attachment
Open an attachment's menu — right click desktop and web, tap the item on mobile — for:
| Action | What it does |
| ------------------------------------------------ | ----------------------------------------------------------------------------- |
| `{{previewAttachment}}` | Opens images and PDFs without downloading them (desktop and web) |
| `{{linkedNotes}}` | Lists the notes that use this file; picking one opens it |
| `{{fileCheck}}` | Verifies the uploaded file is intact and decryptable |
| `{{rename}}` | Changes the filename |
| `Download` | Saves the file to your device |
| `Reupload` | Replaces a broken upload — you must pick the same file, the hash has to match |
| `{{deletePermanently}}` (`{{delete}}` on mobile) | Removes the file from your account and from the notes that use it |
`Download`, `{{fileCheck}}` and `{{delete}}` also work on a multi-selection from the toolbar at the top of the desktop and web list.
::: tip Fix a failed attachment
A file that shows an error usually needs `{{fileCheck}}` first. If the check reports a problem, `Reupload` with the original file repairs it.
:::
<!-- TODO: screenshot — the attachment manager with the type sidebar and the toolbar actions -->
### Download every attachment
The download button at the bottom of the desktop and web sidebar is `{{downloadAllAttachments}}`; on mobile it is the download icon in the header. A progress ring appears while it runs, and pressing the button again cancels it. This is the quickest way to get a local copy of everything you have uploaded.
### Clear the cache
`{{clearCache}}` removes the local copies of files without touching what is on the server. The confirmation spells out what happens: downloaded images and files are **cleared**, pending uploads are **cleared**, and uploaded images and files are **unaffected**.
:::tabs key:platform
== Desktop/Web
Use the `{{clearCache}}` button at the bottom of the attachment manager's sidebar.
== Mobile
Go to `{{settings}}``{{account}}``{{manageAccount}}``{{clearCache}}`. The setting shows the current cache size.
:::
::: warning Pending uploads are cleared too
Anything that has not finished uploading is lost when you clear the cache. Let uploads finish first.
:::
## Read a PDF without downloading it
PDFs open in a viewer inside Notesnook, so you can read one without saving it to your device first. The file is downloaded to the local cache, decrypted in memory and shown — it is never handed to another app unless you ask for that.
To open one, click or tap the PDF attachment where it sits in the note. On desktop and web there is a second route: open the file's menu in the [attachment manager](#manage-your-attachments) and choose `{{previewAttachment}}`. That entry appears only on images and PDFs, and the attachment manager on mobile has no preview action — go through the note instead.
:::tabs key:platform
== Desktop/Web
The PDF opens in a **side pane to the right of the editor**, so you can read it and write at the same time. Drag the divider between the two to resize them.
The pane's toolbar has:
| Control | What it does |
| ---------------------- | --------------------------------------------------------------- |
| `{{search}}` | Searches the text of the PDF and highlights the matches |
| `{{goToPreviousPage}}` | Back one page |
| Page number | Shows the current page and the total; type a number to jump |
| `{{goToNextPage}}` | Forward one page |
| `{{zoomOut}}` / `{{zoomIn}}` | Steps the zoom down or up; the current percentage sits between them |
| `Download` | Saves the PDF to your device |
| `{{enterFullScreen}}` | Hands the whole screen to the PDF |
| `{{close}}` | Closes the pane and returns the space to the editor |
The table of contents and the note properties share this space, so opening the PDF preview closes whichever of those was open.
== Mobile
The PDF opens **full screen** over the app. In its header you get:
- a back arrow to close the viewer and return to the note;
- the current page number with the page total beside it — tap the number, type a page and confirm to jump straight to it;
- a download button to save the file to your device;
- an open-in-new button that hands the PDF to another app on your phone, so you can read it in your usual PDF reader or share it onward.
Scroll and pinch to zoom as you would in any other viewer.
:::
::: info Password-protected PDFs
A PDF with a password on it opens on a `{{pdfLocked}}` screen instead of the document. Enter the PDF's own password to read it. This is the password whoever made the file set on it — it has nothing to do with your Notesnook account password or your [vault](/lock-notes-with-private-vault) password, and Notesnook cannot recover it for you.
:::
## Deleting an attachment
Deleting an attachment removes it from your account **and** from every note that references it — attachments are not moved to [Trash](/trash) the way notes are.
## What is an orphaned attachment?
Deleting a note does not delete the files it contained — they stay in your account as **orphaned** attachments, listed under `{{orphaned}}` in the attachment manager. Delete the ones you no longer want the file for.
## Why is my storage still full after I deleted attachments?
Your plan's storage figure is a **monthly allowance**, not a measure of how much you are currently storing — that is why every plan is written as `50 MB/mo`, `1 GB/mo` and so on. Uploading a file spends part of that month's allowance, and deleting the file afterwards does not hand the allowance back. The allowance starts again at the beginning of the next month.
So deleting attachments is worth doing to keep your account tidy, but it is not the way to get more room this month. If you are hitting the ceiling regularly, a plan with a larger monthly allowance is the fix — see [plans & limits](/plans-and-limits).
<GetNotesnook action="pricing" title="Need more room for files?" text="The free plan gives you 50 MB a month and a 10 MB file size cap. Paid plans go up to 25 GB a month with 5 GB files — and every file stays end-to-end encrypted on all of them." />
## Related pages
- [Plans & limits](/plans-and-limits) — the storage and file size limits for every plan
- [Trash](/trash) — why deleted notes leave their files behind
- [How is my data encrypted?](/how-is-my-data-encrypted) — how attachments are encrypted before upload
- [Private vault](/lock-notes-with-private-vault) — locking the notes your files live in
- [Backup and restore](/backup-and-restore-notes-in-notesnook) — including attachments in a backup
- [Why do I need to log in to upload attachments?](/faqs/login-to-upload-attachments)

View File

@@ -1,111 +1,148 @@
---
title: Backup and restore
description: Notesnook allows you to backup all your notes data to a single backup file. Learn how you can backup your notes and restore them.
pageTitle: How do I back up and restore my notes in Notesnook?
description: Create an encrypted backup of everything in Notesnook, turn on automatic backups, and restore a backup without losing the work you have done since.
keywords:
- notesnook backup
- backup notes app
- restore notesnook backup
- notesnook automatic backups
- nnbackupz file
schema: howto
---
# Backup your notes
# How do I back up and restore my notes?
It is always a good practice to take regular backups of your data so you can easily recover your data in case of data corruption or losing access to your account. All backups are stored locally encrypted (unless you turn off backup encryption) on your device.
Taking regular backups means you can recover your notes if your data is corrupted or you lose access to your account. All backups are stored locally encrypted (unless you turn off backup encryption) on your device.
> error Store your password & recovery key safely
>
> Since all your data is end-to-end encrypted, we have no way to restore your account data if you forget your account password and lose your account recovery key. That's why we recommend that you store your password & recovery key in a password manager or some other safe place.
::: danger Store your password & recovery key safely
Since all your data is end-to-end encrypted, we have no way to restore your account data if you forget your account password and lose your account recovery key. That's why we recommend that you store your password & recovery key in a password manager or some other safe place.
# [Desktop/Web](#/tab/web)
:::
1. Go to Settings
2. Scroll down in the Settings navigation menu and click on `Backup & export` section
3. Click on `Create backup` under `Backup now` heading to create a new `.nnbackupz` file
:::tabs key:platform
== Desktop/Web
![](/create-backup-web.png)
1. Go to `{{settings}}`.
2. Open `{{backupExport}}` section
3. Click `Create backup` under `{{backupNow}}` heading to create a new `.nnbackupz` file
# [Mobile](#/tab/mobile)
![The Backup & export section of Notesnook settings on web, with the Create backup button](/create-backup-web.png)
1. Go to Settings from Sidebar
2. Scroll down to `Backup and Restore`
3. Tap on `Backups`
4. Press on `Backup now` to create a new `.nnbackupz` file
== Mobile
> info
>
> On **Android** when you take a backup for the first time, you will be asked to select a folder where you want to store all your backup files. You can always change your backup files location from `Backups > Select backup directory`.
>
> Regardless of the folder you select, Notesnook will create a folder "Notesnook/backups" inside it and store all backup files there.
1. Go to `{{settings}}`.
2. Open `{{backupRestore}}`
3. Tap `{{backups}}`
4. Tap `{{backupNow}}` to create a new `.nnbackupz` file
---
::: info
On **Android** when you take a backup for the first time, you will be asked to select a folder where you want to store all your backup files. You can always change your backup files location from `Backups > Select backup directory`.
## Automatic Backups
Regardless of the folder you select, Notesnook will create a folder "Notesnook/backups" inside it and store all backup files there.
:::
## Turn on automatic backups
For maximum safety against potential data loss, you can enable daily, weekly or monthly backups of your notes. Enabling automatic backups will ensure that all your data is safely backed up locally after a regular interval.
# [Desktop](#/tab/desktop)
:::tabs key:platform
== Desktop
1. Go to Settings
2. Scroll down in the Settings navigation menu and click on `Backup & export` section
1. Go to `{{settings}}`.
2. Open `{{backupExport}}` section
3. Select the Automatic backups interval from the dropdown
![](/auto-backups-desktop.png)
![The automatic backup interval dropdown in Notesnook desktop settings](/auto-backups-desktop.png)
== Web
::: info
On the **web** app there is no way to automatically save backups to a folder, that is why Notesnook only reminds the users when it's time to create a new backup.
# [Web](#/tab/web)
:::
> info
>
> On the **web** app there is no way to automatically save backups to a folder, that is why Notesnook only reminds the users when it's time to create a new backup.
1. Go to Settings
2. Scroll down in the Settings navigation menu and click on `Backup & export` section
1. Go to `{{settings}}`.
2. Open `{{backupExport}}` section
3. Select the Backup reminders interval from the dropdown
![](/auto-backups-web.png)
![The backup reminder interval dropdown in Notesnook on the web](/auto-backups-web.png)
# [Mobile](#/tab/mobile)
== Mobile
1. Go to Settings > Backup & Restore
2. Press Backups
3. Select automatic backup frequency to enable automatic backups
1. Go to `{{settings}}``{{backupRestore}}`.
2. Tap `{{backups}}`.
3. Choose an automatic backup frequency.
:
:::
---
## Keep backups encrypted
## Encrypted Backups (Recommended)
To keep your backups secure & private, it is recommended that you enable encryption on your backup files instead of storing them as plaintext data. **Encrypted backups are on by default.**
To keep your backups secure & private, it is recommended that you enable encryption on your backup files instead of storing them as plaintext data. **Starting from v2.6.0, encrypted backups are enabled by default for all users.**
:::tabs key:platform
== Desktop/Web
# [Desktop/Web](#/tab/web)
1. Go to `{{settings}}`.
2. Open `{{backupExport}}` section
3. Click the toggle next to `{{backupEncryption}}` to enable/disable encrypted backups
1. Go to Settings
2. Scroll down in the Settings navigation menu and click on `Backup & export` section
3. Click on the toggle next to `Backup encryption` to enable/disable encrypted backups
== Mobile
# [Mobile](#/tab/mobile)
1. Go to `{{settings}}``{{backupRestore}}`
2. Tap `{{backups}}`
3. Tap the toggle next to `{{backupEncryption}}` to enable/disable encrypted backups
1. Go to `Settings` > `Backup & Restore`
2. Tap on `Backups`
3. Tap on the toggle next to `Backup encryption` to enable/disable encrypted backups
:::
---
::: info
Backups are always encrypted with your account password.
> info
>
> Backups are always encrypted with your account password.
:::
# Restore a backup
## Restore a backup
At any point in time, you can restore a backup to recover lost data. However, **to restore a backup, you must be logged in to your Notesnook account.** Backups created on one account can be restored on another Notesnook account.
::: danger Restoring overwrites what you have now
Restoring a backup replaces your current content in-place. Anything that changed since that backup was taken is reverted to how it was in the backup. Entirely new content — notes you created after the backup — is not touched.
# [Desktop/Web](#/tab/web)
**Always create a backup before restoring one.**
1. Go to Settings
2. Scroll down in the Settings navigation menu and click on `Backup & export` section
3. Click on `Restore` button next to `Restore backup` heading
:::
### Recover a few old notes without losing today's work
If you only want something back from an old backup, don't restore it over your current data and hope for the best. Sandwich it:
1. Create a **new backup** of your current data.
2. Restore the **old backup** and take out what you needed.
3. Restore the **new backup** from step 1 to put everything back as it was.
That sequence means no recent change is lost. Be aware of one side effect: restoring an old backup can bring back notes you had deleted since, so check your trash and notes list afterwards.
At any point in time, you can restore a backup to recover lost data. Backups created on one account can be restored on another Notesnook account.
:::tabs key:platform
== Desktop/Web
1. Go to `{{settings}}`.
2. Open `{{backupExport}}` section
3. Click `{{restore}}` button next to `{{restoreBackup}}` heading
4. Select the `.nnbackupz` or `.nnbackup` file from your PC that you want to restore.
# [Mobile](#/tab/mobile)
== Mobile
1. Go to Settings from Sidebar
2. Scroll down to `Backup & Restore` section
3. Tap on `Restore backup`
4. From `Restore backup` sheet, select the backup you want to restore. If your backup file is located in some other location, tap on `Restore from files` on top right corner of the sheet then select the backup file.
1. Go to `{{settings}}`.
2. Open `{{backupRestore}}`
3. Tap `{{restoreBackup}}`
4. From `{{restoreBackup}}` sheet, select the backup you want to restore. If your backup file is located in some other location, tap `{{restoreFromFiles}}` on top right corner of the sheet then select the backup file.
<img src="/restore-backup-mobile.png" height="700px">
![The Restore backup sheet on Notesnook mobile, listing the backup files it found](/restore-backup-mobile.png)
---
:::
## Related pages
- [Exporting notes](/export-notes-from-notesnook) — taking your notes to another app
- [Recovering your account](/recovering-your-account) — when you forget your password
- [Attachments & files](/attachments-and-files) — managing the files in your notes
- [Version history](/note-version-history) — going back to an earlier draft
- [Plans & limits](/plans-and-limits) — what each plan unlocks and the exact limits

View File

@@ -1,39 +1,52 @@
---
title: Create your first note
description: Notesnook let's you create unlimited notes for free. Learn how you can create your first note in Notesnook.
pageTitle: How do I create a note in Notesnook?
description: Create your first note in Notesnook on desktop, web or mobile. Notes save themselves as you type, and you can start one from anywhere in the app.
keywords:
- create a note notesnook
- how to use notesnook
- notesnook first note
schema: howto
---
# Creating your first note
You are in a note taking app, the first thing you'd want to do is create a note.
# [Desktop/Web](#/tab/web)
:::tabs key:platform
== Desktop/Web
1. Click on the `+` button on top right corner or click anywhere inside the editor to focus it.
1. Click the `+` button on top right corner or click anywhere inside the editor to focus it.
2. Start typing in the editor and a new note will be automatically created.
3. As you type, your note is saved automatically whenever you stop for a few seconds.
![First note web](/first-note-desktop.png)
![A new note being typed in the Notesnook desktop editor](/first-note-desktop.png)
> info
>
> The bottom right corner of the app will show the number of words of current note & the last saved time.
>
> ![Status bar desktop](/editor-status-bar-desktop.png)
::: info
The bottom right corner of the app will show the number of words of current note & the last saved time.
# [Mobile](#/tab/mobile)
![The editor status bar at the bottom right, showing the word count and last saved time](/editor-status-bar-desktop.png)
== Mobile
1. On mobile regardless of what screen you are on, you can swipe from right to left to open the editor.
2. You can also press the `+` button on bottom right corner to open the editor (note: the `+` button is visible only on some screens).
3. Start typing in the editor and a new note will be automatically created.
4. As you type, your note is saved automatically whenever you stop for a few seconds.
![First note mobile](/first-note-mobile.png)
![A new note being typed in the Notesnook mobile editor](/first-note-mobile.png)
> info
>
> Below the note title is the editor status bar. It shows you the number of words in the note and last saved time.
::: info
Below the note title is the editor status bar. It shows you the number of words in the note and last saved time.
---
:::
And that is how you create your first private note in Notesnook!
## Related pages
- [Notebooks](/organizing-notes/organize-notes-using-notebooks) — nested notebooks for structure
- [Editor toolbar](/rich-text-editor/rich-text-editor-toolbar) — every formatting tool
- [Search & navigation](/search-and-navigation) — finding anything, fast
- [How sync works](/sync/how-sync-works) — when and how your notes travel
- [Plans & limits](/plans-and-limits) — what each plan unlocks and the exact limits

View File

@@ -1 +0,0 @@
# Custom themes

View File

@@ -1,35 +1,48 @@
---
title: Theme Builder
pageTitle: How do I create a custom Notesnook theme?
description: Build a Notesnook theme in the Theme Builder without writing JSON — pick a starter theme, set your colors and metadata, and export it as a theme file.
keywords:
- notesnook theme builder
- create notesnook theme
- custom theme notes app
schema: howto
---
# Create a theme with the Theme Builder
The Theme Builder provides an easy and accessible way to create themes for Notesnook without prior technical knowledge. The purpose of this tool is to allow anyone, especially a layman, to tweak Notesnook according to their liking.
You can access the Theme Builder at [https://theme-builder.notesnook.com](https://theme-builder.notesnook.com).
> info
>
> The Theme Builder is the exact duplicate of the Notesnook Web application — just with the option to tweak the colors. You can use it to sign into your account, create notes, and everything else you do in the Notesnook app.
::: info
The Theme Builder is a full copy of the Notesnook web app, with an extra panel for tweaking the colors. You can use it to sign into your account, create notes, and everything else you do in the Notesnook app.
:::
Here's a look at the theme builder:
![Toolbar](/theme-builder.png)
![The Notesnook Theme Builder, with the colour panel beside a live copy of the app](/theme-builder.png)
Now let's walk through the whole process of creating your own theme using the Theme Builder.
## 1. Select a starter theme
Before you can create your own theme, it is best to select a starter theme to build upon. This allows us to quickly visualize all the changes without have to build from scratch.
Before you can create your own theme, it is best to select a starter theme to build upon. That lets you see every change against a working app instead of building one from scratch.
The Theme Builder makes this very easy:
1. Open the [Theme Builder](https://theme-builder.notesnook.com).
2. Go to `Settings > Appearance > Themes` and select any theme from the list.
> warn
>
> Keep in mind that a theme can only have one color scheme: `light` or `dark` so choose your starter theme accordingly.
::: warning
Keep in mind that a theme can only have one color scheme: `light` or `dark` so choose your starter theme accordingly.
:::
For our example, we are going to select "Notesnook Light" as our base theme.
![Toolbar](/theme-builder-select-starter-theme.png)
![Choosing Notesnook Light as the starter theme in the Theme Builder](/theme-builder-select-starter-theme.png)
Once the theme is applied, you'll notice all the colors in the Theme Builder update with the colors of the Notesnook Light theme.
@@ -37,47 +50,50 @@ Once the theme is applied, you'll notice all the colors in the Theme Builder upd
Theme metadata allows better discoverability in search and gives users a quick idea of what the theme is. You can read about all the supported properties [here](/custom-themes/introduction#theme-metadata).
![Toolbar](/theme-builder-metadata.png)
![The theme metadata fields in the Theme Builder](/theme-builder-metadata.png)
> warn Theme ID conflicts
>
> Remember that the `id` for your custom theme should not conflict with other published themes on Notesnook. You can see the list of all published theme IDs [here](https://github.com/streetwriters/notesnook-themes/tree/main/themes).
::: warning Theme ID conflicts
Remember that the `id` for your custom theme should not conflict with other published themes on Notesnook. You can see the list of all published theme IDs [here](https://github.com/streetwriters/notesnook-themes/tree/main/themes).
:::
## 3. Configuring base theme scope
> info
>
> Before you proceed, it is recommended that you [learn about how theming in Notesnook works](/custom-themes/introduction#what-is-a-theme), what scopes, variants & colors do etc.
::: info
Before you proceed, it is recommended that you [learn about how theming in Notesnook works](/custom-themes/introduction#what-is-a-theme), what scopes, variants & colors do etc.
:::
Every Notesnook theme must implement the base theme scope. Colors from the `base` theme scope are used as a fallback in all other scopes if a specific color is not defined.
![Toolbar](/theme-builder-base.png)
![The base theme scope expanded in the Theme Builder, showing its variants](/theme-builder-base.png)
For our example, we will be creating a Blue accented theme for Notesnook. In order to do that, we must replace all the occurences of the default green color (#008837) in the `base` theme scope with a nice blue color (#1d4ed8).
For our example, we will be creating a Blue accented theme for Notesnook. In order to do that, we must replace all the occurrences of the default green color (#008837) in the `base` theme scope with a nice blue color (#1d4ed8).
Since we want to create a blue variant of our Notesnook light theme, we will replace the default green (#008837) in our base theme scope with blue(#1d4ed8). We have to go through all variants, primary, secondary, selected, disabled and replace the colors.
![Toolbar](/theme-builder-change-color.gif)
![Replacing the green accent colour with blue and seeing the app update live](/theme-builder-change-color.gif)
As you change each color, you will see the changes reflected in the app in real-time. How cool is that!
As you change each color, the app updates in real time.
## 4. Configuring optional scopes
After configuring `base` theme scope, you can optionally set colors for other scopes, such as `navigationMenu`, to make them look a little different.
> info An example
>
> For example, in the default Notesnook Light theme, the background color of the navigation menu is grayish instead of pure white.
>
> ![Toolbar](/theme-builder-navigation-menu.png)
>
> This is because the default Notesnook Light theme has a different background color set for the `navigationMenu` scope.
>
> ![Toolbar](/theme-builder-navigation-menu-scope.png)
::: info An example
For example, in the default Notesnook Light theme, the background color of the navigation menu is grayish instead of pure white.
The sky is the limit here. In most cases, though, the `base` scope will suffice unless you want to get super creative like me.
![The Notesnook Light navigation menu, with a background slightly greyer than the rest of the app](/theme-builder-navigation-menu.png)
![Toolbar](/theme-builder-navigation-menu-modify.png)
This is because the default Notesnook Light theme has a different background color set for the `navigationMenu` scope.
![The navigationMenu scope in the Theme Builder, showing its own background colour](/theme-builder-navigation-menu-scope.png)
:::
The sky is the limit here. In most cases, though, the `base` scope will suffice unless you want to get more adventurous.
![The navigation menu restyled with a custom background colour](/theme-builder-navigation-menu-modify.png)
And that's it! Your theme is ready to be exported.
@@ -85,7 +101,7 @@ And that's it! Your theme is ready to be exported.
Once you have finished working on your theme, you can export it by clicking the "Export theme" button at the top of Theme Builder pane.
![](/theme-builder-export-theme.png)
![The Export theme button at the top of the Theme Builder panel](/theme-builder-export-theme.png)
You will get a JSON file containing your theme which you can either [install directly into the Notesnook app](/custom-themes/install-a-theme-from-file) for personal use or [publish it](/custom-themes/publish-a-theme) for others to use as well.
@@ -93,3 +109,10 @@ You will get a JSON file containing your theme which you can either [install dir
- [Publish your theme](/custom-themes/publish-a-theme)
- [Install a theme directly from JSON file](/custom-themes/install-a-theme-from-file)
## Related pages
- [How themes work](/custom-themes/introduction) — scopes, variants and colors
- [Publish a theme](/custom-themes/publish-a-theme) — sharing a theme with everyone
- [Install from file](/custom-themes/install-a-theme-from-file) — loading a theme.json
- [Using themes](/custom-themes/using-themes) — light, dark and the theme store

View File

@@ -1,15 +1,33 @@
## Install a theme directly from theme.json file
---
title: Install from file
pageTitle: How do I install a Notesnook theme from a file?
description: Load a theme.json file directly into Notesnook on mobile, desktop or web, and set it as your default theme.
keywords:
- install notesnook theme
- theme.json notesnook
- load theme from file
schema: howto
---
# Install a theme directly from theme.json file
In both mobile and desktop/web apps, you can install themes directly from a JSON theme file.
> info You cannot currently import CSS for a code block.
>
> [Themes must be published](/custom-themes/publish-a-theme) in order for you to use custom CSS for code blocks.
::: info You cannot currently import CSS for a code block.
[Themes must be published](/custom-themes/publish-a-theme) in order for you to use custom CSS for code blocks.
:::
1. Open the Notesnook app
2. Go to Settings > Appearance > Themes
3. Click on "Load from file" button
![Toolbar](/theme-load-file.png)
3. Click "Load from file" button
![The Load from file button in the Notesnook theme settings](/theme-load-file.png)
4. Select the JSON file to load the theme from.
5. Click on "Set as default"
![](/theme-set-as-default.png)
5. Click "Set as default"
![The Set as default button on a theme loaded from a file](/theme-set-as-default.png)
## Related pages
- [Using themes](/custom-themes/using-themes) — light, dark and the theme store
- [Theme Builder](/custom-themes/create-a-theme-with-theme-builder) — building a theme visually
- [How themes work](/custom-themes/introduction) — scopes, variants and colors

Some files were not shown because too many files have changed in this diff Show More