├── .github └── workflows │ ├── build.yml │ └── release_notes.yml ├── .gitignore ├── README.md ├── assets ├── demo.png ├── github_logo.png └── icons │ └── icon.png ├── package.json ├── packages ├── app-desktop │ ├── README.md │ ├── bin │ │ ├── build │ │ ├── notarize.js │ │ ├── setup │ │ └── start │ ├── build │ │ └── entitlements.mac.plist │ ├── package.json │ ├── src │ │ ├── client │ │ │ ├── App.tsx │ │ │ ├── components │ │ │ │ ├── CodeMirrorEditor.tsx │ │ │ │ ├── ColorPicker.tsx │ │ │ │ ├── ConfirmationDialog.tsx │ │ │ │ ├── CustomThemeProvider.tsx │ │ │ │ ├── DatabaseProvider.tsx │ │ │ │ ├── Editor.tsx │ │ │ │ ├── EditorRoot.tsx │ │ │ │ ├── Help.tsx │ │ │ │ ├── HomePage.tsx │ │ │ │ ├── MenuBar.tsx │ │ │ │ ├── Modal.tsx │ │ │ │ ├── NotePreview.tsx │ │ │ │ ├── NotesList.tsx │ │ │ │ ├── SavedQueries.tsx │ │ │ │ ├── Search.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ ├── ThemeItem.tsx │ │ │ │ └── Title.tsx │ │ │ ├── database │ │ │ │ ├── index.ts │ │ │ │ └── reducers.ts │ │ │ ├── fonts.css │ │ │ ├── fonts │ │ │ │ ├── Montserrat-Black.ttf │ │ │ │ ├── Montserrat-BlackItalic.ttf │ │ │ │ ├── Montserrat-Bold.ttf │ │ │ │ ├── Montserrat-BoldItalic.ttf │ │ │ │ ├── Montserrat-ExtraBold.ttf │ │ │ │ ├── Montserrat-ExtraBoldItalic.ttf │ │ │ │ ├── Montserrat-ExtraLight.ttf │ │ │ │ ├── Montserrat-ExtraLightItalic.ttf │ │ │ │ ├── Montserrat-Italic.ttf │ │ │ │ ├── Montserrat-Light.ttf │ │ │ │ ├── Montserrat-LightItalic.ttf │ │ │ │ ├── Montserrat-Medium.ttf │ │ │ │ ├── Montserrat-MediumItalic.ttf │ │ │ │ ├── Montserrat-Regular.ttf │ │ │ │ ├── Montserrat-SemiBold.ttf │ │ │ │ ├── Montserrat-SemiBoldItalic.ttf │ │ │ │ ├── Montserrat-Thin.ttf │ │ │ │ ├── Montserrat-ThinItalic.ttf │ │ │ │ ├── OFL.txt │ │ │ │ ├── RobotoMono-Bold.ttf │ │ │ │ ├── RobotoMono-BoldItalic.ttf │ │ │ │ ├── RobotoMono-ExtraLight.ttf │ │ │ │ ├── RobotoMono-ExtraLightItalic.ttf │ │ │ │ ├── RobotoMono-Italic.ttf │ │ │ │ ├── RobotoMono-Light.ttf │ │ │ │ ├── RobotoMono-LightItalic.ttf │ │ │ │ ├── RobotoMono-Medium.ttf │ │ │ │ ├── RobotoMono-MediumItalic.ttf │ │ │ │ ├── RobotoMono-Regular.ttf │ │ │ │ ├── RobotoMono-SemiBold.ttf │ │ │ │ ├── RobotoMono-SemiBoldItalic.ttf │ │ │ │ ├── RobotoMono-Thin.ttf │ │ │ │ └── RobotoMono-ThinItalic.ttf │ │ │ ├── index.html │ │ │ ├── index.tsx │ │ │ ├── mode │ │ │ │ ├── epics.ts │ │ │ │ ├── index.ts │ │ │ │ └── reducers.ts │ │ │ ├── notes │ │ │ │ ├── epics.ts │ │ │ │ ├── index.ts │ │ │ │ └── reducers.ts │ │ │ ├── selectors │ │ │ │ └── index.ts │ │ │ ├── settings │ │ │ │ ├── epics.ts │ │ │ │ ├── index.ts │ │ │ │ └── reducers.ts │ │ │ ├── store │ │ │ │ └── index.ts │ │ │ └── utils │ │ │ │ ├── color.ts │ │ │ │ ├── contextMenu.tsx │ │ │ │ ├── markdownConverter.ts │ │ │ │ ├── regex.ts │ │ │ │ ├── sql.d.ts │ │ │ │ ├── styled-components.d.ts │ │ │ │ ├── useEditorKeydown.ts │ │ │ │ ├── useEditorNoteEdit.ts │ │ │ │ ├── useEditorPaste.ts │ │ │ │ ├── useKeyPress.ts │ │ │ │ ├── useMenu.ts │ │ │ │ └── visuallyHidden.ts │ │ └── server │ │ │ └── index.ts │ ├── tsconfig.build.json │ ├── tsconfig.client.json │ ├── tsconfig.server.json │ └── webpack.config.js └── lib │ ├── README.md │ ├── package.json │ └── src │ ├── backup.ts │ ├── database │ ├── index.ts │ ├── migrateLegacyFileDB.ts │ └── sqlite │ │ ├── database.ts │ │ └── migrations │ │ ├── 001-initial.sql │ │ ├── 002-settings.sql │ │ └── index.ts │ ├── files.ts │ ├── notes.ts │ ├── search.ts │ ├── theme.ts │ └── types.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest] 15 | 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v1 19 | 20 | - name: Install Node.js, NPM and Yarn 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: 10 24 | 25 | - name: Build/release Electron app 26 | uses: gabrielpoca/action-electron-builder@181b2e24f3809285aff3c5e6147954d826804aee 27 | env: 28 | NODE_OPTIONS: --max_old_space_size=8192 29 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 30 | CSC_LINK: ${{ secrets.CSC_LINK }} 31 | APPLEID: ${{ secrets.APPLEID }} 32 | APPLEIDPASS: ${{ secrets.APPLEIDPASS }} 33 | with: 34 | github_token: ${{ secrets.github_token }} 35 | release: true 36 | max_attempts: 3 37 | -------------------------------------------------------------------------------- /.github/workflows/release_notes.yml: -------------------------------------------------------------------------------- 1 | name: Release Notes 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | release-notary: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v1 14 | 15 | - name: Release Notary Action 16 | uses: docker://outillage/release-notary 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - name: goreleaser 21 | uses: docker://goreleaser/goreleaser 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | with: 25 | args: release 26 | if: success() 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | .cache 4 | node_modules 5 | *.tgz 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![NoteDown](./assets/github_logo.png) 2 | 3 | NoteDown is a note-taking application built for **speed** and **power**. 4 | 5 | ![Screenshot](./assets/demo.png) 6 | 7 | The five principles behind the design of NoteDown are: 8 | 9 | - Keyboard-driven workflow. Almost everything should be available through the keyboard. 10 | - Local. Your notes never leave your computer. 11 | - Text-based. Notes are just text written in Markdown. 12 | - Multimedia. It must allow for images, PDFs, audio files, etc. 13 | - Customizable. The defaults have to be good, but you need to be able to change them. 14 | 15 | **Get the latest version from the [releases page](https://github.com/subvisual/notedown/releases)** 16 | 17 | ## Functionality 18 | 19 | Most note-taking applications work like a wiki: you can write titles for your notes, organize them by folder, assign tags, etc. NoteDown takes a different approach and it's more like a diary, or a scratchpad: you just write everything, anything, one note after the other in chronological order. 20 | 21 | NoteDown is only for the capture and retrieval of information. Put everything into it, and use the search when you need it. 22 | 23 | Besides text, you can add files to save them alongside your notes. Some files have special properties 24 | 25 | - Images will show in both the editor and the notes list. 26 | - The text inside a PDF will be indexed and you can search for it. 27 | - Audio files will show a player. 28 | - Youtube links will embed a player. 29 | - There will be more to come. 30 | 31 | but you can upload any file type. 32 | 33 | Everything you put into it, NoteDown will and make available through the search. 34 | 35 | ### The Editor 36 | 37 | NoteDown demands that you write in [Markdown](https://daringfireball.net/projects/markdown/syntax), and that's the only way to use it to its full potential. But the editor is not limited to writing Markdown: 38 | 39 | - You can drop a file from the file system. A Markdown link to 40 | that file will be inserted at the cursor's position. For images and audio 41 | files, a Markdown embed will be inserted. 42 | - You can paste images from the browser or the file system. A Markdown embed will be placed at the cursor's position. 43 | - Pasting a link will transform it into a Markdown links. 44 | - Images will have a preview bellow the link. We'll be adding more widgets for other elements. 45 | - Writing lists will automatically insert and remove the `*` and `nr.`. 46 | 47 | NoteDown also extends Markdown to allow: 48 | 49 | - Embedding audio files using the syntax `!audio[file name](file url)`. 50 | - Embedding Youtube videos using the syntax `!youtube[file name](link to video)`. 51 | 52 | ### Searching 53 | 54 | NoteDown works like a diary, where you write one note after the other. Time is the only thing connecting notes. To find something, you can either scroll all the way down or use the search. You should use the search as it works in text, file names, links, it even works for text inside PDFs! 55 | 56 | Since you cannot use tags or links like in other note-taking applications, the way to relate information is with keywords. For instance, all my notes related to NoteDown start, or end, with the word `notedown`. This makes it very easy for me to look it up. When I need to save a link to a page talking about Vim, I paste the link and then write a couple of related words such as `vim`, `editor`, or `workflow`. Search is full-text search, which means you can use `NOT`, `OR`, `AND`, `+` to refine the search. You can even use prefix, for instance, to find every word starting with _note_: `note*`. 57 | 58 | ### Notes List 59 | 60 | The notes list allows you to perform operations on individual notes: open in the editor, delete, open in focus mode, etc. There are a lot of shortcuts when you're focused in the notes list. 61 | 62 | ### Backup 63 | 64 | Your notes and files are only saved to your computer, so you should take some precautions to backup your data. The most important are notedown/notedown.sqlite and notedown/files/ and you can find them in: 65 | 66 | - `%APPDATA%` on Windows 67 | - `$XDG_CONFIG_HOME` or `~/.config` on Linux 68 | - `~/Library/Application Support` on macOS 69 | 70 | ### Tips 71 | 72 | - NoteDown comes with a few themes for you to choose from, but you can pick your colors and NoteDown will adjust some things around them, such as the text, to have the necessary contrast for them. 73 | - Use the "focus" mode to hide everything but the editor. 74 | 75 | ### Todo 76 | 77 | These are other things that we may do. We are also open to contributions, so feel free to make them and I'll help getting them merged. 78 | 79 | - Allow writing charts and digrams in markdown. 80 | - Mobile app. 81 | 82 | ## Setup 83 | 84 | To install NoteDown, run the follow commands: 85 | 86 | ```sh 87 | git clone git@github.com:subvisual/notedown.git 88 | cd notedown 89 | bin/setup 90 | ``` 91 | 92 | ## Development 93 | 94 | To start the development environment, run: 95 | 96 | ```sh 97 | bin/server 98 | ``` 99 | 100 | ## Deployment 101 | 102 | If you're building on macOS and you don't have a signing profile setup, you may 103 | want set the environment variable `CSC_IDENTITY_AUTO_DISCOVERY` to `false`. 104 | 105 | To create a macOS and Linux builds, run: 106 | 107 | ```sh 108 | bin/build 109 | ``` 110 | 111 | ## About 112 | 113 | NoteDown was created and is maintained with :heart: by [Subvisual][subvisual]. 114 | If you have any questions or comments, feel free to open an issue or reach out 115 | to [Gabriel on Twitter](https://twitter.com/gabrielgpoca). 116 | 117 | [![Subvisual][subvisual-logo]][subvisual] 118 | 119 | [subvisual]: http://subvisual.com 120 | [subvisual-logo]: https://raw.githubusercontent.com/subvisual/guides/master/github/templates/logos/blue.png 121 | -------------------------------------------------------------------------------- /assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/assets/demo.png -------------------------------------------------------------------------------- /assets/github_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/assets/github_logo.png -------------------------------------------------------------------------------- /assets/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/assets/icons/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "scripts": { 5 | "build": "yarn workspace @notedown/app-desktop build", 6 | "pack": "yarn workspace @notedown/app-desktop pack", 7 | "dist": "yarn workspace @notedown/app-desktop dist", 8 | "release": "yarn workspace @notedown/app-desktop release" 9 | }, 10 | "devDependencies": {}, 11 | "workspaces": [ 12 | "packages/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/app-desktop/README.md: -------------------------------------------------------------------------------- 1 | # `app-desktop` 2 | 3 | > TODO: description 4 | 5 | ## Usage 6 | 7 | ``` 8 | const appDesktop = require('app-desktop'); 9 | 10 | // TODO: DEMONSTRATE API 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/app-desktop/bin/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | yarn dist 4 | -------------------------------------------------------------------------------- /packages/app-desktop/bin/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require("electron-notarize"); 2 | 3 | exports.default = async function notarizing(context) { 4 | const { electronPlatformName, appOutDir } = context; 5 | if (electronPlatformName !== "darwin") { 6 | return; 7 | } 8 | 9 | const appName = context.packager.appInfo.productFilename; 10 | 11 | return await notarize({ 12 | appBundleId: "com.gabrielpoca.notedown", 13 | appPath: `${appOutDir}/${appName}.app`, 14 | ascProvider: "45J8Z3Q97Q", 15 | appleId: process.env.APPLEID, 16 | appleIdPassword: process.env.APPLEIDPASS, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/app-desktop/bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | yarn install 4 | -------------------------------------------------------------------------------- /packages/app-desktop/bin/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | yarn start 4 | -------------------------------------------------------------------------------- /packages/app-desktop/build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.disable-library-validation 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/app-desktop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@notedown/app-desktop", 3 | "version": "1.14.0", 4 | "main": "./build/server/index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "repository": { 8 | "type": "git", 9 | "url": "git@github.com:subvisual/notedown.git" 10 | }, 11 | "scripts": { 12 | "start": "concurrently --raw \"yarn webpack-start-client\" \"yarn webpack-start-server\" \"yarn server-start\"", 13 | "server-start": "wait-on -d 1500 -i 500 http://localhost:8080 && nodemon --watch ./build/server/ --ext js --exec \"NODE_ENV=development electron build/server/index.js\"", 14 | "webpack-start-client": "NODE_ENV=development webpack serve --config-name client", 15 | "webpack-start-server": "NODE_ENV=development webpack --watch --config-name server", 16 | "build": "NODE_ENV=production webpack", 17 | "pack": "yarn build && electron-builder --dir", 18 | "dist": "yarn build && electron-builder -ml", 19 | "release": "electron-builder" 20 | }, 21 | "build": { 22 | "productName": "NoteDown", 23 | "appId": "com.gabrielpoca.notedown", 24 | "afterSign": "bin/notarize.js", 25 | "mac": { 26 | "target": [ 27 | "dmg" 28 | ], 29 | "gatekeeperAssess": false, 30 | "entitlements": "build/entitlements.mac.plist", 31 | "entitlementsInherit": "build/entitlements.mac.plist", 32 | "hardenedRuntime": true, 33 | "category": "com.gabrielpoca.notedown", 34 | "icon": "assets/icons/icon.png" 35 | }, 36 | "linux": { 37 | "target": "tar.gz" 38 | }, 39 | "dmg": { 40 | "sign": false 41 | }, 42 | "files": [ 43 | "build/**/*" 44 | ], 45 | "protocols": { 46 | "name": "notedown-protocol", 47 | "schemes": [ 48 | "notedown" 49 | ] 50 | } 51 | }, 52 | "dependencies": { 53 | "sqlite3": "^5.0.0" 54 | }, 55 | "devDependencies": { 56 | "@reduxjs/toolkit": "^1.4.0", 57 | "@types/codemirror": "^0.0.93", 58 | "@types/lodash": "^4.14.150", 59 | "@types/lowdb": "^1.0.9", 60 | "@types/node": "12", 61 | "@types/pdfjs-dist": "^2.1.4", 62 | "@types/react": "^16.9.34", 63 | "@types/react-color": "^3.0.2", 64 | "@types/react-dom": "^16.9.7", 65 | "@types/react-modal": "^3.10.5", 66 | "@types/react-redux": "^7.1.8", 67 | "@types/shortid": "^0.0.29", 68 | "@types/showdown": "^1.9.3", 69 | "@types/sqlite3": "^3.1.6", 70 | "@types/styled-components": "^5.1.0", 71 | "axios": "^0.21.1", 72 | "codemirror": "^5.53.2", 73 | "concurrently": "^5.2.0", 74 | "css-loader": "^3.5.3", 75 | "date-fns": "^2.13.0", 76 | "electron": "10", 77 | "electron-builder": "^22.10.4", 78 | "electron-is-dev": "^1.2.0", 79 | "electron-notarize": "^1.0.0", 80 | "error-overlay-webpack-plugin": "^0.4.1", 81 | "file-loader": "^6.0.0", 82 | "html-webpack-plugin": "^4.3.0", 83 | "lowdb": "^1.0.0", 84 | "nodemon": "^2.0.3", 85 | "pdfjs-dist": "^2.4.456", 86 | "raw-loader": "^4.0.1", 87 | "react": ">= 16.8.0", 88 | "react-color": "^2.18.1", 89 | "react-dom": ">= 16.8.0", 90 | "react-modal": "^3.11.2", 91 | "react-redux": "^7.2.0", 92 | "redux": "^4.0.5", 93 | "redux-devtools": "^3.5.0", 94 | "redux-observable": "^1.2.0", 95 | "reselect": "^4.0.0", 96 | "rxjs": "^6.5.5", 97 | "shortid": "^2.2.15", 98 | "showdown": "^1.9.1", 99 | "style-loader": "^1.2.1", 100 | "styled-components": "^5.1.0", 101 | "terser-webpack-plugin": "^3.0.1", 102 | "ts-loader": "^7.0.3", 103 | "typescript": "4", 104 | "wait-on": "^4.0.2", 105 | "webpack": "5", 106 | "webpack-cli": "4", 107 | "webpack-dev-server": "^3.10.3" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/App.tsx: -------------------------------------------------------------------------------- 1 | import {} from "electron"; 2 | import * as React from "react"; 3 | import { createGlobalStyle } from "styled-components"; 4 | import * as Modal from "react-modal"; 5 | 6 | Modal.setAppElement("#root"); 7 | 8 | import { CustomThemeProvider } from "./components/CustomThemeProvider"; 9 | import { HomePage } from "./components/HomePage"; 10 | import { Settings } from "./components/Settings"; 11 | import { ColorPicker } from "./components/ColorPicker"; 12 | import { DatabaseProvider } from "./components/DatabaseProvider"; 13 | import { isDark } from "./utils/color"; 14 | import { useMenu } from "./utils/useMenu"; 15 | 16 | const Global = createGlobalStyle` 17 | ::-webkit-scrollbar { 18 | width: 0px; 19 | background: transparent; 20 | } 21 | 22 | html, body, #root { 23 | background-color: ${({ theme }) => theme.background1}; 24 | color: ${({ theme }) => (isDark(theme.background1) ? "#FFFFFF" : "#333")}; 25 | display: flex; 26 | flex-direction: column; 27 | flex: 1; 28 | font-family: Montserrat; 29 | font-size: 16px; 30 | height: 100%; 31 | line-height: 1.4; 32 | max-height: 100%; 33 | max-width: 100%; 34 | overflow: hidden; 35 | width: 100%; 36 | } 37 | 38 | h1, h2, h3, h4 { 39 | line-height: 1.4; 40 | font-weight: 600; 41 | } 42 | 43 | input, label { 44 | color: inherit; 45 | font-size: inherit; 46 | } 47 | 48 | a { 49 | color: inherit; 50 | text-decoration: underline; 51 | line-height: inherit; 52 | } 53 | 54 | code { 55 | font-family: 'RobotoMono'; 56 | } 57 | 58 | * { 59 | -webkit-font-smoothing: antialiased; 60 | box-sizing: border-box; 61 | margin: 0; 62 | min-width: 0; 63 | padding: 0; 64 | } 65 | `; 66 | 67 | export default function App() { 68 | useMenu(); 69 | 70 | return ( 71 | 72 | <> 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/CodeMirrorEditor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as _ from "lodash"; 3 | import styled from "styled-components"; 4 | import * as CodeMirror from "codemirror"; 5 | import { Subject } from "rxjs"; 6 | 7 | import "codemirror/lib/codemirror.css"; 8 | import "codemirror/addon/display/placeholder"; 9 | import "codemirror/addon/display/placeholder"; 10 | import "codemirror/mode/gfm/gfm"; 11 | import "codemirror/mode/javascript/javascript"; 12 | import "codemirror/mode/css/css"; 13 | import "codemirror/mode/ruby/ruby"; 14 | import "codemirror/mode/rust/rust"; 15 | 16 | import * as Files from "@notedown/lib/files"; 17 | 18 | import { 19 | isHref, 20 | isImageSrc, 21 | isAudioSrc, 22 | matchMdImage, 23 | matchMdListItem, 24 | } from "utils/regex"; 25 | 26 | export const pasteWithoutFormatting = new Subject(); 27 | 28 | const Input = styled.textarea` 29 | width: 100%; 30 | padding: 1rem 1rem; 31 | border: 0; 32 | background: transparent; 33 | `; 34 | 35 | interface Props { 36 | extraKeys: { [key: string]: (cm?: CodeMirror.Editor) => any }; 37 | placeholder: string; 38 | onFocus: () => any; 39 | onBlur: () => any; 40 | onEditor: (editor: CodeMirror.Editor) => any; 41 | } 42 | 43 | export const CodeMirrorEditor = ({ 44 | extraKeys, 45 | placeholder, 46 | onFocus, 47 | onBlur, 48 | onEditor, 49 | }: Props) => { 50 | const ref = React.useRef(null); 51 | const [editor, setEditor] = React.useState(null); 52 | 53 | React.useEffect(() => { 54 | if (editor) editor.setOption("extraKeys", extraKeys); 55 | }, [editor, extraKeys]); 56 | 57 | React.useEffect(() => { 58 | if (editor) editor.setOption("placeholder", placeholder); 59 | }, [editor, placeholder]); 60 | 61 | React.useEffect(() => { 62 | if (!editor) return; 63 | 64 | editor.on("focus", onFocus); 65 | editor.on("blur", onBlur); 66 | 67 | return () => { 68 | editor.off("focus", onFocus); 69 | editor.off("blur", onBlur); 70 | }; 71 | }, [editor, onFocus, onBlur]); 72 | 73 | React.useEffect(() => { 74 | if (!editor) return; 75 | 76 | const subscription = pasteWithoutFormatting.subscribe({ 77 | next: (text: string) => { 78 | const doc = editor.getDoc(); 79 | var cursor = doc.getCursor(); 80 | doc.replaceRange(text, cursor); 81 | }, 82 | }); 83 | 84 | return () => subscription.unsubscribe(); 85 | }, [editor]); 86 | 87 | React.useLayoutEffect(() => { 88 | const codeMirror = CodeMirror.fromTextArea(ref.current, { 89 | placeholder, 90 | lineWrapping: true, 91 | lint: false, 92 | autocorrect: false, 93 | autocapitalize: false, 94 | spellcheck: false, 95 | gutters: ["cm-custom-gutter-space"], 96 | mode: { 97 | fencedCodeBlockHighlighting: false, 98 | highlightFormatting: true, 99 | name: "gfm", 100 | tokenTypeOverrides: { 101 | emoji: "emoji", 102 | }, 103 | }, 104 | theme: "notes", 105 | extraKeys, 106 | }); 107 | 108 | let widgets: { 109 | line: number; 110 | widget: CodeMirror.LineWidget; 111 | url: string; 112 | img: HTMLImageElement; 113 | }[] = []; 114 | 115 | const onUpdate = ( 116 | _editor: any, 117 | change: CodeMirror.EditorChangeLinkedList 118 | ) => { 119 | const { from, to, text, removed, origin } = change; 120 | 121 | if (origin === "paste") { 122 | if (isHref(text[0])) { 123 | setImmediate(() => { 124 | codeMirror.replaceRange( 125 | `[${text[0]}](${text[0]})`, 126 | from, 127 | { 128 | line: to.line, 129 | ch: from.ch + text[0].length, 130 | }, 131 | "+notedown" 132 | ); 133 | 134 | codeMirror.setSelection( 135 | { line: to.line, ch: from.ch + 1 }, 136 | { line: to.line, ch: from.ch + 1 + text[0].length } 137 | ); 138 | }); 139 | } 140 | } 141 | 142 | // auto insert list characters 143 | if (text.length > 1 && text[0] === "" && text[1] === "") { 144 | const line = codeMirror.getLine(from.line); 145 | const doc = codeMirror.getDoc(); 146 | const cursor = doc.getCursor(); 147 | 148 | const match = matchMdListItem(line); 149 | 150 | if (match) { 151 | // previous line is empty 152 | if (match.empty && codeMirror.getLine(from.line + 1) === "") { 153 | codeMirror.replaceRange("", { line: from.line, ch: 0 }, to); 154 | } else if (match.type === "unordered") { 155 | codeMirror.replaceRange(match.nextElement, cursor); 156 | } else { 157 | codeMirror.replaceRange(match.nextElement, cursor); 158 | } 159 | } 160 | } 161 | 162 | if (removed.length > text.length) { 163 | const diff = removed.length - text.length; 164 | 165 | widgets.forEach((widget) => { 166 | if (widget.line > from.line + diff) widget.line -= diff; 167 | }); 168 | } else if (text.length > removed.length) { 169 | const diff = text.length - removed.length; 170 | 171 | widgets.forEach((widget) => { 172 | if (widget.line > from.line + diff) widget.line += diff; 173 | }); 174 | } 175 | 176 | _.range(from.line, Math.max(to.line, from.line + text.length) + 1).map( 177 | (line: number) => { 178 | const lineValue = codeMirror.getLine(line); 179 | const lineWidget = _.find(widgets, { line }); 180 | 181 | if (!lineValue) { 182 | if (lineWidget) { 183 | lineWidget.widget.clear(); 184 | widgets = _.reject(widgets, { line }); 185 | } 186 | 187 | return; 188 | } 189 | 190 | const match = matchMdImage(lineValue); 191 | 192 | if (!match) { 193 | if (lineWidget) { 194 | lineWidget.widget.clear(); 195 | widgets = _.reject(widgets, { line }); 196 | } 197 | 198 | return; 199 | } 200 | 201 | let url = match.src; 202 | 203 | if (lineWidget && lineWidget.url === url) { 204 | return; 205 | } 206 | 207 | if (lineWidget) { 208 | lineWidget.img.src = url; 209 | lineWidget.widget.changed(); 210 | } else { 211 | const img = document.createElement("img"); 212 | img.src = url; 213 | widgets.push({ 214 | line, 215 | img: img, 216 | url: url, 217 | widget: codeMirror.addLineWidget(line, img, { 218 | handleMouseEvents: true, 219 | }), 220 | }); 221 | } 222 | } 223 | ); 224 | }; 225 | 226 | const onDrop = async (_: any, e: React.DragEvent) => { 227 | const files = e.dataTransfer.files; 228 | let savedFile; 229 | const doc = codeMirror.getDoc(); 230 | const cursor = doc.getCursor(); 231 | 232 | if (e.dataTransfer.getData("url")) { 233 | const url = e.dataTransfer.getData("url"); 234 | savedFile = await Files.addRemoteFile(url); 235 | } else { 236 | for (var i = 0; i < files.length; i++) { 237 | const file = files.item(i); 238 | savedFile = await Files.addLocalFile(file); 239 | } 240 | } 241 | 242 | if (isImageSrc(savedFile.filePath)) 243 | doc.replaceRange( 244 | `![${savedFile.name}](notesfile://${savedFile.fileName})`, 245 | cursor 246 | ); 247 | else if (isAudioSrc(savedFile.filePath)) 248 | doc.replaceRange( 249 | `!audio[${savedFile.name}](notesfile://${savedFile.fileName})`, 250 | cursor 251 | ); 252 | else 253 | doc.replaceRange( 254 | `[${savedFile.name}](notesfile://${window.encodeURI( 255 | savedFile.fileName 256 | )})`, 257 | cursor 258 | ); 259 | }; 260 | 261 | codeMirror.on("change", onUpdate); 262 | // @ts-ignore 263 | codeMirror.on("drop", onDrop); 264 | 265 | setEditor(codeMirror); 266 | onEditor(codeMirror); 267 | 268 | return () => { 269 | codeMirror.off("change", onUpdate); 270 | // @ts-ignore 271 | codeMirror.off("drop", onDrop); 272 | onEditor(null); 273 | setEditor(null); 274 | }; 275 | }, [ref]); 276 | 277 | return ; 278 | }; 279 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChromePicker } from "react-color"; 3 | import styled from "styled-components"; 4 | import { useSelector, useDispatch } from "react-redux"; 5 | import * as ReactModal from "react-modal"; 6 | import { findKey, map, pick, keys } from "lodash"; 7 | 8 | import { ThemeColors } from "@notedown/lib/types"; 9 | import { getTheme, getMode } from "../selectors"; 10 | import { themeColors } from "../settings"; 11 | import { textColorForBackground } from "../utils/color"; 12 | import { modeClose } from "../mode"; 13 | import { ThemeItem } from "./ThemeItem"; 14 | 15 | const PICKER_WIDTH = 225; 16 | 17 | const customStyles = { 18 | overlay: { 19 | zIndex: 2, 20 | backgroundColor: "transparent", 21 | }, 22 | content: { 23 | backgroundColor: "transparent", 24 | border: 0, 25 | bottom: "auto", 26 | left: "auto", 27 | margin: 0, 28 | padding: 0, 29 | right: "1rem", 30 | top: "calc(4rem + 1rem)", 31 | }, 32 | }; 33 | 34 | const staticThemes = { 35 | default: { 36 | background1: "#2a2438", 37 | background2: "#352f44", 38 | accent1: "#411e8f", 39 | }, 40 | subvisual: { 41 | background1: "#FFFFFF", 42 | background2: "#F1F6FF", 43 | accent1: "#045CFC", 44 | }, 45 | light: { 46 | background1: "#FFFFFF", 47 | background2: "#E9E7EA", 48 | accent1: "#411e8f", 49 | }, 50 | dark: { 51 | background1: "#202020", 52 | background2: "#272727", 53 | accent1: "#045cfc", 54 | }, 55 | }; 56 | 57 | const colors: (keyof ThemeColors)[] = ["background1", "background2", "accent1"]; 58 | 59 | const colorLabel: ThemeColors = { 60 | accent1: "Highlight", 61 | background1: "Background 1", 62 | background2: "Background 2", 63 | }; 64 | 65 | const Root = styled.div` 66 | display: flex; 67 | flex-direction: column; 68 | align-items: flex-end; 69 | `; 70 | 71 | const ThemePicker = styled.div` 72 | background: ${({ theme }) => theme.background2}; 73 | padding: 2rem; 74 | border-radius: 0.5rem; 75 | `; 76 | 77 | const CustomPicker = styled.div` 78 | padding: 2rem; 79 | background: ${({ theme }) => theme.background2}; 80 | display: grid; 81 | grid-template-columns: auto ${PICKER_WIDTH}px; 82 | grid-column-gap: 2rem; 83 | border-radius: 0.5rem; 84 | min-height: 290px; 85 | margin-top: 1rem; 86 | min-width: 340px; 87 | `; 88 | 89 | const ColorGroup = styled.div` 90 | display: grid; 91 | grid-template-columns: auto 1fr; 92 | grid-row-gap: 1rem; 93 | grid-column-gap: 0.5rem; 94 | align-items: center; 95 | align-content: start; 96 | `; 97 | 98 | const ThemeGroup = styled.div` 99 | display: grid; 100 | grid-template-columns: 1fr; 101 | grid-row-gap: 1rem; 102 | align-items: center; 103 | align-content: start; 104 | `; 105 | 106 | const Label = styled.label` 107 | color: ${({ theme }) => textColorForBackground(theme.background2)}; 108 | font-size: 0.75rem; 109 | cursor: pointer; 110 | `; 111 | 112 | export const ColorPicker = () => { 113 | const [themeName, setThemeName] = React.useState< 114 | keyof typeof staticThemes | "custom" 115 | >(null); 116 | const [color, setColor] = React.useState("accent1"); 117 | const currentMode = useSelector(getMode); 118 | const dispatch = useDispatch(); 119 | const ref = React.useRef(null); 120 | const theme = useSelector(getTheme); 121 | 122 | React.useEffect(() => { 123 | const name = findKey( 124 | staticThemes, 125 | pick(theme.colors, ["accent1", "background1", "background2"]) 126 | ); 127 | 128 | if (name) setThemeName(name as keyof typeof staticThemes); 129 | else setThemeName("custom"); 130 | }, [theme, setThemeName]); 131 | 132 | React.useLayoutEffect(() => { 133 | if (!ref.current) return; 134 | 135 | const keydownHandler = (e: KeyboardEvent) => { 136 | e.stopPropagation(); 137 | }; 138 | 139 | ref.current.addEventListener("keydown", keydownHandler, { passive: true }); 140 | 141 | return () => { 142 | ref.current && ref.current.removeEventListener("keydown", keydownHandler); 143 | }; 144 | }, [ref.current, currentMode]); 145 | 146 | return ( 147 | <> 148 | dispatch(modeClose())} 151 | style={customStyles} 152 | contentLabel={"Theme Editor"} 153 | > 154 | 155 | 156 | 157 | {map(staticThemes, (value, key) => ( 158 | dispatch(themeColors(value))} 163 | theme={value} 164 | /> 165 | ))} 166 | setThemeName("custom")} 170 | theme={theme.colors} 171 | /> 172 | 173 | 174 | {themeName === "custom" && ( 175 | 176 | 177 | {colors.map((colorName) => ( 178 | <> 179 | setColor(colorName)} 181 | type="radio" 182 | id={colorName} 183 | name="color" 184 | value={colorName} 185 | checked={color == colorName} 186 | /> 187 | 188 | 189 | ))} 190 | 191 | 194 | dispatch(themeColors({ ...theme.colors, [color]: hex })) 195 | } 196 | color={theme.colors[color]} 197 | /> 198 | 199 | )} 200 | 201 | 202 | 203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/ConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Modal } from "./Modal"; 3 | import styled from "styled-components"; 4 | 5 | import { textColorForBackground } from "../utils/color"; 6 | 7 | const Content = styled.div` 8 | display: grid; 9 | padding: 1rem; 10 | grid-template-columns: auto 1fr; 11 | grid-row-gap: 1rem; 12 | grid-column-gap: 1rem; 13 | justify-items: start; 14 | `; 15 | 16 | const Label = styled.div` 17 | grid-column: 1 / span 2; 18 | `; 19 | 20 | const Button = styled.button` 21 | background: none; 22 | border-radius: 4px; 23 | color: inherit; 24 | cursor: pointer; 25 | font-size: inherit; 26 | outline: none; 27 | padding: 0.5rem; 28 | text-decoration: none; 29 | border: 0; 30 | 31 | &:focus { 32 | background: ${({ theme }) => theme.accent1}; 33 | color: ${({ theme }) => textColorForBackground(theme.accent1)}; 34 | } 35 | `; 36 | 37 | export const ConfirmationDialog = ({ 38 | onYes, 39 | onNo, 40 | label, 41 | }: { 42 | onYes: () => any; 43 | onNo: () => any; 44 | label: string; 45 | }) => { 46 | return ( 47 | 48 | 49 | 50 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/CustomThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ThemeProvider } from "styled-components"; 3 | 4 | import { useSelector } from "react-redux"; 5 | import { getTheme } from "../selectors"; 6 | 7 | export const CustomThemeProvider = ({ children }: { children: any }) => { 8 | const currentTheme = useSelector(getTheme); 9 | 10 | return {children}; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/DatabaseProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | 4 | import { databaseLoad } from "database"; 5 | import { createDatabase } from "@notedown/lib/database"; 6 | import { getDb } from "../selectors"; 7 | import { themeLoad, backupFolder } from "../settings"; 8 | 9 | export const DatabaseProvider = ({ children }: { children: any }) => { 10 | const dispatch = useDispatch(); 11 | const db = useSelector(getDb); 12 | 13 | React.useEffect(() => { 14 | if (!dispatch) return; 15 | 16 | (async () => { 17 | const db = await createDatabase(); 18 | 19 | dispatch(databaseLoad({ db })); 20 | })(); 21 | }, [dispatch]); 22 | 23 | React.useEffect(() => { 24 | if (!dispatch || !db) return; 25 | 26 | dispatch(themeLoad()); 27 | dispatch(backupFolder()); 28 | }, [db, dispatch]); 29 | 30 | if (!db) return null; 31 | 32 | return <>{children}; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as _ from "lodash"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { CodeMirrorEditor } from "./CodeMirrorEditor"; 5 | const { remote } = window.require("electron"); 6 | 7 | import "codemirror/mode/gfm/gfm"; 8 | import "codemirror/lib/codemirror.css"; 9 | import "codemirror/addon/display/placeholder"; 10 | 11 | import { notesAdd, notesUpdate, notesEditTmp } from "notes"; 12 | import { getEdit, getWritingFocusMode, getMode } from "selectors"; 13 | import { useEditorPaste } from "utils/useEditorPaste"; 14 | import { useEditorKeydown } from "utils/useEditorKeydown"; 15 | import { useEditorNoteEdit } from "utils/useEditorNoteEdit"; 16 | import { modeSet } from "mode"; 17 | import EditorRoot from "./EditorRoot"; 18 | import { debounce } from "lodash"; 19 | 20 | const placeholder = "Write your thoughts or drag any files here"; 21 | 22 | export function Editor() { 23 | const rootRef = React.useRef(null); 24 | const dispatch = useDispatch(); 25 | const noteEdit = useSelector(getEdit); 26 | const [editor, setEditor] = React.useState(null); 27 | const writingFocusMode = useSelector(getWritingFocusMode); 28 | const mode = useSelector(getMode); 29 | 30 | useEditorNoteEdit(editor, noteEdit); 31 | useEditorKeydown(rootRef); 32 | useEditorPaste(rootRef, editor); 33 | 34 | React.useEffect(() => { 35 | if (!editor) return; 36 | 37 | if (mode === "editor" || writingFocusMode) editor.focus(); 38 | else editor.getInputField().blur(); 39 | }, [mode, editor]); 40 | 41 | React.useEffect(() => { 42 | if (!editor) return; 43 | 44 | const onChange = debounce( 45 | () => { 46 | const currentContent = editor.getValue(); 47 | 48 | if (noteEdit || currentContent) { 49 | dispatch(notesEditTmp({ ...noteEdit, content: currentContent })); 50 | } else { 51 | dispatch(notesEditTmp(null)); 52 | } 53 | }, 54 | 500, 55 | { maxWait: 1000, trailing: true, leading: true } 56 | ); 57 | 58 | editor.on("change", onChange); 59 | 60 | return () => { 61 | editor.off("change", onChange); 62 | }; 63 | }, [editor, noteEdit, dispatch]); 64 | 65 | const extraKeys = React.useMemo(() => { 66 | const save = (codeMirror: CodeMirror.Editor) => { 67 | if (noteEdit && noteEdit.id && noteEdit.id !== "draft") { 68 | dispatch( 69 | notesUpdate({ 70 | ...noteEdit, 71 | content: codeMirror.getValue(), 72 | history: editor.getHistory(), 73 | }) 74 | ); 75 | } else { 76 | dispatch( 77 | notesAdd({ 78 | content: codeMirror.getValue(), 79 | history: editor.getHistory(), 80 | }) 81 | ); 82 | } 83 | setImmediate(() => { 84 | editor.getInputField().blur(); 85 | }); 86 | }; 87 | 88 | return { 89 | Esc: () => { 90 | if (mode === "editor" || writingFocusMode) dispatch(modeSet("notes")); 91 | }, 92 | "Shift-Ctrl-Enter": () => { 93 | dispatch(modeSet("editorFocus")); 94 | }, 95 | "Shift-Cmd-Enter": () => { 96 | dispatch(modeSet("editorFocus")); 97 | }, 98 | "Ctrl-Enter": (codeMirror: CodeMirror.Editor) => { 99 | save(codeMirror); 100 | }, 101 | "Cmd-Enter": (codeMirror: CodeMirror.Editor) => { 102 | save(codeMirror); 103 | }, 104 | }; 105 | }, [mode, editor, noteEdit, dispatch]); 106 | 107 | const onFocus = React.useCallback(() => { 108 | if (mode !== "editor" && !writingFocusMode) dispatch(modeSet("editor")); 109 | }, [mode]); 110 | 111 | const onBlur = React.useCallback(() => { 112 | if ( 113 | (mode === "editor" || writingFocusMode) && 114 | !!remote.webContents.getFocusedWebContents() 115 | ) 116 | dispatch(modeSet("notes")); 117 | }, [mode]); 118 | 119 | return ( 120 | 121 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/EditorRoot.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import { 4 | textColorForBackground, 5 | shadeColor, 6 | smallContrast, 7 | } from "../utils/color"; 8 | 9 | export default styled.div<{ center: boolean }>` 10 | position: relative; 11 | flex: 1; 12 | overflow: scroll; 13 | color: ${({ theme }) => textColorForBackground(theme.background1)}; 14 | padding: 1rem 0 0.8rem 0; 15 | 16 | @media (max-width: 900px) { 17 | padding: 1rem 2rem 1rem 0; 18 | } 19 | 20 | .cm-custom-gutter-space { 21 | width: ${({ center }) => (center ? "0" : "3rem")}; 22 | background: ${({ theme }) => theme.background1}; 23 | } 24 | 25 | img { 26 | max-height: 200px; 27 | max-width: 100%; 28 | border-radius: 0.5rem; 29 | } 30 | 31 | .CodeMirror { 32 | font-family: Montserrat; 33 | height: 100%; 34 | } 35 | 36 | .cm-s-notes.CodeMirror { 37 | background: ${({ theme }) => theme.background1}; 38 | color: currentColor; 39 | } 40 | 41 | .cm-s-notes .CodeMirror-placeholder { 42 | margin-left: 0.5rem; 43 | color: ${({ theme }) => 44 | shadeColor(textColorForBackground(theme.background1), 30)}; 45 | } 46 | 47 | .cm-s-notes div.CodeMirror-selected { 48 | background: ${({ theme }) => smallContrast(theme.background1)}; 49 | } 50 | 51 | .cm-s-notes .CodeMirror-gutters { 52 | background: currentColor; 53 | border-right: 0px; 54 | } 55 | 56 | .cm-s-notes .CodeMirror-guttermarker { 57 | color: currentColor; 58 | } 59 | 60 | .cm-s-notes .CodeMirror-guttermarker-subtle { 61 | color: currentColor; 62 | } 63 | 64 | .cm-s-notes .CodeMirror-linenumber { 65 | color: currentColor; 66 | } 67 | 68 | .cm-s-notes .CodeMirror-cursor { 69 | border-left: 1px solid currentColor; 70 | } 71 | 72 | .cm-s-notes span.cm-comment { 73 | color: currentColor; 74 | font-family: "RobotoMono"; 75 | } 76 | 77 | .cm-s-notes span.cm-atom { 78 | color: currentColor; 79 | } 80 | 81 | .cm-s-notes span.cm-number { 82 | color: currentColor; 83 | } 84 | 85 | .cm-s-notes span.cm-comment.cm-attribute { 86 | color: currentColor; 87 | } 88 | 89 | .cm-s-notes span.cm-comment.cm-def { 90 | color: currentColor; 91 | } 92 | 93 | .cm-s-notes span.cm-comment.cm-tag { 94 | color: currentColor; 95 | font-weight: bold; 96 | } 97 | 98 | .cm-s-notes span.cm-comment.cm-type { 99 | color: currentColor; 100 | } 101 | 102 | .cm-s-notes span.cm-property, 103 | .cm-s-notes span.cm-attribute { 104 | color: currentColor; 105 | } 106 | 107 | .cm-s-notes span.cm-keyword { 108 | color: currentColor; 109 | } 110 | 111 | .cm-s-notes span.cm-builtin { 112 | color: currentColor; 113 | } 114 | 115 | .cm-s-notes span.cm-string:not(.cm-link) { 116 | color: currentColor; 117 | font-weight: bold; 118 | } 119 | 120 | .cm-s-notes .cm-link.cm-url.cm-string { 121 | font-style: italic; 122 | color: ${({ theme }) => smallContrast(theme.background2)}; 123 | } 124 | 125 | .cm-s-notes span.cm-variable { 126 | color: currentColor; 127 | } 128 | 129 | .cm-s-notes span.cm-variable-2 { 130 | color: currentColor; 131 | } 132 | 133 | .cm-s-notes span.cm-variable-3, 134 | .cm-s-notes span.cm-type { 135 | color: currentColor; 136 | } 137 | 138 | .cm-s-notes span.cm-def { 139 | color: currentColor; 140 | } 141 | 142 | .cm-s-notes span.cm-bracket { 143 | color: currentColor; 144 | } 145 | 146 | .cm-s-notes span.cm-tag { 147 | color: currentColor; 148 | font-weight: bold; 149 | } 150 | 151 | .cm-s-notes span.cm-header { 152 | color: currentColor; 153 | } 154 | 155 | .cm-s-notes span.cm-header-1 { 156 | font-size: 1.3rem; 157 | } 158 | 159 | .cm-s-notes span.cm-header-2 { 160 | font-size: 1.2rem; 161 | } 162 | 163 | .cm-s-notes span.cm-header-3 { 164 | font-size: 1.1rem; 165 | } 166 | 167 | .cm-s-notes span.cm-link { 168 | color: currentColor; 169 | } 170 | 171 | .cm-s-notes span.cm-error { 172 | background: ${({ theme }) => theme.accent1}; 173 | color: ${({ theme }) => textColorForBackground(theme.accent1)}; 174 | } 175 | 176 | .cm-s-notes .CodeMirror-activeline-background { 177 | background: currentColor; 178 | } 179 | 180 | .cm-s-notes .CodeMirror-matchingbracket { 181 | text-decoration: underline; 182 | color: currentColor !important; 183 | } 184 | 185 | .cm-s-notes .cm-formatting.cm-formatting-header { 186 | font-weight: normal; 187 | } 188 | 189 | .cm-s-notes .cm-formatting.cm-formatting-strong { 190 | font-weight: normal; 191 | } 192 | 193 | .cm-s-notes .cm-formatting.cm-formatting-em { 194 | font-style: normal; 195 | } 196 | 197 | .cm-s-notes .cm-formatting.cm-formatting-task { 198 | font-weight: bold; 199 | } 200 | `; 201 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/Help.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import * as ReactModal from "react-modal"; 4 | import { useSelector, useDispatch } from "react-redux"; 5 | 6 | import { textColorForBackground } from "../utils/color"; 7 | import { getMode, getTheme } from "../selectors"; 8 | import { modeClose, modeSet } from "../mode"; 9 | import { Title } from "./Title"; 10 | 11 | const Content = styled.div` 12 | padding: 2rem; 13 | 14 | p { 15 | margin-bottom: 1rem; 16 | } 17 | 18 | ul { 19 | list-style-position: outside; 20 | padding-left: 1rem; 21 | margin-bottom: 2rem; 22 | } 23 | 24 | button { 25 | background: transparent; 26 | border: 0; 27 | text-decoration: underline; 28 | font-size: inherit; 29 | color: currentColor; 30 | font-family: inherit; 31 | } 32 | `; 33 | 34 | const SideBySide = styled.div` 35 | display: grid; 36 | grid-template-columns: repeat(2, 1fr); 37 | grid-column-gap: 4rem; 38 | `; 39 | 40 | export const Help = () => { 41 | const currentMode = useSelector(getMode); 42 | const dispatch = useDispatch(); 43 | const theme = useSelector(getTheme); 44 | 45 | return ( 46 | dispatch(modeClose())} 49 | style={{ 50 | overlay: { 51 | backgroundColor: theme.colors.background1, 52 | zIndex: 4, 53 | }, 54 | content: { 55 | backgroundColor: "transparent", 56 | border: 0, 57 | bottom: "auto", 58 | color: textColorForBackground(theme.colors.background1), 59 | left: "50%", 60 | margin: 0, 61 | minWidth: 600, 62 | overflow: "initial", 63 | padding: 0, 64 | right: "auto", 65 | top: "50%", 66 | transform: "translate(-50%, -50%)", 67 | }, 68 | }} 69 | contentLabel="Help and shortcuts" 70 | > 71 | 72 | {currentMode === "tips" && ( 73 |
    74 |
  • Write in markdown.
  • 75 |
  • Use full-text search.
  • 76 |
  • 77 | Drag and drop files from the file system or the browser to import 78 | them. 79 |
  • 80 |
  • Paste images to import them.
  • 81 |
  • Files are saved alongside the notes.
  • 82 |
  • 83 | 86 |
  • 87 |
88 | )} 89 | {currentMode === "shortcuts" && ( 90 | <> 91 |

92 | Any shortcut that uses Cmd as a modifier also supports{" "} 93 | Ctrl. 94 |

95 | 96 |
97 | Global Shortcuts 98 |
    99 |
  • 100 | Escape - Move focus to the notes list 101 |
  • 102 |
103 | Notes list shortcuts 104 |
    105 |
  • 106 | i/up - Select the note above. 107 |
  • 108 |
  • 109 | j/down - Select the note below. 110 |
  • 111 |
  • 112 | g - Select the first note. 113 |
  • 114 |
  • 115 | G - Select the last note. 116 |
  • 117 |
  • 118 | i/Cmd+N - Focus on the editor. 119 |
  • 120 |
  • 121 | e/Cmd+E - Edit the selected note. 122 |
  • 123 |
  • 124 | f/Cmd+F - Go to the search input. Press{" "} 125 | Cmd+F again to leave. 126 |
  • 127 |
  • 128 | d - Delete the selected note. 129 |
  • 130 |
  • 131 | s - Show the list of shortcuts. 132 |
  • 133 |
  • 134 | h - Show a list of tips. 135 |
  • 136 |
  • 137 | c - Change the theme. 138 |
  • 139 |
  • 140 | Cmd+, - Preferences 141 |
  • 142 |
143 |
144 |
145 | Editor shortcuts 146 |
    147 |
  • 148 | Cmd+Enter - Save the note on the editor. 149 |
  • 150 |
  • 151 | Escape - Restore the focus back to the notes list. 152 |
  • 153 |
  • 154 | t/Cmd+T - Toggle focus mode. 155 |
  • 156 |
  • 157 | Cmd+Shift+Enter - Paste without formatting. 158 |
  • 159 |
160 | Search shortcuts 161 |
    162 |
  • 163 | Cmd+Enter - Saves the current search. 164 |
  • 165 |
  • 166 | Cmd+number - Uses the saved search with the 167 | corresponding number. 168 |
  • 169 |
170 |
171 |
172 | 173 | )} 174 |
175 |
176 | ); 177 | }; 178 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect } from "react"; 3 | import styled from "styled-components"; 4 | import { useDispatch, useSelector } from "react-redux"; 5 | 6 | import { NotesList } from "./NotesList"; 7 | import { Editor } from "./Editor"; 8 | import { Help } from "./Help"; 9 | import { MenuBar } from "./MenuBar"; 10 | import { notesLoad, notesLoadSavedQueries } from "../notes"; 11 | import { modeHandleKey } from "../mode"; 12 | import { 13 | getSelected, 14 | getSearchResultNotes, 15 | getWritingFocusMode, 16 | getMode, 17 | } from "../selectors"; 18 | import { SavedQueryes } from "./SavedQueries"; 19 | 20 | const Root = styled.div<{ hidePanel: boolean }>` 21 | display: flex; 22 | flex-direction: row; 23 | justify-content: center; 24 | width: 100%; 25 | height: calc(100% - 4rem); 26 | margin: 0 auto; 27 | position: relative; 28 | 29 | &::before { 30 | content: ""; 31 | pointer-events: none; 32 | position: absolute; 33 | top: 0; 34 | left: 0; 35 | height: 100%; 36 | width: 50%; 37 | background: ${({ theme, hidePanel }) => 38 | hidePanel ? "transparent" : theme.background2}; 39 | z-index: 4; 40 | 41 | @media (max-width: 900px) { 42 | background: transparent; 43 | } 44 | } 45 | `; 46 | 47 | const Left = styled.div<{ hide: boolean }>` 48 | display: flex; 49 | flex-direction: column; 50 | flex-basis: 700px; 51 | flex-grow: 0; 52 | flex-shrink: 1; 53 | z-index: 4; 54 | 55 | @media (max-width: 900px) { 56 | position: absolute; 57 | top: 0; 58 | left: 0; 59 | width: 400px; 60 | max-width: 90%; 61 | background: ${({ theme }) => theme.background2}; 62 | z-index: 4; 63 | height: 100%; 64 | transform: ${({ hide }) => 65 | hide ? "translateX(calc(-100% + 1.49rem))" : "translateX(0)"}; 66 | transition: all ease-in-out 0.1s; 67 | } 68 | `; 69 | 70 | const Right = styled.div` 71 | position: relative; 72 | display: flex; 73 | flex-direction: column; 74 | flex-basis: 700px; 75 | flex-grow: 0; 76 | flex-shrink: 1; 77 | z-index: 0; 78 | overflow: hidden; 79 | `; 80 | 81 | const RightOverlay = styled.div<{ show: boolean }>` 82 | position: absolute; 83 | top: 0; 84 | left: 50%; 85 | height: 100%; 86 | width: 50%; 87 | transition: opacity ease-in-out 0.1s; 88 | transition-delay: 0.1s; 89 | opacity: ${({ show }) => (show ? "1" : "0")}; 90 | background-color: ${({ theme }) => theme.background2}; 91 | pointer-events: ${({ show }) => (show ? "initial" : "none")}; 92 | z-index: 3; 93 | `; 94 | 95 | export function HomePage() { 96 | const dispatch = useDispatch(); 97 | 98 | const selected = useSelector(getSelected); 99 | const notes = useSelector(getSearchResultNotes); 100 | const mode = useSelector(getMode); 101 | const writingFocusMode = useSelector(getWritingFocusMode); 102 | 103 | useEffect(() => { 104 | dispatch(notesLoad()); 105 | dispatch(notesLoadSavedQueries()); 106 | }, []); 107 | 108 | useEffect(() => { 109 | if (!dispatch) return; 110 | 111 | const handler = (event: KeyboardEvent) => { 112 | dispatch( 113 | modeHandleKey({ 114 | key: event.key, 115 | ctrlKey: !!event.ctrlKey, 116 | metaKey: !!event.metaKey, 117 | }) 118 | ); 119 | }; 120 | 121 | window.addEventListener("keydown", handler); 122 | 123 | return () => window.removeEventListener("keydown", handler); 124 | }, [dispatch, notes]); 125 | 126 | return ( 127 | <> 128 | 129 | 130 | 131 | {!writingFocusMode && ( 132 | 133 | 134 | 135 | )} 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/MenuBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | 5 | import { Search } from "./Search"; 6 | import { getSearchQuery } from "../selectors"; 7 | import { notesSearch, notesSaveSearch } from "../notes"; 8 | import { getWritingFocusMode } from "../selectors"; 9 | 10 | const Root = styled.div<{ small: boolean }>` 11 | -webkit-app-region: drag; 12 | flex-basis: ${({ small }) => (small ? "3rem" : "4rem")}; 13 | flex-grow: 0; 14 | flex-shrink: 0; 15 | position: relative; 16 | display: flex; 17 | justify-content: center; 18 | border-bottom: 1px solid 19 | ${({ theme, small }) => (small ? "transparent" : theme.background2)}; 20 | `; 21 | 22 | export function MenuBar() { 23 | const dispatch = useDispatch(); 24 | 25 | const writingFocusMode = useSelector(getWritingFocusMode); 26 | const query = useSelector(getSearchQuery); 27 | 28 | return ( 29 | 30 | dispatch(notesSearch(ev.target.value))} 33 | onSave={(value: string) => dispatch(notesSaveSearch(value))} 34 | placeholder="Search your notes" 35 | /> 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactModal from "react-modal"; 3 | 4 | import { textColorForBackground } from "../utils/color"; 5 | import { useSelector } from "react-redux"; 6 | import { getTheme } from "../selectors"; 7 | 8 | export const Modal = ({ 9 | children, 10 | contentLabel, 11 | open, 12 | onClose, 13 | style, 14 | }: { 15 | children: any; 16 | contentLabel?: string; 17 | open: boolean; 18 | onClose: () => any; 19 | style?: React.CSSProperties; 20 | }) => { 21 | const theme = useSelector(getTheme); 22 | 23 | return ( 24 | 51 | {children} 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/NotePreview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { format } from "date-fns"; 3 | import styled, { keyframes, css } from "styled-components"; 4 | import { useDispatch } from "react-redux"; 5 | 6 | import { Note } from "@notedown/lib/types"; 7 | import { notesEdit, notesSelect, notesUpdate, notesDelete } from "notes"; 8 | import { textColorForBackground } from "utils/color"; 9 | import { convertMdToHTML } from "utils/markdownConverter"; 10 | 11 | const NoteDate = styled.div` 12 | color: currentColor; 13 | font-size: 0.75rem; 14 | font-style: italic; 15 | height: 1rem; 16 | line-height: 1rem; 17 | `; 18 | 19 | const NoteHeader = styled.div` 20 | display: flex; 21 | align-items: center; 22 | padding-left: 1.5rem; 23 | margin-bottom: 0.5rem; 24 | 25 | @media (max-width: 900px) { 26 | padding-left: 1rem; 27 | } 28 | `; 29 | 30 | const Root = styled.div<{ selected: boolean }>` 31 | background: ${({ theme, selected }) => 32 | selected ? theme.accent1 : theme.background1}; 33 | color: ${({ theme, selected }) => 34 | textColorForBackground(selected ? theme.accent1 : theme.background2)}; 35 | cursor: pointer; 36 | display: flex; 37 | flex-direction: column; 38 | font-size: 1rem; 39 | outline: none; 40 | padding: 1.5rem; 41 | text-align: left; 42 | transition: all 0.1s ease-in-out; 43 | width: 100%; 44 | margin-bottom: 2rem; 45 | border-radius: 4px; 46 | 47 | @media (max-width: 900px) { 48 | padding: 1rem; 49 | } 50 | 51 | audio { 52 | width: 100%; 53 | margin-bottom: 1rem; 54 | } 55 | 56 | .youtube { 57 | position: relative; 58 | padding-bottom: 56.25%; 59 | margin-bottom: 1rem; 60 | 61 | iframe { 62 | position: absolute; 63 | top: 0; 64 | left: 0; 65 | width: 100%; 66 | height: 100%; 67 | } 68 | } 69 | 70 | img { 71 | max-width: 100%; 72 | max-height: 200px; 73 | border-radius: 4px; 74 | vertical-align: text-top; 75 | } 76 | 77 | ul, 78 | ol { 79 | list-style-position: outside; 80 | margin: 1rem 0; 81 | padding-left: 1rem; 82 | } 83 | 84 | li { 85 | margin-bottom: 0.5rem; 86 | } 87 | 88 | li > p:first-child { 89 | margin-top: 0; 90 | } 91 | 92 | li > p:last-child { 93 | margin: 0; 94 | } 95 | 96 | p { 97 | word-break: break-word; 98 | margin: 0.5rem 0 1rem; 99 | } 100 | 101 | h1, 102 | h2, 103 | h3, 104 | h4 { 105 | margin-top: 1rem; 106 | margin-bottom: 0.5rem; 107 | } 108 | 109 | h1 { 110 | font-size: 1.4rem; 111 | } 112 | 113 | h2 { 114 | font-size: 1.3rem; 115 | } 116 | 117 | h3 { 118 | font-size: 1.2rem; 119 | } 120 | 121 | h4 { 122 | font-size: 1.1rem; 123 | } 124 | 125 | input[type="checkbox"] { 126 | margin-left: 0 !important; 127 | margin-bottom: 3px !important; 128 | } 129 | `; 130 | 131 | const fadeIn = keyframes` 132 | from { 133 | opacity: 0; 134 | } 135 | 136 | to { 137 | opacity: 1; 138 | } 139 | `; 140 | 141 | const NoteActions = styled.div<{ selected: boolean }>` 142 | display: inline-flex; 143 | margin-left: 1rem; 144 | opacity: 0; 145 | 146 | ${({ selected }) => 147 | selected && 148 | css` 149 | animation-fill-mode: forwards; 150 | animation-name: ${fadeIn}; 151 | animation-duration: 0.2s; 152 | animation-delay: 0.1s; 153 | animation-iteration-count: 1; 154 | animation-timing-function: linear; 155 | `} 156 | 157 | button { 158 | background: transparent; 159 | font-size: 0.75rem; 160 | display: flex; 161 | justify-content: center; 162 | align-items: center; 163 | margin-right: 0.5rem; 164 | border: 0; 165 | cursor: pointer; 166 | color: inherit; 167 | opacity: 0.9; 168 | 169 | &:hover { 170 | opacity: 1; 171 | } 172 | } 173 | `; 174 | 175 | const Content = styled.div<{ selected: boolean }>` 176 | display: flex; 177 | flex-direction: column; 178 | cursor: initial; 179 | grid-column: 1 / span 2; 180 | 181 | pre { 182 | overflow: auto; 183 | margin: 1rem 0; 184 | color: ${({ theme }) => textColorForBackground(theme.background2)}; 185 | background: ${({ theme }) => theme.background2}; 186 | padding: 0.8rem; 187 | border-radius: 4px; 188 | } 189 | 190 | > *:first-child { 191 | margin-top: 0; 192 | } 193 | 194 | > *:last-child { 195 | margin-bottom: 0; 196 | } 197 | `; 198 | 199 | function NoteContent({ 200 | children, 201 | selected, 202 | }: { 203 | children: string; 204 | selected: boolean; 205 | }) { 206 | const [html, setHtml] = React.useState(""); 207 | 208 | React.useEffect(() => { 209 | setHtml(convertMdToHTML(children)); 210 | }, [children]); 211 | 212 | return ( 213 | 214 | ); 215 | } 216 | 217 | interface Props { 218 | selected?: boolean; 219 | note: Note; 220 | focus?: boolean; 221 | } 222 | 223 | export const NotePreview = ({ selected, note, focus }: Props) => { 224 | const ref = React.createRef(); 225 | const dispatch = useDispatch(); 226 | 227 | React.useEffect(() => { 228 | if (!focus || !ref.current) return; 229 | //@ts-ignore 230 | ref.current.scrollIntoViewIfNeeded(); 231 | }, [focus, ref.current]); 232 | 233 | React.useLayoutEffect(() => { 234 | if (!ref.current) return; 235 | 236 | const handler = (e: MouseEvent) => { 237 | if (e.target instanceof Element && e.target.nodeName === "IMG") { 238 | window.open(e.target.getAttribute("src"), "_blank"); 239 | } 240 | }; 241 | 242 | ref.current.addEventListener("click", handler, { passive: true }); 243 | 244 | return () => 245 | ref.current && ref.current.removeEventListener("click", handler); 246 | }, [ref.current]); 247 | 248 | return ( 249 | <> 250 | 251 | {format(new Date(note.createdAt), "MM/dd/yyyy")} 252 | 253 | {selected && ( 254 | <> 255 | 256 | 263 | 266 | 267 | )} 268 | 269 | 270 | dispatch(notesSelect(note.id))} 274 | selected={selected} 275 | > 276 | {note.content} 277 | 278 | 279 | ); 280 | }; 281 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/NotesList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import styled from "styled-components"; 4 | import { useSelector, useDispatch } from "react-redux"; 5 | 6 | import { ConfirmationDialog } from "./ConfirmationDialog"; 7 | import { NotePreview } from "./NotePreview"; 8 | 9 | import { getFocusedNoteId, getDeletingNote } from "selectors"; 10 | import { notesRemove, notesDelete } from "../notes"; 11 | import { Note } from "@notedown/lib/types"; 12 | 13 | const Root = styled.div` 14 | position: relative; 15 | overflow-y: scroll; 16 | overflow-x: hidden; 17 | flex: 1; 18 | display: flex; 19 | flex-direction: column; 20 | padding-right: 3rem; 21 | padding-left: 1rem; 22 | 23 | @media (max-width: 900px) { 24 | padding-right: 1rem; 25 | } 26 | `; 27 | 28 | const Inside = styled.div` 29 | margin-top: 1rem; 30 | display: flex; 31 | flex-direction: column; 32 | `; 33 | 34 | export function NotesList({ 35 | notes, 36 | selected, 37 | }: { 38 | notes: Note[]; 39 | selected: Note; 40 | }) { 41 | const focusNoteId = useSelector(getFocusedNoteId); 42 | const deleting = useSelector(getDeletingNote); 43 | const dispatch = useDispatch(); 44 | 45 | return ( 46 | 47 | 48 | {notes.map((note) => ( 49 | 55 | ))} 56 | 57 | {deleting && ( 58 | dispatch(notesRemove(deleting.id))} 61 | onNo={() => dispatch(notesDelete(null))} 62 | /> 63 | )} 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/SavedQueries.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector, useDispatch } from "react-redux"; 3 | import { getSavedQueries } from "selectors"; 4 | import styled from "styled-components"; 5 | import { notesSearch } from "notes"; 6 | import { Title } from "./Title"; 7 | 8 | const Root = styled.div` 9 | padding: 1rem 0 0.8rem 3.5rem; 10 | overflow-y: scroll; 11 | overflow-x: hidden; 12 | height: 100%; 13 | width: 100%; 14 | 15 | ol { 16 | list-style-position: inside; 17 | } 18 | `; 19 | 20 | const Button = styled.button` 21 | background: transparent; 22 | font-size: 1rem; 23 | padding: 0.5rem 1rem; 24 | color: inherit; 25 | border: 0; 26 | outline: none; 27 | `; 28 | 29 | export const SavedQueryes = () => { 30 | const queries = useSelector(getSavedQueries); 31 | const dispatch = useDispatch(); 32 | 33 | return ( 34 | 35 | Saved Searches 36 | {queries.length === 0 ? ( 37 |
38 | There are no saved queries. 39 |
Search for something and press Cmd+Enter to save the 40 | search. 41 |
42 | ) : null} 43 |
    44 | {queries.map((query: string, index: number) => ( 45 |
  1. 46 | 49 |
  2. 50 | ))} 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import * as React from "react"; 3 | import { useSelector, useDispatch } from "react-redux"; 4 | 5 | import { textColorForBackground, shadeColor } from "../utils/color"; 6 | import { getMode, getWritingFocusMode } from "../selectors"; 7 | import { modeClose, modeHandleKey } from "../mode"; 8 | 9 | const Root = styled.div<{ active: boolean }>` 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | max-width: calc(1400px - 2rem); 14 | margin: 0 5rem; 15 | width: 100%; 16 | 17 | svg { 18 | pointer-events: none; 19 | 20 | path { 21 | fill: ${({ theme, active }) => 22 | active 23 | ? textColorForBackground(theme.background1) 24 | : shadeColor(textColorForBackground(theme.background1), 50)}; 25 | } 26 | } 27 | `; 28 | 29 | const Icon = () => { 30 | return ( 31 | 38 | 39 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | const Input = styled.input` 62 | background: transparent; 63 | border: none; 64 | color: ${({ theme }) => 65 | shadeColor(textColorForBackground(theme.background1), 30)}; 66 | font-size: 1rem; 67 | max-width: calc(1400px - 2rem); 68 | height: 2rem; 69 | outline: none; 70 | padding: 0 1rem; 71 | transition: all 0.2s linear; 72 | width: 100%; 73 | 74 | &::placeholder { 75 | color: currentColor; 76 | } 77 | `; 78 | 79 | interface Props { 80 | onSave: (value: string) => any; 81 | } 82 | 83 | export const Search = ({ 84 | onSave, 85 | ...props 86 | }: React.InputHTMLAttributes & Props) => { 87 | const ref = React.useRef(null); 88 | const inputRef = React.useRef(null); 89 | const writingFocusMode = useSelector(getWritingFocusMode); 90 | 91 | const [focus, setFocus] = React.useState(false); 92 | 93 | const currentMode = useSelector(getMode); 94 | const dispatch = useDispatch(); 95 | 96 | React.useLayoutEffect(() => { 97 | if (!focus) dispatch(modeClose()); 98 | 99 | const handler = (ev: KeyboardEvent) => { 100 | if (focus) ev.stopPropagation(); 101 | 102 | if (focus && ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) { 103 | onSave(inputRef.current.value); 104 | return inputRef.current.blur(); 105 | } 106 | 107 | if (focus && (ev.key === "Escape" || ev.key === "Enter")) 108 | return inputRef.current.blur(); 109 | 110 | if (focus && (ev.metaKey || ev.ctrlKey)) { 111 | dispatch( 112 | modeHandleKey({ 113 | key: ev.key, 114 | ctrlKey: !!ev.ctrlKey, 115 | metaKey: !!ev.metaKey, 116 | }) 117 | ); 118 | } 119 | }; 120 | 121 | ref.current.addEventListener("keydown", handler); 122 | 123 | return () => ref.current.removeEventListener("keydown", handler); 124 | }, [ref, inputRef, focus]); 125 | 126 | React.useLayoutEffect(() => { 127 | if (!inputRef.current) return; 128 | 129 | if (currentMode !== "search") { 130 | return inputRef.current.blur(); 131 | } 132 | 133 | setImmediate(() => inputRef.current && inputRef.current.focus()); 134 | }, [inputRef, currentMode]); 135 | 136 | return ( 137 | 138 | {!writingFocusMode && ( 139 | <> 140 | 141 | setFocus(false)} 145 | onFocus={() => setFocus(true)} 146 | /> 147 | 148 | )} 149 | 150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { remote } from "electron"; 3 | import * as ReactModal from "react-modal"; 4 | import styled from "styled-components"; 5 | import { useSelector, useDispatch } from "react-redux"; 6 | 7 | import { textColorForBackground } from "../utils/color"; 8 | 9 | import { Title } from "./Title"; 10 | import { getTheme, getBackupFolder, getMode } from "../selectors"; 11 | import { backupFolderSet } from "../settings"; 12 | import { modeClose } from "mode"; 13 | 14 | const Root = styled.div``; 15 | 16 | const Label = styled.div` 17 | font-weight: bold; 18 | `; 19 | 20 | const Button = styled.button` 21 | background: none; 22 | border: none; 23 | text-decoration: underline; 24 | margin-left: 0.75rem; 25 | padding: 0; 26 | color: ${({ theme }) => textColorForBackground(theme.background1)}; 27 | font-size: 0.75rem; 28 | `; 29 | 30 | const ButtonClose = styled(Button)` 31 | font-size: 1rem; 32 | margin: 0; 33 | margin-top: 2rem; 34 | `; 35 | 36 | export function Settings() { 37 | const theme = useSelector(getTheme); 38 | const backupFolder = useSelector(getBackupFolder); 39 | const dispatch = useDispatch(); 40 | const currentMode = useSelector(getMode); 41 | 42 | const pickBackupFolder = async () => { 43 | const { filePaths } = await remote.dialog.showOpenDialog({ 44 | properties: ["openDirectory", "createDirectory"], 45 | }); 46 | 47 | if (filePaths.length > 0) { 48 | dispatch(backupFolderSet(filePaths[0])); 49 | } 50 | }; 51 | 52 | return ( 53 | dispatch(modeClose())} 56 | style={{ 57 | overlay: { 58 | backgroundColor: theme.colors.background1, 59 | zIndex: 4, 60 | }, 61 | content: { 62 | backgroundColor: "transparent", 63 | border: 0, 64 | bottom: "auto", 65 | color: textColorForBackground(theme.colors.background1), 66 | left: "50%", 67 | margin: 0, 68 | minWidth: 600, 69 | overflow: "initial", 70 | padding: 0, 71 | right: "auto", 72 | top: "50%", 73 | transform: "translate(-50%, -50%)", 74 | }, 75 | }} 76 | contentLabel="Settings" 77 | > 78 | 79 | Settings 80 | 85 |

{backupFolder || "Your backup folder is not set"}

86 | dispatch(modeClose())}>Close 87 |
88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/ThemeItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { ThemeColors } from "../settings"; 5 | import { visuallyHidden } from "../utils/visuallyHidden"; 6 | import { textColorForBackground } from "../utils/color"; 7 | 8 | const Root = styled.button` 9 | display: grid; 10 | grid-template-columns: auto 1fr; 11 | grid-column-gap: 0.5rem; 12 | align-items: center; 13 | border: 0; 14 | background: none; 15 | outline: none; 16 | cursor: pointer; 17 | `; 18 | 19 | const Label = styled.label` 20 | color: ${({ theme }) => textColorForBackground(theme.background2)}; 21 | cursor: pointer; 22 | text-align: left; 23 | font-size: 0.75rem; 24 | `; 25 | 26 | const CustomRadio = ({ 27 | colors, 28 | checked, 29 | }: { 30 | colors: ThemeColors; 31 | checked: boolean; 32 | }) => { 33 | return ( 34 | 35 | 43 | 51 | 64 | 65 | ); 66 | }; 67 | 68 | export const ThemeItem = ({ 69 | theme, 70 | name, 71 | checked, 72 | onChange, 73 | }: { 74 | theme: ThemeColors; 75 | name: string; 76 | checked: boolean; 77 | onChange: (theme: ThemeColors) => any; 78 | }) => { 79 | return ( 80 | onChange(theme)}> 81 | onChange(theme)} 84 | type="radio" 85 | id={name} 86 | name="theme" 87 | value={name} 88 | checked={checked} 89 | /> 90 | 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Title = styled.div` 4 | font-size: 1.5rem; 5 | margin-bottom: 1rem; 6 | `; 7 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./reducers"; 2 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/database/reducers.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | import { DatabaseState } from "@notedown/lib/types"; 4 | 5 | const initialState: DatabaseState = { db: null }; 6 | 7 | const database = createSlice({ 8 | name: "database", 9 | initialState: initialState, 10 | reducers: { 11 | databaseLoad: (state, action) => { 12 | return { ...state, db: action.payload.db }; 13 | }, 14 | }, 15 | }); 16 | 17 | export const { databaseLoad } = database.actions; 18 | 19 | export const databaseReducer = database.reducer; 20 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Montserrat; 3 | src: url("fonts/Montserrat-Regular.ttf"); 4 | font-weight: 400; 5 | } 6 | 7 | @font-face { 8 | font-family: Montserrat; 9 | src: url("fonts/Montserrat-SemiBold.ttf"); 10 | font-weight: 600; 11 | } 12 | 13 | @font-face { 14 | font-family: Montserrat; 15 | src: url("fonts/Montserrat-Bold.ttf"); 16 | font-weight: 700; 17 | } 18 | 19 | @font-face { 20 | font-family: Montserrat; 21 | src: url("fonts/Montserrat-Italic.ttf"); 22 | font-style: italic; 23 | font-weight: 400; 24 | } 25 | 26 | @font-face { 27 | font-family: RobotoMono; 28 | src: url("fonts/RobotoMono-Regular.ttf"); 29 | font-weight: 400; 30 | } 31 | 32 | @font-face { 33 | font-family: RobotoMono; 34 | src: url("fonts/RobotoMono-Bold.ttf"); 35 | font-weight: 700; 36 | } 37 | 38 | @font-face { 39 | font-family: RobotoMono; 40 | src: url("fonts/RobotoMono-Italic.ttf"); 41 | font-style: italic; 42 | font-weight: 400; 43 | } 44 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-Black.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-BlackItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-BoldItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-ExtraBold.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-ExtraBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-ExtraBoldItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-ExtraLight.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-Italic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-Light.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-LightItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-Medium.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-MediumItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-Regular.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-SemiBold.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-Thin.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/Montserrat-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/Montserrat-ThinItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-Bold.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-BoldItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-ExtraLight.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-Italic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-Light.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-LightItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-Medium.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-MediumItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-Regular.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-SemiBold.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-Thin.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/fonts/RobotoMono-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subvisual/notedown/cb6e18cc6ed9461e923e2203fb6ca4a15586f772/packages/app-desktop/src/client/fonts/RobotoMono-ThinItalic.ttf -------------------------------------------------------------------------------- /packages/app-desktop/src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import { ipcRenderer } from "electron"; 5 | 6 | import { notesAdd } from "notes"; 7 | import configure from "./store"; 8 | import "./fonts.css"; 9 | import "./utils/contextMenu"; 10 | 11 | import App from "./App"; 12 | 13 | const store = configure(); 14 | 15 | ipcRenderer.on("open-url", (_event, data) => { 16 | const url = new URL(data); 17 | const action = url.pathname.replace("//", ""); 18 | const content = url.searchParams.get("content"); 19 | 20 | if (action === "add") store.dispatch(notesAdd({ content })); 21 | }); 22 | 23 | ReactDOM.render( 24 | 25 | 26 | , 27 | document.getElementById("root") 28 | ); 29 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/mode/epics.ts: -------------------------------------------------------------------------------- 1 | import { ofType, ActionsObservable, StateObservable } from "redux-observable"; 2 | import { mergeMap, filter, tap, ignoreElements } from "rxjs/operators"; 3 | import { of } from "rxjs"; 4 | 5 | import { modeHandleKey, modeSet } from "./reducers"; 6 | import { RootState, Note } from "@notedown/lib/types"; 7 | import { getSearchResultNotes, getAllNotes, getSelected } from "selectors"; 8 | import { findIndex } from "lodash"; 9 | import { notesSelect, notesEdit, notesDelete, notesSearch } from "notes"; 10 | 11 | const getSelectedNoteIndex = (notes: Note[], selected: Note) => { 12 | return selected && findIndex(notes, (note) => note.id === selected.id); 13 | }; 14 | 15 | export const modeNotesKeyEpic = ( 16 | action$: ActionsObservable>, 17 | state$: StateObservable 18 | ) => 19 | action$.pipe( 20 | filter(() => state$.value.mode.name === "notes"), 21 | ofType(modeHandleKey.type), 22 | mergeMap(({ payload: { key, ctrlKey, metaKey } }) => { 23 | if (ctrlKey || metaKey) return of(); 24 | 25 | if (key === "/" || key === "f") { 26 | // SEARCH 27 | return of(modeSet("search")); 28 | } else if (key === "j" || key === "ArrowDown") { 29 | // MOVE DOWN 30 | const notes = getSearchResultNotes(state$.value); 31 | const selected = getSelected(state$.value); 32 | const index = getSelectedNoteIndex(notes, selected); 33 | const newIndex = selected ? Math.min(index + 1, notes.length - 1) : 0; 34 | 35 | return of(notesSelect(notes[newIndex].id)); 36 | } else if (key === "k" || key === "ArrowUp") { 37 | // MOVE UP 38 | const notes = getSearchResultNotes(state$.value); 39 | const selected = getSelected(state$.value); 40 | const index = getSelectedNoteIndex(notes, selected); 41 | const newIndex = selected ? Math.max(index - 1, 0) : 0; 42 | 43 | return of(notesSelect(notes[newIndex].id)); 44 | } else if (key === "e") { 45 | // EDIT 46 | return of(notesEdit()); 47 | } else if (key === "d") { 48 | // DELETE 49 | return of(notesDelete(null)); 50 | } else if (key === "t") { 51 | // FOCUS 52 | return of(modeSet("editorFocus")); 53 | } else if (key === "h") { 54 | // HELP 55 | return of(modeSet("tips")); 56 | } else if (key === "s") { 57 | // SHORTCUTS 58 | return of(modeSet("shortcuts")); 59 | } else if (key === "i") { 60 | // MOVE TO EDITOR 61 | return of(modeSet("editor")); 62 | } else if (key === "c") { 63 | // COLOR PICKER 64 | return of(modeSet("colorPicker")); 65 | } else if (key === "G") { 66 | // LAST 67 | const notes = getAllNotes(state$.value); 68 | return of(notesSelect(notes[notes.length - 1].id)); 69 | } else if (key === "g") { 70 | // FIRST 71 | const notes = getAllNotes(state$.value); 72 | return of(notesSelect(notes[0].id)); 73 | } else { 74 | return of(); 75 | } 76 | }) 77 | ); 78 | 79 | export const modeSearchKeyEpic = ( 80 | action$: ActionsObservable>, 81 | state$: StateObservable 82 | ) => 83 | action$.pipe( 84 | filter(() => state$.value.mode.name === "search"), 85 | ofType(modeHandleKey.type), 86 | mergeMap(({ payload }) => { 87 | const nr = parseInt(payload.key, 10); 88 | 89 | if (nr && nr < 10 && nr > 0 && (payload.ctrlKey || payload.metaKey)) { 90 | return of(notesSearch(state$.value.notes.savedSearches[nr - 1])); 91 | } 92 | 93 | return of(); 94 | }) 95 | ); 96 | 97 | export const modeEpics = [modeNotesKeyEpic, modeSearchKeyEpic]; 98 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/mode/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./reducers"; 2 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/mode/reducers.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | import { ModeState } from "@notedown/lib/types"; 4 | 5 | const initialState: ModeState = { name: "notes" }; 6 | 7 | const mode = createSlice({ 8 | name: "mode", 9 | initialState: initialState, 10 | 11 | reducers: { 12 | modeSet: (state, action) => { 13 | if (state.name === "search" && action.payload === "search") 14 | return { ...state, name: "notes" }; 15 | 16 | if (state.name === "editorFocus" && action.payload === "editorFocus") 17 | return { ...state, name: "editor" }; 18 | 19 | return { ...state, name: action.payload }; 20 | }, 21 | modeClose: (state) => { 22 | return { ...state, name: "notes" }; 23 | }, 24 | modeHandleKey: (state, _) => state, 25 | }, 26 | }); 27 | 28 | export const { modeSet, modeClose, modeHandleKey } = mode.actions; 29 | 30 | export const modeReducer = mode.reducer; 31 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/notes/epics.ts: -------------------------------------------------------------------------------- 1 | import { interval, of, from } from "rxjs"; 2 | import { 3 | mergeMap, 4 | throttle, 5 | tap, 6 | ignoreElements, 7 | debounceTime, 8 | filter, 9 | map, 10 | catchError, 11 | take, 12 | concatMap, 13 | } from "rxjs/operators"; 14 | import { ofType, ActionsObservable, StateObservable } from "redux-observable"; 15 | 16 | import { 17 | notesAdd, 18 | notesAddSuccess, 19 | notesLoadSuccess, 20 | notesRemove, 21 | notesUpdate, 22 | notesLoad, 23 | notesSearch, 24 | notesSearchResult, 25 | notesSelect, 26 | notesSelectDebounced, 27 | notesEdit, 28 | notesEditTmp, 29 | notesEditSuccess, 30 | notesSaveSearch, 31 | notesLoadSavedQueries, 32 | notesLoadSavedQueriesResult, 33 | } from "./reducers"; 34 | import * as Notes from "@notedown/lib/notes"; 35 | import { RootState, Note } from "@notedown/lib/types"; 36 | import { modeSet } from "mode"; 37 | import { getSelected } from "selectors"; 38 | import * as Search from "@notedown/lib/search"; 39 | 40 | export const notesRestoreTmpStateEpic = ( 41 | action$: ActionsObservable> 42 | ) => 43 | action$.pipe( 44 | ofType(notesLoad.type), 45 | take(1), 46 | map(() => localStorage.getItem("tmpNotesEdit")), 47 | filter((content: unknown) => { 48 | return typeof content === "string" && content !== ""; 49 | }), 50 | map((content: unknown) => JSON.parse(content as string)), 51 | catchError((err) => { 52 | console.error(err); 53 | return of(null); 54 | }), 55 | mergeMap((note: unknown) => of(notesEdit(note as Note))) 56 | ); 57 | 58 | export const notesSaveTmpStateEpic = ( 59 | action$: ActionsObservable> 60 | ) => 61 | action$.pipe( 62 | ofType(notesEditTmp.type), 63 | map(({ payload }) => (!!payload ? JSON.stringify(payload) : "")), 64 | tap((content) => localStorage.setItem("tmpNotesEdit", content)), 65 | ignoreElements() 66 | ); 67 | 68 | export const notesLoadEpic = ( 69 | action$: ActionsObservable>, 70 | state$: StateObservable 71 | ) => 72 | action$.pipe( 73 | ofType(notesLoad.type), 74 | mergeMap(() => Notes.loadAll(state$.value.db.db).then(notesLoadSuccess)) 75 | ); 76 | 77 | export const notesOnboardingEpic = ( 78 | action$: ActionsObservable> 79 | ) => 80 | action$.pipe( 81 | ofType(notesLoadSuccess.type), 82 | mergeMap(({ payload }) => of(payload.length)), 83 | filter((count) => count === 0), 84 | mergeMap(() => 85 | of( 86 | notesAdd({ 87 | content: ` 88 | Hi! 89 | 90 | If this is your first time around, here are a few tips to get you started: 91 | 92 | 1. Write your notes on the right and press _Cmd+Enter_ or _Ctrl+Enter_ to save them. 93 | 2. NoteDown was designed around searching. Write everything down and use the seach _Cmd+f_ to look it up when necessary. 94 | 3. Your focus can be on the notes list, the editor, the search input or other modals. To restore the focus back to the notes list, press _Escape_. 95 | 4. From the notes list, press _s_ to see all the available shortcuts, or _h_ for more tips. 96 | `, 97 | }) 98 | ) 99 | ) 100 | ); 101 | 102 | export const notesAddEpic = ( 103 | action$: ActionsObservable>, 104 | state$: StateObservable 105 | ) => 106 | action$.pipe( 107 | ofType(notesAdd.type), 108 | mergeMap(({ payload }) => 109 | Notes.add(state$.value.db.db, payload).then(notesAddSuccess) 110 | ) 111 | ); 112 | 113 | export const notesRemoveEpic = ( 114 | action$: ActionsObservable>, 115 | state$: StateObservable 116 | ) => 117 | action$.pipe( 118 | ofType(notesRemove.type), 119 | tap(({ payload }) => Notes.remove(state$.value.db.db, payload)), 120 | ignoreElements() 121 | ); 122 | 123 | export const notesUpdateEpic = ( 124 | action$: ActionsObservable>, 125 | state$: StateObservable 126 | ) => 127 | action$.pipe( 128 | ofType(notesUpdate.type), 129 | tap(({ payload }) => Notes.update(state$.value.db.db, payload)), 130 | ignoreElements() 131 | ); 132 | 133 | export const notesSelectEpic = ( 134 | action$: ActionsObservable> 135 | ) => 136 | action$.pipe( 137 | ofType(notesSelect.type), 138 | throttle(() => interval(50), { leading: true, trailing: true }), 139 | mergeMap(({ payload }) => of(notesSelectDebounced(payload))) 140 | ); 141 | 142 | export const notesSearchEpic = ( 143 | action$: ActionsObservable>, 144 | state$: StateObservable 145 | ) => 146 | action$.pipe( 147 | ofType(notesSearch.type), 148 | filter(({ payload }) => !!payload), 149 | throttle(() => interval(500), { trailing: true, leading: false }), 150 | concatMap(({ payload }) => 151 | from(Notes.search(state$.value.db.db, payload)).pipe( 152 | catchError((_err) => { 153 | return of([]); 154 | }) 155 | ) 156 | ), 157 | mergeMap((results) => of(notesSearchResult(results))) 158 | ); 159 | 160 | export const notesEditEpic = ( 161 | action$: ActionsObservable>, 162 | state$: StateObservable 163 | ) => 164 | action$.pipe( 165 | ofType(notesEdit.type), 166 | map(({ payload }) => (payload ? payload : getSelected(state$.value))), 167 | filter((note) => !!note), 168 | mergeMap((note) => of(notesEditSuccess(note))) 169 | ); 170 | 171 | export const notesEditFocusEpic = ( 172 | action$: ActionsObservable> 173 | ) => 174 | action$.pipe( 175 | ofType(notesEditSuccess.type), 176 | debounceTime(20), 177 | mergeMap(() => of(modeSet("editor"))) 178 | ); 179 | 180 | export const notesSaveSearchEpic = ( 181 | action$: ActionsObservable> 182 | ) => 183 | action$.pipe( 184 | ofType(notesSaveSearch.type), 185 | debounceTime(500), 186 | map(({ payload }) => Search.save(payload)), 187 | mergeMap((queries) => of(notesLoadSavedQueriesResult(queries))) 188 | ); 189 | 190 | export const notesLoadSavedSearchEpic = ( 191 | action$: ActionsObservable> 192 | ) => 193 | action$.pipe( 194 | ofType(notesLoadSavedQueries.type), 195 | mergeMap(() => of(notesLoadSavedQueriesResult(Search.getAll()))) 196 | ); 197 | 198 | export const notesEpics = [ 199 | notesLoadEpic, 200 | notesAddEpic, 201 | notesRemoveEpic, 202 | notesUpdateEpic, 203 | notesSearchEpic, 204 | notesSelectEpic, 205 | notesOnboardingEpic, 206 | notesEditEpic, 207 | notesEditFocusEpic, 208 | notesRestoreTmpStateEpic, 209 | notesSaveTmpStateEpic, 210 | notesSaveSearchEpic, 211 | notesLoadSavedSearchEpic, 212 | ]; 213 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/notes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./reducers"; 2 | export * from "./epics"; 3 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/notes/reducers.ts: -------------------------------------------------------------------------------- 1 | import { orderBy } from "lodash"; 2 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 3 | 4 | import { Note, NotesState } from "@notedown/lib/types"; 5 | 6 | const initialState: NotesState = { 7 | notes: [], 8 | edit: null, 9 | savedSearches: [], 10 | searchResult: [], 11 | searchQuery: "", 12 | }; 13 | 14 | const notes = createSlice({ 15 | name: "notes", 16 | initialState: initialState, 17 | reducers: { 18 | notesDelete: (state, action) => { 19 | if (action.payload) return { ...state, deleting: action.payload }; 20 | else if (!state.deleting && !action.payload && state.selectedId) 21 | return { ...state, deleting: state.selectedId }; 22 | else return { ...state, deleting: null }; 23 | }, 24 | notesLoadSavedQueriesResult: (state, action) => { 25 | return { ...state, savedSearches: action.payload }; 26 | }, 27 | notesSearch: (state, action) => { 28 | return { ...state, searchQuery: action.payload }; 29 | }, 30 | notesSearchResult: (state, action) => { 31 | return { ...state, searchResult: action.payload }; 32 | }, 33 | notesAdd: (state, action) => { 34 | return { ...state, edit: { ...action.payload, id: "draft" } }; 35 | }, 36 | notesAddSuccess: (state, action) => { 37 | return { 38 | ...state, 39 | edit: null, 40 | notes: [action.payload, ...state.notes], 41 | selectedId: action.payload.id, 42 | }; 43 | }, 44 | notesEditSuccess: (state, action) => { 45 | return { 46 | ...state, 47 | edit: action.payload, 48 | }; 49 | }, 50 | notesSelectDebounced: (state, action) => { 51 | return { ...state, selectedId: action.payload, focusId: action.payload }; 52 | }, 53 | notesUpdate: (state, action) => { 54 | return { 55 | ...state, 56 | edit: null, 57 | selectedId: action.payload.id, 58 | notes: state.notes.map((note) => { 59 | if (note.id === action.payload.id) 60 | return { ...note, ...action.payload }; 61 | else return note; 62 | }), 63 | }; 64 | }, 65 | notesLoadSuccess: (state, action) => { 66 | return { 67 | ...state, 68 | notes: orderBy( 69 | action.payload, 70 | (note: Note) => new Date(note.createdAt), 71 | "desc" 72 | ), 73 | }; 74 | }, 75 | notesRemove: (state, action) => { 76 | return { 77 | ...state, 78 | notes: state.notes.filter((note) => note.id !== action.payload), 79 | }; 80 | }, 81 | notesEdit: (state, _action: PayloadAction) => state, 82 | notesEditTmp: (state, _action) => state, 83 | notesSelect: (state, _action) => state, 84 | notesLoad: (state) => state, 85 | notesSelectDown: (state, _action) => state, 86 | notesLoadSavedQueries: (state) => state, 87 | notesSaveSearch: (state, _action) => state, 88 | }, 89 | }); 90 | 91 | export const { 92 | notesSelect, 93 | notesSelectDebounced, 94 | notesDelete, 95 | notesRemove, 96 | notesUpdate, 97 | notesEdit, 98 | notesEditSuccess, 99 | notesEditTmp, 100 | notesAdd, 101 | notesAddSuccess, 102 | notesLoad, 103 | notesLoadSuccess, 104 | notesSelectDown, 105 | notesLoadSavedQueries, 106 | notesLoadSavedQueriesResult, 107 | notesSaveSearch, 108 | notesSearch, 109 | notesSearchResult, 110 | } = notes.actions; 111 | 112 | export const notesReducer = notes.reducer; 113 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/selectors/index.ts: -------------------------------------------------------------------------------- 1 | import { find, orderBy, compact } from "lodash"; 2 | import { createSelector } from "reselect"; 3 | 4 | import { Note, RootState } from "@notedown/lib/types"; 5 | 6 | export const getAllNotes = (state: RootState) => state.notes.notes; 7 | 8 | export const getDeletingNote = (state: RootState) => 9 | state.notes.deleting 10 | ? find(state.notes.notes, { id: state.notes.deleting }) 11 | : null; 12 | 13 | export const getSelectedId = (state: RootState) => state.notes.selectedId; 14 | 15 | export const getSelected = createSelector( 16 | getAllNotes, 17 | getSelectedId, 18 | (notes, id) => find(notes, { id }) 19 | ); 20 | 21 | export const getEdit = (state: RootState) => state.notes.edit; 22 | 23 | export const getFocusedNoteId = (state: RootState) => state.notes.focusId; 24 | 25 | export const getSearchQuery = (state: RootState) => state.notes.searchQuery; 26 | 27 | export const getSearchResult = (state: RootState) => state.notes.searchResult; 28 | 29 | export const getSearchResultNotes = createSelector( 30 | getSearchQuery, 31 | getAllNotes, 32 | getSearchResult, 33 | (searchQuery, notes, searchResult) => 34 | searchQuery 35 | ? orderBy( 36 | compact(searchResult.map(({ id }) => find(notes, { id }))), 37 | (note: Note) => new Date(note.createdAt), 38 | "desc" 39 | ) 40 | : notes.filter((note) => !note.archived) 41 | ); 42 | 43 | export const getTheme = (state: RootState) => state.settings; 44 | 45 | export const getMode = (state: RootState) => state.mode.name; 46 | 47 | export const getWritingFocusMode = (state: RootState) => 48 | state.mode.name === "editorFocus"; 49 | 50 | export const getDb = (state: RootState) => state.db.db; 51 | 52 | export const getBackupFolder = (state: RootState) => 53 | state.settings.backupFolder; 54 | 55 | export const getSavedQueries = (state: RootState) => state.notes.savedSearches; 56 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/settings/epics.ts: -------------------------------------------------------------------------------- 1 | import { ofType, ActionsObservable } from "redux-observable"; 2 | import { of, merge, interval } from "rxjs"; 3 | import { 4 | debounceTime, 5 | mergeMap, 6 | tap, 7 | ignoreElements, 8 | catchError, 9 | map, 10 | filter, 11 | } from "rxjs/operators"; 12 | import { StateObservable } from "redux-observable"; 13 | 14 | import { 15 | themeLoad, 16 | themeColors, 17 | backupFolder, 18 | backupFolderResult, 19 | backupFolderSet, 20 | } from "./reducers"; 21 | import * as Theme from "@notedown/lib/theme"; 22 | import * as Backup from "@notedown/lib/backup"; 23 | import { RootState } from "@notedown/lib/types"; 24 | 25 | export const loadThemeEpic = ( 26 | action$: ActionsObservable>, 27 | state$: StateObservable 28 | ) => 29 | action$.pipe( 30 | ofType(themeLoad.type), 31 | mergeMap(() => Theme.get(state$.value.db.db).then(themeColors)) 32 | ); 33 | 34 | export const setThemeEpic = ( 35 | action$: ActionsObservable>, 36 | state$: StateObservable 37 | ) => 38 | action$.pipe( 39 | ofType(themeColors.type), 40 | tap(({ payload }) => Theme.set(state$.value.db.db, payload)), 41 | ignoreElements() 42 | ); 43 | 44 | export const loadBackupFolderEpic = ( 45 | action$: ActionsObservable> 46 | ) => 47 | action$.pipe( 48 | ofType(backupFolder.type), 49 | mergeMap(() => Backup.get().then(backupFolderResult)) 50 | ); 51 | 52 | export const setBackupFolderEpic = ( 53 | action$: ActionsObservable> 54 | ) => 55 | action$.pipe( 56 | ofType(backupFolderSet.type), 57 | mergeMap(({ payload }) => Backup.set(payload).then(backupFolderResult)), 58 | catchError((err) => { 59 | console.error(err); 60 | return of([]); 61 | }) 62 | ); 63 | 64 | export const runBackupEpic = ( 65 | action$: ActionsObservable>, 66 | state$: StateObservable 67 | ) => 68 | merge( 69 | interval(60000 * 15), 70 | action$.pipe(ofType(backupFolderResult.type)) 71 | ).pipe( 72 | debounceTime(5000), 73 | map(() => state$.value.settings.backupFolder), 74 | filter((content: unknown) => { 75 | return !!content && typeof content === "string" && content !== ""; 76 | }), 77 | mergeMap((backupFolder: unknown) => Backup.run(backupFolder as string)), 78 | ignoreElements() 79 | ); 80 | 81 | export const settingsEpics = [ 82 | loadThemeEpic, 83 | setThemeEpic, 84 | loadBackupFolderEpic, 85 | setBackupFolderEpic, 86 | runBackupEpic, 87 | ]; 88 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/settings/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./epics"; 2 | export * from "./reducers"; 3 | 4 | import { ThemeColors } from "@notedown/lib/types"; 5 | 6 | export { ThemeColors }; 7 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/settings/reducers.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | import { SettingsState } from "@notedown/lib/types"; 4 | 5 | const initialState: SettingsState = { 6 | colors: { 7 | background1: "#2a2438", 8 | background2: "#352f44", 9 | accent1: "#411e8f", 10 | }, 11 | }; 12 | 13 | const settings = createSlice({ 14 | name: "settings", 15 | initialState: initialState, 16 | reducers: { 17 | themeColors: (state, action) => { 18 | return { ...state, colors: action.payload }; 19 | }, 20 | backupFolderResult: (state, action) => { 21 | return { ...state, backupFolder: action.payload }; 22 | }, 23 | themeLoad: (state) => state, 24 | backupFolder: (state) => state, 25 | backupFolderSet: (state, _action) => state, 26 | }, 27 | }); 28 | 29 | export const { 30 | themeColors, 31 | themeLoad, 32 | backupFolder, 33 | backupFolderResult, 34 | backupFolderSet, 35 | } = settings.actions; 36 | 37 | export const settingsReducer = settings.reducer; 38 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/store/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { combineEpics, createEpicMiddleware } from "redux-observable"; 3 | import { configureStore } from "@reduxjs/toolkit"; 4 | 5 | import { notesReducer } from "../notes/reducers"; 6 | import { settingsReducer } from "../settings/reducers"; 7 | import { modeReducer } from "../mode/reducers"; 8 | import { databaseReducer } from "../database/reducers"; 9 | 10 | import { notesEpics } from "../notes/epics"; 11 | import { settingsEpics } from "../settings/epics"; 12 | import { modeEpics } from "../mode/epics"; 13 | 14 | export const rootEpic = combineEpics.apply(this, [ 15 | ...notesEpics, 16 | ...settingsEpics, 17 | ...modeEpics, 18 | ]); 19 | 20 | const epicMiddleware = createEpicMiddleware(); 21 | 22 | export default function configure() { 23 | const store = configureStore({ 24 | reducer: combineReducers({ 25 | notes: notesReducer, 26 | settings: settingsReducer, 27 | mode: modeReducer, 28 | db: databaseReducer, 29 | }), 30 | middleware: [epicMiddleware], 31 | devTools: true, 32 | }); 33 | 34 | epicMiddleware.run(rootEpic); 35 | 36 | return store; 37 | } 38 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/color.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from "lodash"; 2 | 3 | const hexToRgb = memoize((hex: string) => { 4 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 5 | return result 6 | ? { 7 | r: parseInt(result[1], 16), 8 | g: parseInt(result[2], 16), 9 | b: parseInt(result[3], 16), 10 | } 11 | : null; 12 | }); 13 | 14 | const hexToHsl = memoize((hex: string) => { 15 | let { r, g, b } = hexToRgb(hex); 16 | 17 | r /= 255; 18 | g /= 255; 19 | b /= 255; 20 | 21 | // Find greatest and smallest channel values 22 | let cmin = Math.min(r, g, b), 23 | cmax = Math.max(r, g, b), 24 | delta = cmax - cmin, 25 | h = 0, 26 | s = 0, 27 | l = 0; 28 | 29 | // calculate hue 30 | // no difference 31 | if (delta == 0) h = 0; 32 | // red is max 33 | else if (cmax == r) h = ((g - b) / delta) % 6; 34 | // green is max 35 | else if (cmax == g) h = (b - r) / delta + 2; 36 | // blue is max 37 | else h = (r - g) / delta + 4; 38 | 39 | h = Math.round(h * 60); 40 | 41 | // make negative hues positive behind 360° 42 | if (h < 0) h += 360; 43 | 44 | // calculate lightness 45 | l = (cmax + cmin) / 2; 46 | 47 | // calculate saturation 48 | s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); 49 | 50 | // multiply l and s by 100 51 | s = +(s * 100).toFixed(1); 52 | l = +(l * 100).toFixed(1); 53 | 54 | return { h, s, l }; 55 | }); 56 | 57 | export function isDark(hex: string, threshold = 125) { 58 | const { r, g, b } = hexToRgb(hex); 59 | 60 | return (r * 299 + g * 587 + b * 114) / 1000 < threshold; 61 | } 62 | 63 | export const textColorForBackground = memoize((backgroundColor: string) => { 64 | if (isDark(backgroundColor)) return "#FFFFFF"; 65 | else return "#333333"; 66 | }); 67 | 68 | export const shadowColorForBackground = memoize((backgroundColor: string) => { 69 | if (isDark(backgroundColor)) return "rgba(0, 0, 0, 1)"; 70 | else if (isDark(backgroundColor, 170)) return "rgba(0, 0, 0, 0.50)"; 71 | else return "rgba(0, 0, 0, 0.25)"; 72 | }); 73 | 74 | export function shadeColor(color: string, percent: number) { 75 | const p = isDark(color) ? percent : -1 * percent; 76 | 77 | const { r, g, b } = hexToRgb(color); 78 | 79 | let R = Math.floor((r * (100 + p)) / 100); 80 | let G = Math.floor((g * (100 + p)) / 100); 81 | let B = Math.floor((b * (100 + p)) / 100); 82 | 83 | R = R < 255 ? R : 255; 84 | G = G < 255 ? G : 255; 85 | B = B < 255 ? B : 255; 86 | 87 | const RR = R.toString(16).length == 1 ? "0" + R.toString(16) : R.toString(16); 88 | const GG = G.toString(16).length == 1 ? "0" + G.toString(16) : G.toString(16); 89 | const BB = B.toString(16).length == 1 ? "0" + B.toString(16) : B.toString(16); 90 | 91 | return "#" + RR + GG + BB; 92 | } 93 | 94 | export function smallContrast(color: string) { 95 | let { h, s, l } = hexToHsl(color); 96 | 97 | if (l > 50) l -= 15; 98 | else l += 15; 99 | 100 | return `hsl(${h},${s}%,${l}%)`; 101 | } 102 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/contextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { remote, shell } from "electron"; 2 | import { notesFileToFullPath } from "@notedown/lib/files"; 3 | 4 | const { Menu, MenuItem } = remote; 5 | 6 | const getElementSource = (el: EventTarget) => { 7 | if (el instanceof HTMLImageElement) { 8 | return el.src; 9 | } else if (el instanceof HTMLAudioElement) { 10 | return el.currentSrc; 11 | } else if (el instanceof HTMLAnchorElement) { 12 | return el.href; 13 | } 14 | 15 | return null; 16 | }; 17 | 18 | const createMenuForElement = (el: EventTarget) => { 19 | const src = getElementSource(el); 20 | 21 | const menu = new Menu(); 22 | 23 | if (src && src.startsWith("notesfile://")) { 24 | menu.append( 25 | new MenuItem({ 26 | label: "Open", 27 | click() { 28 | shell.openPath(notesFileToFullPath(src)); 29 | }, 30 | }) 31 | ); 32 | menu.append( 33 | new MenuItem({ 34 | label: "Reveal in file explorer", 35 | click() { 36 | shell.showItemInFolder(notesFileToFullPath(src)); 37 | }, 38 | }) 39 | ); 40 | } 41 | 42 | return menu; 43 | }; 44 | 45 | window.addEventListener( 46 | "contextmenu", 47 | (e) => { 48 | e.preventDefault(); 49 | 50 | const menu = createMenuForElement(e.target); 51 | menu.popup({ window: remote.getCurrentWindow() }); 52 | }, 53 | false 54 | ); 55 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/markdownConverter.ts: -------------------------------------------------------------------------------- 1 | import * as showdown from "showdown"; 2 | import { last } from "lodash"; 3 | 4 | var mediaExtensions = function () { 5 | const audioExtension = { 6 | type: "lang", 7 | regex: /!audio\[[^\]]*\]\((?.*?)(?=\"|\))(?\".*\")?\)/g, 8 | replace: (_match: string, url: string) => { 9 | const fileType = last(url.split(".")); 10 | return ``; 11 | }, 12 | }; 13 | 14 | return [audioExtension]; 15 | }; 16 | 17 | const converter = new showdown.Converter({ 18 | extensions: [mediaExtensions], 19 | }); 20 | 21 | converter.setOption("tasklists", true); 22 | converter.setOption("omitExtraWLInCodeBlocks", true); 23 | converter.setOption("noHeaderId", true); 24 | converter.setOption("parseImgDimensions", true); 25 | converter.setOption("disableForced4SpacesIndentedSublists", true); 26 | converter.setOption("simpleLineBreaks", true); 27 | converter.setOption("requireSpaceBeforeHeadingText", true); 28 | 29 | export const convertMdToHTML = (md: string) => converter.makeHtml(md); 30 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/regex.ts: -------------------------------------------------------------------------------- 1 | export const isHref = (text: string) => 2 | text.match( 3 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ 4 | ); 5 | 6 | export const isImageSrc = (text: string) => 7 | text.endsWith("png") || 8 | text.endsWith("jpg") || 9 | text.endsWith("jpeg") || 10 | text.endsWith("svg"); 11 | 12 | export const isAudioSrc = (text: string) => 13 | text.endsWith("mp3") || text.endsWith("wav"); 14 | 15 | export const matchMdImage = (text: string) => { 16 | const match = text.match( 17 | /!\[[^\]]*\]\((?.*?)(?=\"|\))(?\".*\")?\)/ 18 | ); 19 | 20 | if (!match) return null; 21 | 22 | return { src: match[1].split(" ")[0] }; 23 | }; 24 | 25 | interface MatchMdListItemResponse { 26 | type: "unordered" | "ordered"; 27 | empty: boolean; 28 | nextElement: string; 29 | } 30 | 31 | export const matchMdListItem = (text: string): MatchMdListItemResponse => { 32 | const match = text.match(/^([\d\*\-])(\.?) (.*)/); 33 | 34 | if (!match) return null; 35 | 36 | const type = match[1] === "*" || match[1] === "-" ? "unordered" : "ordered"; 37 | 38 | return { 39 | type, 40 | empty: match[3] === "", 41 | nextElement: 42 | type === "unordered" 43 | ? `${match[1]}${match[2]} ` 44 | : `${parseInt(match[1]) + 1}${match[2]} `, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/sql.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.sql" { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/styled-components.d.ts: -------------------------------------------------------------------------------- 1 | import { ThemeColors } from "../settings"; 2 | 3 | declare module "styled-components" { 4 | export interface DefaultTheme extends ThemeColors {} 5 | } 6 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/useEditorKeydown.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const useEditorKeydown = ( 4 | ref: React.MutableRefObject 5 | ) => { 6 | React.useLayoutEffect(() => { 7 | if (!ref.current) return; 8 | 9 | const localHandler = (e: KeyboardEvent) => { 10 | e.stopPropagation(); 11 | }; 12 | 13 | ref.current.addEventListener("keydown", localHandler); 14 | 15 | return () => ref.current.removeEventListener("keydown", localHandler); 16 | }, [ref.current]); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/useEditorNoteEdit.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { DraftNote, Note } from "@notedown/lib/types"; 4 | 5 | export const useEditorNoteEdit = ( 6 | editor: CodeMirror.Editor, 7 | noteEdit: Note | DraftNote 8 | ) => { 9 | React.useEffect(() => { 10 | if (!editor) return; 11 | 12 | if (noteEdit && noteEdit.content) { 13 | editor.setValue(noteEdit.content); 14 | 15 | if (noteEdit.history) editor.setHistory(noteEdit.history); 16 | 17 | setImmediate(() => { 18 | editor.execCommand("goDocEnd"); 19 | }); 20 | 21 | setTimeout(() => { 22 | editor.refresh(); 23 | editor.execCommand("goDocEnd"); 24 | }, 1000); 25 | } else { 26 | editor.setValue(""); 27 | editor.setHistory({ done: [], undone: [] }); 28 | } 29 | }, [editor, noteEdit]); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/useEditorPaste.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as electron from "electron"; 3 | import * as Files from "@notedown/lib/files"; 4 | 5 | export const useEditorPaste = ( 6 | ref: React.MutableRefObject, 7 | editor: CodeMirror.Editor 8 | ) => { 9 | React.useLayoutEffect(() => { 10 | if (!ref.current || !editor) return; 11 | 12 | const onPaste = async (_event: Event) => { 13 | const image = electron.clipboard.readImage(); 14 | if (image.isEmpty()) return; 15 | const savedFile = await Files.addBuffer(image.toJPEG(90)); 16 | const doc = editor.getDoc(); 17 | var cursor = doc.getCursor(); 18 | doc.replaceRange(`![](notesfile://${savedFile.fileName})`, cursor); 19 | }; 20 | 21 | ref.current.addEventListener("paste", onPaste); 22 | 23 | return () => ref.current.removeEventListener("paste", onPaste); 24 | }, [ref.current, editor]); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/useKeyPress.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export function useKeyPress( 4 | targetKey: string, 5 | opts: { metaKey?: boolean } = {}, 6 | cb: () => any 7 | ) { 8 | useEffect(() => { 9 | function downHandler(event: KeyboardEvent) { 10 | if (event.key !== targetKey) return; 11 | 12 | if (!opts.metaKey && !event.metaKey && !event.ctrlKey) cb(); 13 | 14 | if (!!opts.metaKey && (event.metaKey || event.ctrlKey)) cb(); 15 | } 16 | 17 | window.addEventListener("keydown", downHandler); 18 | return () => { 19 | window.removeEventListener("keydown", downHandler); 20 | }; 21 | }, [cb, targetKey]); 22 | } 23 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/useMenu.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch } from "react-redux"; 3 | 4 | const { remote, clipboard } = require("electron"); 5 | const { Menu } = remote; 6 | 7 | import { modeSet } from "../mode"; 8 | import { MenuItemConstructorOptions } from "electron"; 9 | import { notesEdit } from "../notes"; 10 | import { pasteWithoutFormatting } from "../components/CodeMirrorEditor"; 11 | 12 | const isMac = process.platform === "darwin"; 13 | 14 | export const useMenu = () => { 15 | const dispatch = useDispatch(); 16 | 17 | React.useMemo(() => { 18 | //@ts-ignore 19 | const menu: MenuItemConstructorOptions[] = [ 20 | ...(isMac 21 | ? [ 22 | { 23 | label: "NoteDown", 24 | submenu: [{ role: "quit" }], 25 | }, 26 | ] 27 | : []), 28 | { 29 | label: "NoteDown", 30 | submenu: [ 31 | { 32 | label: "New Note", 33 | accelerator: "CmdOrCtrl+N", 34 | click: () => { 35 | dispatch(modeSet("editor")); 36 | }, 37 | }, 38 | { 39 | label: "Edit Note", 40 | accelerator: "CmdOrCtrl+E", 41 | click: () => dispatch(notesEdit()), 42 | }, 43 | { type: "separator" }, 44 | { 45 | label: "Preferences", 46 | accelerator: "CmdOrCtrl+,", 47 | click: () => { 48 | dispatch(modeSet("settings")); 49 | }, 50 | }, 51 | { type: "separator" }, 52 | { label: "Quit", role: "quit" }, 53 | ], 54 | }, 55 | { 56 | label: "File", 57 | submenu: [isMac ? { role: "close" } : { role: "quit" }], 58 | }, 59 | { 60 | label: "Edit", 61 | submenu: [ 62 | { role: "undo" }, 63 | { role: "redo" }, 64 | { role: "cut" }, 65 | { role: "copy" }, 66 | { role: "paste" }, 67 | { 68 | label: "Paste Without Formatting", 69 | accelerator: "CmdOrCtrl+Shift+V", 70 | click: () => pasteWithoutFormatting.next(clipboard.readText()), 71 | }, 72 | { role: "delete" }, 73 | { role: "selectAll" }, 74 | { type: "separator" }, 75 | { 76 | label: "Search Notes", 77 | accelerator: "CmdOrCtrl+F", 78 | click: () => dispatch(modeSet("search")), 79 | }, 80 | { type: "separator" }, 81 | { 82 | label: "Theme", 83 | click: async () => { 84 | dispatch(modeSet("colorPicker")); 85 | }, 86 | }, 87 | ], 88 | }, 89 | { 90 | label: "View", 91 | submenu: [ 92 | { 93 | label: "Focus mode", 94 | accelerator: "CmdOrCtrl+T", 95 | click: () => dispatch(modeSet("editorFocus")), 96 | }, 97 | { type: "separator" }, 98 | { label: "Reload", role: "reload" }, 99 | { label: "Toggle Developer Tools", role: "toggleDevTools" }, 100 | ], 101 | }, 102 | { 103 | label: "Help", 104 | submenu: [ 105 | { 106 | label: "Tips", 107 | click: async () => { 108 | dispatch(modeSet("tips")); 109 | }, 110 | }, 111 | { 112 | label: "Shortcuts", 113 | click: async () => { 114 | dispatch(modeSet("shortcuts")); 115 | }, 116 | }, 117 | ], 118 | }, 119 | ]; 120 | 121 | const appMenu = Menu.buildFromTemplate(menu); 122 | Menu.setApplicationMenu(appMenu); 123 | }, [dispatch]); 124 | }; 125 | -------------------------------------------------------------------------------- /packages/app-desktop/src/client/utils/visuallyHidden.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | export const visuallyHidden: CSSProperties = { 4 | position: "absolute", 5 | height: "1px", 6 | width: "1px", 7 | overflow: "hidden", 8 | clip: "rect(1px, 1px, 1px, 1px)", 9 | whiteSpace: "nowrap", 10 | }; 11 | -------------------------------------------------------------------------------- /packages/app-desktop/src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, protocol, shell, session } from "electron"; 2 | import * as path from "path"; 3 | import * as isDev from "electron-is-dev"; 4 | import * as querystring from "querystring"; 5 | import * as axios from "axios"; 6 | import { exec } from "child_process"; 7 | import * as os from "os"; 8 | 9 | import * as Theme from "@notedown/lib/theme"; 10 | import { notesFileToFullPath } from "@notedown/lib/files"; 11 | import { createDatabase } from "@notedown/lib/database"; 12 | 13 | const filesFolder = path.join(app.getPath("userData"), "files"); 14 | 15 | let mainWindow: Electron.BrowserWindow; 16 | 17 | declare global { 18 | namespace NodeJS { 19 | interface Global { 20 | axios: any; 21 | } 22 | } 23 | } 24 | 25 | global.axios = axios; 26 | 27 | protocol.registerSchemesAsPrivileged([ 28 | { 29 | scheme: "notesfile", 30 | privileges: { 31 | standard: true, 32 | secure: true, 33 | allowServiceWorkers: true, 34 | supportFetchAPI: true, 35 | corsEnabled: true, 36 | }, 37 | }, 38 | ]); 39 | 40 | async function createWindow() { 41 | protocol.registerFileProtocol("notesfile", (request, cb) => { 42 | const fileURI = notesFileToFullPath(request.url); 43 | 44 | cb({ path: fileURI }); 45 | }); 46 | 47 | if (isDev) { 48 | session.defaultSession.loadExtension( 49 | path.join( 50 | os.homedir(), 51 | "/Library/Application Support/Google/Chrome/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/2.17.0_0" 52 | ) 53 | ); 54 | } 55 | 56 | let colors = null; 57 | 58 | try { 59 | const db = await createDatabase(); 60 | colors = await Theme.get(db); 61 | db.close(); 62 | } catch (e) { 63 | console.error(e); 64 | } 65 | 66 | mainWindow = new BrowserWindow({ 67 | backgroundColor: colors ? colors.background1 : undefined, 68 | height: 900, 69 | titleBarStyle: "hiddenInset", 70 | width: 1500, 71 | autoHideMenuBar: true, 72 | minimizable: true, 73 | webPreferences: { 74 | enableRemoteModule: true, 75 | nodeIntegration: true, 76 | }, 77 | }); 78 | 79 | mainWindow.menuBarVisible = false; 80 | 81 | mainWindow.loadURL( 82 | isDev 83 | ? "http://localhost:8080" 84 | : `file://${path.join(__dirname, "../../build/client/index.html")}` 85 | ); 86 | 87 | if (isDev) setTimeout(() => mainWindow.webContents.openDevTools(), 4000); 88 | 89 | mainWindow.on("closed", () => { 90 | mainWindow = null; 91 | }); 92 | 93 | mainWindow.webContents.on("will-navigate", (event, url) => { 94 | if (url.startsWith("notesfile")) { 95 | const fileURI = notesFileToFullPath(url); 96 | exec(`open \"${fileURI}\"`); 97 | event.preventDefault(); 98 | return; 99 | } 100 | 101 | if (url.startsWith("http://localhost")) return; 102 | 103 | event.preventDefault(); 104 | shell.openExternal(url); 105 | }); 106 | 107 | mainWindow.webContents.on("new-window", (event, url) => { 108 | if (url.startsWith("notesfile")) { 109 | let fileURL = url; 110 | 111 | if (fileURL[fileURL.length - 1] === "/") { 112 | fileURL = fileURL.substr(0, fileURL.length - 1); 113 | } 114 | 115 | fileURL = querystring.unescape(fileURL); 116 | exec( 117 | `open '${path.join(filesFolder, fileURL.replace("notesfile://", ""))}'` 118 | ); 119 | event.preventDefault(); 120 | return; 121 | } 122 | 123 | if (url.startsWith("http://localhost")) return; 124 | 125 | event.preventDefault(); 126 | shell.openExternal(url); 127 | }); 128 | } 129 | 130 | app.allowRendererProcessReuse = false; 131 | app.setAsDefaultProtocolClient("notedown"); 132 | 133 | const gotTheLock = app.requestSingleInstanceLock(); 134 | 135 | if (!gotTheLock) { 136 | app.quit(); 137 | } else { 138 | app.on("open-url", async function (event, data) { 139 | if (mainWindow === null) { 140 | await createWindow(); 141 | } 142 | 143 | event.preventDefault(); 144 | mainWindow.webContents.send("open-url", data); 145 | }); 146 | 147 | app.on("ready", createWindow); 148 | 149 | app.on("window-all-closed", () => { 150 | if (process.platform !== "darwin") { 151 | app.quit(); 152 | } 153 | }); 154 | 155 | app.on("activate", () => { 156 | if (mainWindow === null) { 157 | createWindow(); 158 | } 159 | }); 160 | } 161 | -------------------------------------------------------------------------------- /packages/app-desktop/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "baseUrl": ".", 6 | "paths": { 7 | "*": ["src/client/*", "src/*"] 8 | } 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/app-desktop/tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": ["src/client/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/app-desktop/tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": ["src/server/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/app-desktop/webpack.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const ErrorOverlayPlugin = require("error-overlay-webpack-plugin"); 5 | const TerserPlugin = require("terser-webpack-plugin"); 6 | 7 | const isDev = process.env.NODE_ENV === "development"; 8 | 9 | const clientPlugins = [ 10 | new HtmlWebpackPlugin({ 11 | title: "NoteDown", 12 | template: "src/client/index.html", 13 | }), 14 | ]; 15 | 16 | if (isDev) clientPlugins.push(new ErrorOverlayPlugin()); 17 | 18 | function createAliasesToFolder(originPath) { 19 | return fs 20 | .readdirSync(originPath, { withFileTypes: true }) 21 | .filter((found) => found.isDirectory()) 22 | .reduce( 23 | (memo, folder) => ({ 24 | ...memo, 25 | [folder.name]: path.join(originPath, folder.name), 26 | }), 27 | {} 28 | ); 29 | } 30 | 31 | module.exports = [ 32 | { 33 | name: "server", 34 | entry: "./src/server/index.ts", 35 | mode: process.env.NODE_ENV || "development", 36 | target: "electron-main", 37 | stats: "errors-only", 38 | node: false, 39 | devtool: isDev ? "inline-source-map" : "source-map", 40 | module: { 41 | rules: [ 42 | { 43 | test: /\.sql$/, 44 | loader: "raw-loader", 45 | }, 46 | { 47 | test: /\.tsx?$/, 48 | loader: "ts-loader", 49 | exclude: /node_modules/, 50 | options: { 51 | onlyCompileBundledFiles: true, 52 | }, 53 | }, 54 | ], 55 | }, 56 | resolve: { 57 | extensions: [".tsx", ".ts", ".js"], 58 | alias: { 59 | "@notedown/lib": path.resolve(__dirname, "../lib/src"), 60 | }, 61 | }, 62 | output: { 63 | filename: "index.js", 64 | path: path.resolve(__dirname, "build/server/"), 65 | }, 66 | externals: { 67 | sqlite3: "commonjs sqlite3", 68 | }, 69 | optimization: isDev 70 | ? {} 71 | : { 72 | minimize: true, 73 | minimizer: [new TerserPlugin()], 74 | }, 75 | }, 76 | { 77 | name: "client", 78 | entry: "./src/client/index.tsx", 79 | devtool: isDev ? "inline-source-map" : "source-map", 80 | mode: process.env.NODE_ENV || "development", 81 | target: "electron-renderer", 82 | devServer: { 83 | contentBase: path.join(__dirname, "build/client"), 84 | stats: "errors-only", 85 | watchContentBase: true, 86 | }, 87 | module: { 88 | rules: [ 89 | { 90 | test: /\.tsx?$/, 91 | loader: "ts-loader", 92 | exclude: /node_modules/, 93 | options: { 94 | onlyCompileBundledFiles: true, 95 | }, 96 | }, 97 | { 98 | test: /\.css$/i, 99 | use: ["style-loader", "css-loader"], 100 | }, 101 | { 102 | test: /\.(png|jpg|gif)$/, 103 | use: ["file-loader"], 104 | }, 105 | { 106 | test: /\.(woff|woff2|eot|ttf|otf)$/, 107 | use: ["file-loader"], 108 | }, 109 | { 110 | test: /\.sql$/, 111 | use: "raw-loader", 112 | }, 113 | ], 114 | }, 115 | resolve: { 116 | extensions: [".tsx", ".ts", ".js"], 117 | alias: { 118 | "@notedown/lib": path.resolve(__dirname, "../lib/src"), 119 | ...createAliasesToFolder(path.resolve(__dirname, "src/client")), 120 | }, 121 | }, 122 | output: { 123 | filename: "index.js", 124 | path: path.resolve(__dirname, "build/client/"), 125 | }, 126 | externals: { 127 | sqlite3: "commonjs sqlite3", 128 | }, 129 | plugins: clientPlugins, 130 | optimization: isDev 131 | ? {} 132 | : { 133 | minimize: true, 134 | minimizer: [new TerserPlugin()], 135 | }, 136 | }, 137 | ]; 138 | -------------------------------------------------------------------------------- /packages/lib/README.md: -------------------------------------------------------------------------------- 1 | # `lib` 2 | 3 | > TODO: description 4 | 5 | ## Usage 6 | 7 | ``` 8 | const lib = require('lib'); 9 | 10 | // TODO: DEMONSTRATE API 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@notedown/lib", 3 | "version": "1.0.0", 4 | "description": "> TODO: description", 5 | "author": "Gabriel Poça ", 6 | "homepage": "https://github.com/subvisual/notedown#readme", 7 | "license": "ISC", 8 | "main": "lib/lib.js", 9 | "directories": { 10 | "lib": "lib", 11 | "test": "__tests__" 12 | }, 13 | "files": [ 14 | "lib" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/subvisual/notedown.git" 19 | }, 20 | "scripts": { 21 | "test": "echo \"Error: run tests from root\" && exit 1" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/subvisual/notedown/issues" 25 | }, 26 | "dependencies": { 27 | "sqlite3": "^5.0.0" 28 | }, 29 | "devDependencies": { 30 | "@reduxjs/toolkit": "^1.4.0", 31 | "@types/codemirror": "^0.0.93", 32 | "@types/lodash": "^4.14.150", 33 | "@types/lowdb": "^1.0.9", 34 | "@types/node": "12", 35 | "@types/pdfjs-dist": "^2.1.4", 36 | "@types/react": "^16.9.34", 37 | "@types/react-color": "^3.0.2", 38 | "@types/react-dom": "^16.9.7", 39 | "@types/react-modal": "^3.10.5", 40 | "@types/react-redux": "^7.1.8", 41 | "@types/shortid": "^0.0.29", 42 | "@types/showdown": "^1.9.3", 43 | "@types/sqlite3": "^3.1.6", 44 | "@types/styled-components": "^5.1.0", 45 | "axios": "^0.21.1", 46 | "codemirror": "^5.53.2", 47 | "concurrently": "^5.2.0", 48 | "css-loader": "^3.5.3", 49 | "date-fns": "^2.13.0", 50 | "error-overlay-webpack-plugin": "^0.4.1", 51 | "file-loader": "^6.0.0", 52 | "html-webpack-plugin": "^4.3.0", 53 | "lowdb": "^1.0.0", 54 | "nodemon": "^2.0.3", 55 | "pdfjs-dist": "^2.4.456", 56 | "raw-loader": "^4.0.1", 57 | "react": ">= 16.8.0", 58 | "react-color": "^2.18.1", 59 | "react-dom": ">= 16.8.0", 60 | "react-modal": "^3.11.2", 61 | "react-redux": "^7.2.0", 62 | "redux": "^4.0.5", 63 | "redux-devtools": "^3.5.0", 64 | "redux-observable": "^1.2.0", 65 | "reselect": "^4.0.0", 66 | "rxjs": "^6.5.5", 67 | "shortid": "^2.2.15", 68 | "showdown": "^1.9.1", 69 | "style-loader": "^1.2.1", 70 | "styled-components": "^5.1.0", 71 | "terser-webpack-plugin": "^3.0.1", 72 | "ts-loader": "^7.0.3", 73 | "typescript": "4", 74 | "wait-on": "^4.0.2", 75 | "webpack": "5", 76 | "webpack-cli": "4", 77 | "webpack-dev-server": "^3.10.3" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/lib/src/backup.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | import { file } from "@notedown/lib/database"; 5 | import { folder } from "@notedown/lib/files"; 6 | 7 | const fsPromises = fs.promises; 8 | 9 | export const get = async () => { 10 | return localStorage.getItem("backupFolder") || null; 11 | }; 12 | 13 | export const set = async (folder: string | null) => { 14 | localStorage.setItem("backupFolder", folder || ""); 15 | return folder; 16 | }; 17 | 18 | export const run = async (backupFolder: string) => { 19 | await fsPromises.copyFile(file, path.join(backupFolder, "notedown.sqlite")); 20 | 21 | try { 22 | await fsPromises.mkdir(path.join(backupFolder, "files")); 23 | } catch (err) { 24 | if (err.code !== "EEXIST") throw err; 25 | } 26 | 27 | const allFiles = await fsPromises.readdir(folder); 28 | 29 | allFiles.map(async (file) => { 30 | try { 31 | await fsPromises.copyFile( 32 | path.join(folder, file), 33 | path.join(backupFolder, "files", file), 34 | fs.constants.COPYFILE_EXCL 35 | ); 36 | } catch (_) {} 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/lib/src/database/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import * as SQLite3 from "sqlite3"; 3 | 4 | import { Database } from "../types"; 5 | import * as migrations from "./sqlite/migrations"; 6 | import * as migrateLegacyFileDB from "./migrateLegacyFileDB"; 7 | 8 | let localApp = app; 9 | 10 | const isRenderer = process && process.type === "renderer"; 11 | 12 | if (isRenderer) { 13 | const { remote } = window.require("electron"); 14 | localApp = remote.app; 15 | } 16 | 17 | export const file = `${localApp.getPath("userData")}/notedown.sqlite`; 18 | 19 | export const createDatabase = async () => { 20 | const sqlite = new SQLite3.Database(file); 21 | 22 | const db = new Database(sqlite); 23 | 24 | try { 25 | if (isRenderer) { 26 | await migrations.run(db); 27 | await migrateLegacyFileDB.run(db); 28 | } 29 | } catch (e) { 30 | console.error(e); 31 | } 32 | 33 | return db; 34 | }; 35 | -------------------------------------------------------------------------------- /packages/lib/src/database/migrateLegacyFileDB.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as low from "lowdb"; 3 | import { LowdbAsync } from "lowdb"; 4 | import * as FileAsync from "lowdb/adapters/FileAsync"; 5 | import { app } from "electron"; 6 | 7 | import { Database, ThemeColors } from "../types"; 8 | import * as Notes from "../notes"; 9 | 10 | export type Note = { 11 | archived?: boolean; 12 | content: string; 13 | createdAt: Date; 14 | deleted: boolean; 15 | history?: object; 16 | id: string; 17 | updatedAt: Date; 18 | }; 19 | 20 | export interface FileDatabaseInside { 21 | entries: Note[]; 22 | theme: ThemeColors; 23 | } 24 | 25 | export type FileDatabase = LowdbAsync; 26 | 27 | let localApp = app; 28 | 29 | const isRenderer = process && process.type === "renderer"; 30 | 31 | if (isRenderer) { 32 | const { remote } = window.require("electron"); 33 | localApp = remote.app; 34 | } 35 | 36 | export const databaseFile = `${localApp.getPath("userData")}/.timeline.json`; 37 | 38 | export const fileLoadAll = async (db: FileDatabase) => { 39 | return db.get("entries").reject({ deleted: true }).value(); 40 | }; 41 | 42 | export const getTheme = async (db: FileDatabase) => { 43 | return db.get("theme").value(); 44 | }; 45 | 46 | export const run = async (db: Database) => { 47 | const adapter = new FileAsync(databaseFile); 48 | const fileDB = await low(adapter); 49 | 50 | fileDB 51 | .defaults({ 52 | entries: [], 53 | theme: { 54 | background1: "#2a2438", 55 | background2: "#352f44", 56 | accent1: "#411e8f", 57 | }, 58 | }) 59 | .write(); 60 | 61 | const notes = await fileLoadAll(fileDB); 62 | 63 | if (localStorage.getItem("fileDBToSQLITE") != "true") { 64 | try { 65 | await db.run("BEGIN"); 66 | const theme = await getTheme(fileDB); 67 | 68 | await db.run("REPLACE INTO settings(id, value) values(?, json(?))", [ 69 | "theme", 70 | JSON.stringify(theme), 71 | ]); 72 | 73 | await Promise.all( 74 | notes.map( 75 | async ({ 76 | content, 77 | deleted, 78 | createdAt, 79 | history, 80 | updatedAt, 81 | archived, 82 | }) => { 83 | return db.run( 84 | "INSERT INTO notes (content, pdfsContent, deleted, archived, history, createdAt, updatedAt) values (?, ?, ?, ?, ?, ?, ?)", 85 | [ 86 | content || "", 87 | await Notes.getPDFsContent(content), 88 | deleted || false, 89 | archived || false, 90 | history ? JSON.stringify(history) : null, 91 | createdAt, 92 | updatedAt, 93 | ] 94 | ); 95 | } 96 | ) 97 | ); 98 | 99 | localStorage.setItem("fileDBToSQLITE", "true"); 100 | 101 | await db.run("COMMIT"); 102 | } catch (e) { 103 | await db.run("ROLLBACK"); 104 | console.error(e); 105 | } 106 | } 107 | }; 108 | 109 | if (!fs.existsSync(databaseFile)) { 110 | fs.closeSync(fs.openSync(databaseFile, "w")); 111 | } 112 | -------------------------------------------------------------------------------- /packages/lib/src/database/sqlite/database.ts: -------------------------------------------------------------------------------- 1 | import * as SQLite3 from "sqlite3"; 2 | import { callbackify } from "util"; 3 | 4 | export class Database { 5 | db: SQLite3.Database; 6 | 7 | constructor(db: SQLite3.Database) { 8 | this.db = db; 9 | } 10 | 11 | exec = (sql: string): Promise => { 12 | return new Promise((resolve, reject) => { 13 | let returned = false; 14 | 15 | this.db.exec(sql, (err: Error | null) => { 16 | if (returned) return; 17 | 18 | returned = true; 19 | if (err) reject(err); 20 | else resolve(); 21 | }); 22 | }); 23 | }; 24 | 25 | run = (sql: string, params: any[] = []) => { 26 | return new Promise((resolve, reject) => { 27 | let returned = false; 28 | 29 | this.db.run(sql, params, function (err: Error | null) { 30 | if (returned) return; 31 | 32 | returned = true; 33 | if (err) reject(err); 34 | else resolve(this); 35 | }); 36 | }); 37 | }; 38 | 39 | all = (sql: string, ...params: any[]) => { 40 | return new Promise((resolve, reject) => { 41 | let returned = false; 42 | 43 | this.db.all(sql, params, (err: Error | null, rows: any[]) => { 44 | if (returned) return; 45 | 46 | returned = true; 47 | if (err) reject(err); 48 | else resolve(rows); 49 | }); 50 | }); 51 | }; 52 | 53 | get = (sql: string, ...params: any[]) => { 54 | return new Promise((resolve, reject) => { 55 | this.db.get(sql, params, (err: Error | null, row: any) => { 56 | if (err) reject(err); 57 | else resolve(row); 58 | }); 59 | }); 60 | }; 61 | 62 | serialize = (callback: () => void) => { 63 | return this.db.serialize(callback); 64 | }; 65 | 66 | close = (): Promise => { 67 | return new Promise((resolve, reject) => { 68 | this.db.close((err: Error) => { 69 | if (err) reject(err); 70 | else resolve(); 71 | }); 72 | }); 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /packages/lib/src/database/sqlite/migrations/001-initial.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS notes ( 2 | id INTEGER PRIMARY KEY NOT NULL, 3 | content TEXT NOT NULL, 4 | pdfsContent TEXT, 5 | archived BOOLEAN NOT NULL DEFAULT 0, 6 | deleted BOOLEAN NOT NULL DEFAULT 0, 7 | history TEXT, 8 | createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, 9 | updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP 10 | ); 11 | 12 | CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( 13 | id UNINDEXED, 14 | content, 15 | pdfsContent, 16 | createdAt, 17 | content='notes', 18 | content_rowid='id', 19 | tokenize='porter' 20 | ); 21 | 22 | CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes 23 | BEGIN 24 | INSERT INTO notes_fts (rowid, content, createdAt, pdfsContent) 25 | VALUES (new.id, new.content, new.createdAt, new.pdfsContent); 26 | END; 27 | 28 | CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes 29 | BEGIN 30 | INSERT INTO notes_fts (notes_fts, rowid, content, createdAt, pdfsContent) 31 | VALUES ('delete', old.id, old.content, old.createdAt, old.pdfsContent); 32 | END; 33 | 34 | CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes 35 | BEGIN 36 | INSERT INTO notes_fts (notes_fts, rowid, content, createdAt, pdfsContent) 37 | VALUES ('delete', old.id, old.content, old.createdAt, old.pdfsContent); 38 | INSERT INTO notes_fts (rowid, content, createdAt, pdfsContent) 39 | VALUES (new.id, new.content, new.createdAt, new.pdfsContent); 40 | END; 41 | -------------------------------------------------------------------------------- /packages/lib/src/database/sqlite/migrations/002-settings.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS settings ( 2 | id VARCHAR(100) PRIMARY KEY NOT NULL, 3 | value TEXT 4 | ); 5 | -------------------------------------------------------------------------------- /packages/lib/src/database/sqlite/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "../database"; 2 | 3 | import migration1 from "./001-initial.sql"; 4 | import migration2 from "./002-settings.sql"; 5 | 6 | const migrations = [migration1, migration2].map((sql, index) => ({ 7 | id: index, 8 | sql, 9 | })); 10 | 11 | interface DatabaseMigrations { 12 | id: number; 13 | } 14 | 15 | export const run = async (db: Database) => { 16 | db.serialize(function () { 17 | db.run(`CREATE TABLE IF NOT EXISTS migrations ( 18 | id INTEGER PRIMARY KEY 19 | )`); 20 | }); 21 | 22 | const dbMigrations = (await db.all( 23 | `SELECT id FROM migrations ORDER BY id ASC` 24 | )) as DatabaseMigrations[]; 25 | 26 | const lastMigrationId = dbMigrations.length 27 | ? dbMigrations[dbMigrations.length - 1].id 28 | : -1; 29 | 30 | for (const migration of migrations) { 31 | if (migration.id > lastMigrationId) { 32 | await db.run("BEGIN"); 33 | try { 34 | await db.exec(migration.sql); 35 | await db.run(`INSERT INTO migrations (id) VALUES (?)`, [migration.id]); 36 | await db.run("COMMIT"); 37 | } catch (err) { 38 | await db.run("ROLLBACK"); 39 | throw err; 40 | } 41 | } 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /packages/lib/src/files.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs"; 3 | import * as shortid from "shortid"; 4 | import * as querystring from "querystring"; 5 | import { app } from "electron"; 6 | import * as axios from "axios"; 7 | 8 | let localApp = app; 9 | let localAxios = axios as any; 10 | 11 | const isRenderer = process && process.type === "renderer"; 12 | 13 | if (isRenderer) { 14 | const { remote } = window.require("electron"); 15 | localApp = remote.app; 16 | localAxios = remote.getGlobal("axios"); 17 | } 18 | 19 | const fsPromises = fs.promises; 20 | 21 | export const folder = path.join(localApp.getPath("userData"), "files"); 22 | 23 | if (!fs.existsSync(folder)) { 24 | fs.mkdirSync(folder); 25 | } 26 | 27 | function getExtension(contentType: string) { 28 | switch (contentType) { 29 | case "image/jpeg": 30 | return "jpeg"; 31 | case "image/jpg": 32 | return "jpg"; 33 | case "image/png": 34 | return "png"; 35 | } 36 | } 37 | 38 | export const notesFileToFullPath = (uri: string) => { 39 | let url = uri.substr(12); 40 | 41 | if (url[url.length - 1] === "/") { 42 | url = url.substr(0, url.length - 1); 43 | } 44 | 45 | url = querystring.unescape(url); 46 | 47 | return path.join(folder, url); 48 | }; 49 | 50 | export const addRemoteFile = async (url: string) => { 51 | const id = shortid(); 52 | 53 | const response = await localAxios.get(url, { responseType: "arraybuffer" }); 54 | 55 | const ext = getExtension(response.headers["content-type"]); 56 | const fileName = `${id}.${ext}`.toLowerCase(); 57 | 58 | const filePath = path.join(folder, fileName); 59 | await fsPromises.writeFile(filePath, response.data); 60 | 61 | return { id, name: fileName, filePath, fileName }; 62 | }; 63 | 64 | export const addLocalFile = async (file: File) => { 65 | const id = shortid(); 66 | 67 | const data = await fsPromises.readFile(file.path); 68 | 69 | const fileName = `${id}-${file.name}` 70 | .match(/[a-zA-Z0-9\.\-_]+/g) 71 | .join("-") 72 | .toLowerCase(); 73 | const filePath = path.join(folder, fileName); 74 | await fsPromises.writeFile(filePath, data); 75 | 76 | return { id, name: file.name, filePath, fileName: fileName }; 77 | }; 78 | 79 | export const addBuffer = async (buffer: Buffer) => { 80 | const id = shortid(); 81 | const fileName = id.toLowerCase(); 82 | const filePath = path.join(folder, fileName); 83 | await fsPromises.writeFile(filePath, buffer); 84 | 85 | return { id, name: fileName, filePath, fileName: fileName }; 86 | }; 87 | 88 | export const get = async (fileName: string) => { 89 | return fsPromises.readFile(path.join(folder, fileName)); 90 | }; 91 | 92 | export const remove = async (fileName: string) => { 93 | try { 94 | await fsPromises.unlink(path.join(folder, fileName)); 95 | } catch (e) { 96 | console.error(e); 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /packages/lib/src/notes.ts: -------------------------------------------------------------------------------- 1 | import * as PDFJS from "pdfjs-dist"; 2 | import { range, flatten, compact } from "lodash"; 3 | 4 | import { Note, Database } from "./types"; 5 | 6 | //@ts-ignore 7 | import pdfjsWorker from "pdfjs-dist/build/pdf.worker.entry"; 8 | PDFJS.GlobalWorkerOptions.workerSrc = pdfjsWorker; 9 | 10 | export const search = async (db: Database, query: string) => { 11 | return db.all( 12 | "SELECT id FROM notes_fts WHERE notes_fts MATCH ? ORDER BY rank", 13 | query 14 | ) as Promise[]>; 15 | }; 16 | 17 | interface DBNote extends Omit { 18 | history?: string; 19 | } 20 | 21 | export const loadAll = async (db: Database) => { 22 | const notes = (await db.all( 23 | "SELECT * FROM notes WHERE deleted = 0 ORDER BY createdAt DESC" 24 | )) as DBNote[]; 25 | 26 | return notes.map((note) => ({ 27 | ...note, 28 | history: note.history ? JSON.parse(note.history) : null, 29 | })); 30 | }; 31 | 32 | export const add = async ( 33 | db: Database, 34 | { content, history }: Pick 35 | ) => { 36 | const { 37 | lastID, 38 | } = (await db.run( 39 | "INSERT INTO notes (content, pdfsContent, history) values (?, ?, ?)", 40 | [content, await getPDFsContent(content), JSON.stringify(history)] 41 | )) as { lastID: number }; 42 | 43 | const note = { 44 | id: lastID, 45 | content, 46 | createdAt: new Date(), 47 | updatedAt: new Date(), 48 | deleted: false, 49 | archived: false, 50 | }; 51 | 52 | return note; 53 | }; 54 | 55 | export const remove = async (db: Database, id: number) => { 56 | await db.run("UPDATE notes SET deleted = 1, updatedAt = ? WHERE id = ?", [ 57 | new Date(), 58 | id, 59 | ]); 60 | }; 61 | 62 | export const update = async (db: Database, note: Note) => { 63 | await db.run( 64 | "UPDATE notes SET content = ?, pdfsContent = ?, history = ?, deleted = ?, archived = ?, updatedAt = ? WHERE id = ?", 65 | [ 66 | note.content, 67 | getPDFsContent(note.content), 68 | JSON.stringify(note.history), 69 | note.deleted, 70 | note.archived, 71 | new Date(), 72 | note.id, 73 | ] 74 | ); 75 | 76 | return; 77 | }; 78 | 79 | export async function getPDFsContent(content: string) { 80 | let pdfsContent = ""; 81 | 82 | if (content) { 83 | const pdfs = await pdfFromMarkdown(content); 84 | const allContent = await Promise.all(pdfs.map(textFromPDF)); 85 | pdfsContent += " " + flatten(allContent).join(" "); 86 | } 87 | 88 | return pdfsContent; 89 | } 90 | 91 | async function pdfFromMarkdown(markdown: string) { 92 | const markdownPDFs = markdown.match(/\[[^\]]*\]\([^\)]*\.pdf\)/g); 93 | 94 | if (!markdownPDFs) return []; 95 | 96 | return compact( 97 | await Promise.all( 98 | markdownPDFs.map(async (content) => { 99 | const match = content.match(/\[[^\]]*\]\(([^\)]*\.pdf)\)/); 100 | 101 | if (match) return match[1]; 102 | else return null; 103 | }) 104 | ) 105 | ); 106 | } 107 | 108 | async function textFromPDF(pdfURL: string) { 109 | const res = PDFJS.getDocument(pdfURL); 110 | const pdf = await res.promise; 111 | const maxPages = pdf.numPages; 112 | 113 | return Promise.all( 114 | range(1, maxPages + 1).map(async (pageNr) => { 115 | const page = await pdf.getPage(pageNr); 116 | const pageContent = await page.getTextContent(); 117 | return pageContent.items.map(({ str }) => str); 118 | }) 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /packages/lib/src/search.ts: -------------------------------------------------------------------------------- 1 | export const save = (query: String) => { 2 | const current = JSON.parse(localStorage.getItem("searchQueries") || "[]"); 3 | const newQueries = [query, ...current.slice(0, 9)]; 4 | localStorage.setItem("searchQueries", JSON.stringify(newQueries)); 5 | return newQueries; 6 | }; 7 | 8 | export const getAll = () => { 9 | return JSON.parse(localStorage.getItem("searchQueries") || "[]"); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/lib/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { ThemeColors, Database } from "./types"; 2 | 3 | export const get = async (db: Database) => { 4 | const result = (await db.get( 5 | "SELECT value FROM settings WHERE id='theme'" 6 | )) as { value: string }; 7 | 8 | if (result && result.value) return JSON.parse(result.value) as ThemeColors; 9 | else 10 | return { 11 | background1: "#2a2438", 12 | background2: "#352f44", 13 | accent1: "#411e8f", 14 | }; 15 | }; 16 | 17 | export const set = async (db: Database, colors: ThemeColors) => { 18 | return db.run("REPLACE INTO settings(value, id) VALUES(?, 'theme')", [ 19 | JSON.stringify(colors), 20 | ]); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/lib/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "./database/sqlite/database"; 2 | export { Database } from "./database/sqlite/database"; 3 | 4 | export interface ThemeColors { 5 | background1: string; 6 | background2: string; 7 | accent1: string; 8 | } 9 | 10 | export type Note = { 11 | archived?: boolean; 12 | content: string; 13 | createdAt: Date; 14 | deleted: boolean; 15 | history?: object; 16 | id: number; 17 | updatedAt: Date; 18 | }; 19 | 20 | export type DraftNote = { 21 | id: "draft"; 22 | content: string; 23 | history?: object; 24 | }; 25 | 26 | export type SearchResult = { 27 | id: number; 28 | }; 29 | 30 | export interface NotesState { 31 | savedSearches: string[]; 32 | deleting?: number; 33 | edit?: Note | DraftNote; 34 | focusId?: number; 35 | notes: Note[]; 36 | searchQuery: string; 37 | searchResult: SearchResult[]; 38 | selectedId?: number; 39 | } 40 | 41 | export interface SettingsState { 42 | colors: ThemeColors; 43 | backupFolder?: string; 44 | } 45 | 46 | export interface DatabaseState { 47 | db: Database; 48 | } 49 | 50 | export type ModeStateNames = 51 | | "settings" 52 | | "tips" 53 | | "shortcuts" 54 | | "colorPicker" 55 | | "notes" 56 | | "search" 57 | | "editor" 58 | | "editorFocus"; 59 | 60 | export interface ModeState { 61 | name: ModeStateNames; 62 | } 63 | 64 | export interface RootState { 65 | notes: NotesState; 66 | settings: SettingsState; 67 | mode: ModeState; 68 | db: DatabaseState; 69 | } 70 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "lib": ["dom", "es2015"], 5 | "types": [], 6 | "module": "commonjs", 7 | "sourceMap": true, 8 | "paths": { 9 | "*": ["node_modules/*"] 10 | }, 11 | "strict": true, 12 | "strictNullChecks": false, 13 | "noImplicitAny": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | 4 | "compilerOptions": { 5 | "baseUrl": "./packages", 6 | "paths": { 7 | "@notedown/lib": ["lib/src"], 8 | "@notedown/app-desktop": ["app-desktop/src"], 9 | "@notedown/*": ["*/src"] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------