├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── src │ ├── menu.rs │ ├── main.rs │ ├── config.rs │ ├── file.rs │ └── commands.rs ├── Cargo.toml └── tauri.conf.json ├── .husky ├── pre-commit └── commit-msg ├── commitlint.config.js ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── manifest.json └── index.html ├── screenshots ├── app.png └── banner.jpg ├── .prettierrc ├── .github ├── FUNDING.yml └── workflows │ ├── tests.yml │ └── release.yml ├── src ├── setupTests.js ├── assets │ └── icons │ │ ├── checkbox-unchecked.svg │ │ ├── checkbox-checked.svg │ │ ├── tool-rectangle.svg │ │ ├── adjust.svg │ │ ├── export.svg │ │ ├── left-arrow.svg │ │ ├── tool-arrow.svg │ │ ├── tool-ellipse.svg │ │ ├── close.svg │ │ ├── select.svg │ │ ├── maximize.svg │ │ ├── dark-mode.svg │ │ ├── redo.svg │ │ ├── undo.svg │ │ ├── folder.svg │ │ ├── zoom-out.svg │ │ ├── tool-freehand.svg │ │ ├── info.svg │ │ ├── zoom-in.svg │ │ ├── help.svg │ │ ├── eraser.svg │ │ ├── home.svg │ │ ├── move.svg │ │ ├── trashcan.svg │ │ └── light-mode.svg ├── components │ ├── FormCheckbox │ │ ├── styles.module.css │ │ └── index.js │ ├── FormSelect │ │ ├── index.js │ │ └── styles.module.css │ ├── Paper │ │ ├── components │ │ │ ├── HelpButton │ │ │ │ ├── styles.module.css │ │ │ │ └── index.js │ │ │ ├── InfoButton │ │ │ │ ├── styles.module.css │ │ │ │ └── index.js │ │ │ ├── ExportButton │ │ │ │ ├── styles.module.css │ │ │ │ └── index.js │ │ │ ├── Palette │ │ │ │ ├── styles.module.css │ │ │ │ └── index.js │ │ │ └── Toolbar │ │ │ │ ├── styles.module.css │ │ │ │ └── index.js │ │ ├── constants.js │ │ ├── styles.module.css │ │ └── helpers.js │ ├── InlineEdit │ │ ├── styles.module.css │ │ └── index.js │ ├── App │ │ └── index.js │ ├── Sortable │ │ ├── index.js │ │ └── styles.module.css │ ├── ToggleDarkMode │ │ ├── styles.module.css │ │ └── index.js │ ├── Modal │ │ ├── index.js │ │ └── styles.module.css │ └── Library │ │ ├── components │ │ ├── FolderListItem │ │ │ ├── styles.module.css │ │ │ └── index.js │ │ └── PaperListItem │ │ │ ├── styles.module.css │ │ │ └── index.js │ │ ├── styles.module.css │ │ └── index.js ├── reportWebVitals.js ├── reducers │ ├── paper │ │ └── paperSlice.js │ ├── router │ │ └── routerSlice.js │ ├── settings │ │ └── settingsSlice.js │ └── library │ │ └── librarySlice.js ├── constants.js ├── index.js ├── store.js ├── helpers.js ├── index.css └── utils │ └── paper-export.js ├── .editorconfig ├── .gitignore ├── package.json ├── README.md └── LICENSE /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/public/logo512.png -------------------------------------------------------------------------------- /screenshots/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/screenshots/app.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /screenshots/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/screenshots/banner.jpg -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kkoomen 2 | custom: ["https://paypal.me/koomenk", "https://buymeacoff.ee/kkoomen"] 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | ./node_modules/.bin/commitlint --edit $1 5 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | WixTools 5 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kkoomen/pointless/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/assets/icons/checkbox-unchecked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/menu.rs: -------------------------------------------------------------------------------- 1 | use tauri::{Menu, Submenu, MenuItem}; 2 | 3 | pub fn init() -> Menu { 4 | let root_submenu = Submenu::new("", Menu::new().add_native_item(MenuItem::Quit)); 5 | let menu = Menu::new() 6 | .add_submenu(root_submenu); 7 | 8 | menu 9 | } 10 | -------------------------------------------------------------------------------- /src/components/FormCheckbox/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .input-checkbox { 7 | width: 0; 8 | height: 0; 9 | visibility: hidden; 10 | pointer-events: none; 11 | } 12 | 13 | .label { 14 | margin-left: 5px; 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/icons/checkbox-checked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = LF 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.rs] 13 | indent_size = 4 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/assets/icons/tool-rectangle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/FormSelect/index.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import styles from './styles.module.css'; 3 | 4 | export function FormSelect({ className, children, ...otherProps }) { 5 | return ( 6 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/icons/adjust.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/export.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Paper/components/HelpButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .help-icon:hover { 2 | cursor: pointer; 3 | } 4 | 5 | .help-icon:focus, 6 | .help-icon:active { 7 | opacity: 0.5; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | .help-icon { 12 | fill: #fff; 13 | } 14 | } 15 | 16 | @media (prefers-color-scheme: light) { 17 | .help-icon { 18 | fill: #000; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Paper/components/InfoButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .info-icon:hover { 2 | cursor: pointer; 3 | } 4 | 5 | .info-icon:focus, 6 | .info-icon:active { 7 | opacity: 0.5; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | .info-icon { 12 | fill: #fff; 13 | } 14 | } 15 | 16 | @media (prefers-color-scheme: light) { 17 | .info-icon { 18 | fill: #000; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/assets/icons/left-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/tool-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = (onPerfEntry) => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod menu; 3 | mod commands; 4 | mod file; 5 | 6 | fn main() { 7 | tauri::Builder::default() 8 | .menu(menu::init()) 9 | .setup(|app| { 10 | let handle = app.handle(); 11 | config::init(handle); 12 | Ok(()) 13 | }) 14 | .invoke_handler(commands::get_handlers()) 15 | .run(tauri::generate_context!()) 16 | .expect("failed to run app"); 17 | } 18 | -------------------------------------------------------------------------------- /src/reducers/paper/paperSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const initialState = { 4 | paperId: null, 5 | }; 6 | 7 | const paperSlice = createSlice({ 8 | name: 'paper', 9 | initialState, 10 | reducers: { 11 | setCurrentPaper: (state, action) => { 12 | state.paperId = action.payload; 13 | }, 14 | }, 15 | }); 16 | 17 | export const { setCurrentPaper } = paperSlice.actions; 18 | 19 | export default paperSlice.reducer; 20 | -------------------------------------------------------------------------------- /src/assets/icons/tool-ellipse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/select.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/InlineEdit/styles.module.css: -------------------------------------------------------------------------------- 1 | .inline-edit__input { 2 | background: transparent; 3 | border: 1px dashed #999; 4 | font: inherit; 5 | line-height: inherit; 6 | color: inherit; 7 | padding: 0; 8 | margin: 0; 9 | min-width: 1rem; 10 | max-width: 100%; 11 | } 12 | 13 | .inline-edit__text:hover { 14 | cursor: text; 15 | } 16 | 17 | .inline-edit__input:focus { 18 | outline: none; 19 | } 20 | 21 | .inline-edit__input__tmp-element { 22 | white-space: pre; 23 | position: fixed; 24 | opacity: 0; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import Library from '../Library'; 3 | import Paper from '../Paper'; 4 | 5 | function App() { 6 | const currentRoute = useSelector((state) => state.router.current); 7 | switch (currentRoute.name) { 8 | case 'library': 9 | return ; 10 | 11 | case 'paper': 12 | return ; 13 | 14 | default: 15 | console.error('Unknown route', currentRoute); 16 | break; 17 | } 18 | } 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /src/assets/icons/maximize.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/reducers/router/routerSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const initialState = { 4 | current: { 5 | name: 'library', 6 | args: {}, 7 | }, 8 | }; 9 | 10 | const routerSlice = createSlice({ 11 | name: 'router', 12 | initialState, 13 | reducers: { 14 | to: (state, action) => { 15 | const { name, args } = action.payload; 16 | state.current.name = name; 17 | state.current.args = args; 18 | }, 19 | }, 20 | }); 21 | 22 | export const { to } = routerSlice.actions; 23 | 24 | export default routerSlice.reducer; 25 | -------------------------------------------------------------------------------- /src/components/FormSelect/styles.module.css: -------------------------------------------------------------------------------- 1 | .select { 2 | color: inherit; 3 | width: 100%; 4 | padding: 1rem; 5 | border-radius: 4px; 6 | transition-property: box-shadow; 7 | transition-duration: 0.2s; 8 | transition-timing-function: ease-in-out; 9 | background-image: none; 10 | -webkit-appearance: none; 11 | font-size: 1.4rem; 12 | background: #fff; 13 | border-color: #eaeaea; 14 | } 15 | 16 | .select:active, 17 | .select:focus { 18 | outline: 0; 19 | } 20 | 21 | .select:hover { 22 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); 23 | cursor: pointer; 24 | } 25 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/icons/dark-mode.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/redo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/undo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Paper/components/ExportButton/styles.module.css: -------------------------------------------------------------------------------- 1 | .revert-filename-changes:hover { 2 | text-decoration: underline; 3 | cursor: pointer; 4 | } 5 | 6 | .revert-filename-changes { 7 | color: black; 8 | } 9 | 10 | .export-icon:hover { 11 | cursor: pointer; 12 | } 13 | 14 | .export-icon:focus, 15 | .export-icon:active { 16 | opacity: 0.5; 17 | } 18 | 19 | .export-icon.disabled { 20 | fill: #757575; 21 | pointer-events: none; 22 | } 23 | 24 | @media (prefers-color-scheme: dark) { 25 | .export-icon { 26 | fill: #fff; 27 | } 28 | } 29 | 30 | @media (prefers-color-scheme: light) { 31 | .export-icon { 32 | fill: #000; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/assets/icons/folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/zoom-out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/tool-freehand.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import { BaseDirectory } from '@tauri-apps/api/fs'; 2 | 3 | export const KEY = { 4 | SPACEBAR: 32, 5 | A: 65, 6 | C: 67, 7 | E: 69, 8 | F: 70, 9 | I: 73, 10 | Q: 81, 11 | R: 82, 12 | S: 83, 13 | V: 86, 14 | X: 88, 15 | Z: 90, 16 | ZERO: 48, 17 | ENTER: 13, 18 | PLUS: 187, 19 | MINUS: 189, 20 | LEFT_SQUARE_BRACKET: 219, 21 | RIGHT_SQUARE_BRACKET: 221, 22 | DELETE: 46, 23 | BACKSPACE: 8, 24 | }; 25 | 26 | export const VIEW_MODE = { 27 | GRID: 1, 28 | LIST: 2, 29 | }; 30 | 31 | export const SORT_BY = { 32 | NAME_AZ: 1, 33 | NAME_ZA: 2, 34 | LAST_MODIFIED_ASC: 3, 35 | LAST_MODIFIED_DESC: 4, 36 | CREATED_ASC: 5, 37 | CREATED_DESC: 6, 38 | }; 39 | 40 | export const BASE_DIR = BaseDirectory.App; 41 | export const EXPORTS_DIR = BaseDirectory.Download; 42 | -------------------------------------------------------------------------------- /src/assets/icons/zoom-in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/help.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::{PathBuf, Path}; 2 | use std::fs; 3 | 4 | use tauri::AppHandle; 5 | 6 | pub fn get_app_data_dir_path(handle: AppHandle) -> PathBuf { 7 | return handle.path_resolver().app_data_dir().unwrap(); 8 | } 9 | 10 | pub fn get_settings_filepath(handle: AppHandle) -> String { 11 | let app_data_dir = get_app_data_dir_path(handle); 12 | 13 | return format!("{}/settings.dat", app_data_dir.display()); 14 | } 15 | 16 | pub fn get_library_dir_path(handle: AppHandle) -> String { 17 | let config_dir = get_app_data_dir_path(handle); 18 | 19 | return format!("{}/library", config_dir.display()); 20 | } 21 | 22 | pub fn init(handle: AppHandle) { 23 | let library_dir_path = get_library_dir_path(handle); 24 | if !Path::new(&library_dir_path).exists() { 25 | fs::create_dir_all(library_dir_path).unwrap(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/assets/icons/eraser.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Sortable/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styles from './styles.module.css'; 4 | import classNames from 'classnames'; 5 | 6 | export function Sortable(props) { 7 | return ( 8 | 9 | {props.children} 10 | 16 | 22 | 23 | ); 24 | } 25 | 26 | Sortable.propTypes = { 27 | sortAscActive: PropTypes.bool, 28 | sortDescActive: PropTypes.bool, 29 | onSortAsc: PropTypes.func.isRequired, 30 | onSortDesc: PropTypes.func.isRequired, 31 | }; 32 | 33 | export default Sortable; 34 | -------------------------------------------------------------------------------- /src/components/FormCheckbox/index.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import styles from './styles.module.css'; 3 | import { useState } from 'react'; 4 | import { ReactComponent as UncheckedIcon } from './../../assets/icons/checkbox-unchecked.svg'; 5 | import { ReactComponent as CheckedIcon } from './../../assets/icons/checkbox-checked.svg'; 6 | 7 | export function FormCheckbox({ className, label, onChange = () => {}, ...otherProps }) { 8 | const [checked, setChecked] = useState(false); 9 | 10 | const onLabelClick = () => { 11 | const newValue = !checked; 12 | setChecked(newValue); 13 | onChange(newValue); 14 | }; 15 | 16 | return ( 17 |
18 | {checked ? ( 19 | 20 | ) : ( 21 | 22 | )} 23 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pointless" 3 | version = "1.0.0" 4 | description = "An endless drawing canvas." 5 | authors = ["Kim 金可明 "] 6 | license = "MIT" 7 | repository = "git@github.com:kkoomen/pointless.git" 8 | default-run = "pointless" 9 | edition = "2021" 10 | rust-version = "1.57" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "1.2.1", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "1.2.5", features = ["dialog-all", "fs-all", "os-all", "path-all"] } 21 | brotli = "3.3.4" 22 | 23 | [features] 24 | # by default Tauri runs in production mode 25 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 26 | default = [ "custom-protocol" ] 27 | # this feature is used used for production builds where `devPath` points to the filesystem 28 | # DO NOT remove this 29 | custom-protocol = [ "tauri/custom-protocol" ] 30 | -------------------------------------------------------------------------------- /src-tauri/src/file.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Write, Read, self}; 2 | use std::fs::File; 3 | use std::fs; 4 | use std::path::Path; 5 | 6 | pub fn compress(filename: &str, contents: &str) -> bool { 7 | let mut writer = brotli::CompressorWriter::new(File::create(filename).unwrap(), 4096, 11, 22); 8 | write!(&mut writer, "{}", contents).expect("Could not compress contents"); 9 | 10 | true 11 | } 12 | 13 | pub fn decompress>(filename: P) -> Result { 14 | let file = File::open(filename)?; 15 | let mut reader = brotli::Decompressor::new(file, 4096); 16 | let mut data = String::new(); 17 | reader.read_to_string(&mut data)?; 18 | 19 | Ok(data) 20 | } 21 | 22 | 23 | pub fn read_directory_contents(path: &str) -> Vec { 24 | let mut contents: Vec = vec![]; 25 | 26 | for entry in fs::read_dir(path).unwrap() { 27 | let file_name = entry.unwrap().file_name(); 28 | let file_name_str = file_name.to_str().unwrap().to_owned(); 29 | if !file_name_str.starts_with(".") { 30 | contents.push(file_name_str); 31 | } 32 | } 33 | 34 | contents 35 | } 36 | -------------------------------------------------------------------------------- /src/assets/icons/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/move.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/trashcan.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ToggleDarkMode/styles.module.css: -------------------------------------------------------------------------------- 1 | .toggle-dark-mode__container { 2 | width: 6rem; 3 | height: 3rem; 4 | display: flex; 5 | position: relative; 6 | border-radius: 1.5rem; 7 | } 8 | 9 | .toggle-dark-mode__container:before { 10 | content: ''; 11 | position: absolute; 12 | top: 50%; 13 | left: 3.4rem; 14 | transform: translateY(-50%); 15 | border-radius: 50%; 16 | background: #fff; 17 | border: 1px solid #d1d1d1; 18 | box-shadow: 0 0 1rem rgba(0, 0, 0, 0.15); 19 | width: 2.3rem; 20 | height: 2.3rem; 21 | transition-property: left; 22 | transition-duration: 0.2s; 23 | transition-timing-function: ease-in-out; 24 | cursor: pointer; 25 | } 26 | 27 | /* .toggle-dark-mode__container:hover:before { */ 28 | /* cursor: */ 29 | /* } 30 | 31 | */ 32 | .is-dark-mode:before { 33 | left: 4px; 34 | } 35 | 36 | .toggle-dark-mode__icon-container { 37 | display: flex; 38 | align-items: center; 39 | justify-content: center; 40 | flex-grow: 1; 41 | } 42 | 43 | .toggle-dark-mode__container img { 44 | margin: 6px; 45 | pointer-events: none; 46 | } 47 | 48 | @media (prefers-color-scheme: dark) { 49 | .toggle-dark-mode__container { 50 | background: #505050; 51 | } 52 | } 53 | 54 | @media (prefers-color-scheme: light) { 55 | .toggle-dark-mode__container { 56 | background: #eaeaea; 57 | border: 1px solid #d1d1d1; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Paper/constants.js: -------------------------------------------------------------------------------- 1 | export const LINEWIDTH = { 2 | SMALL: 2, 3 | MEDIUM: 5, 4 | LARGE: 8, 5 | }; 6 | 7 | export const MODE = { 8 | FREEHAND: 'freehand', 9 | ELLIPSE: 'ellipse', 10 | RECTANGLE: 'rectangle', 11 | ARROW: 'arrow', 12 | PAN: 'pan', 13 | ERASE: 'erase', 14 | SELECT: 'select', 15 | }; 16 | 17 | export const PALETTE_LIGHT = [ 18 | '#fd5865', // red 19 | '#ff8e52', // orange 20 | '#feb849', // yellow 21 | '#1fc370', // green 22 | '#2e9ceb', // blue 23 | '#959595', // gray 24 | '#000000', // black 25 | ]; 26 | 27 | export const PALETTE_DARK = [ 28 | '#fd5865', // red 29 | '#ff8e52', // orange 30 | '#feb849', // yellow 31 | '#1fc370', // green 32 | '#2e9ceb', // blue 33 | '#959595', // gray 34 | '#ffffff', // white 35 | ]; 36 | 37 | export const ERASER_CURSOR_COLOR = '#888'; 38 | export const ERASER_SIZE = 20; 39 | export const ERASER_MIN_SIZE = 10; 40 | export const ERASER_MAX_SIZE = 200; 41 | export const ERASER_SCALE_FACTOR = 5; 42 | export const DEFAULT_STROKE_COLOR_LIGHTMODE = '#000000'; 43 | export const DEFAULT_STROKE_COLOR_DARKMODE = '#ffffff'; 44 | export const CANVAS_BACKGROUND_COLOR_LIGHTMODE = '#f8f8f8'; 45 | export const CANVAS_BACKGROUND_COLOR_DARKMODE = '#252525'; 46 | 47 | export const MIN_SCALE = 0.05; // 5% 48 | export const MAX_SCALE = 10; // 1000% 49 | export const SCALE_FACTOR = 0.002; // 0.2% 50 | export const SCALE_BY = 0.1; // 10% 51 | -------------------------------------------------------------------------------- /src/components/Modal/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import { ReactComponent as CloseIcon } from './../../assets/icons/close.svg'; 5 | import styles from './styles.module.css'; 6 | 7 | class Modal extends React.Component { 8 | render() { 9 | return ( 10 |
16 |
17 |
18 | {this.props.title} 19 | 20 |
21 |
{this.props.children}
22 | {this.props.actions && ( 23 |
{this.props.actions}
24 | )} 25 |
26 |
27 | ); 28 | } 29 | } 30 | 31 | Modal.propTypes = { 32 | title: PropTypes.string, 33 | actions: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), 34 | size: PropTypes.oneOf(['small', 'medium']), 35 | open: PropTypes.bool, 36 | onClose: PropTypes.func, 37 | }; 38 | 39 | Modal.defaultProps = { 40 | size: 'small', 41 | open: false, 42 | onClose: () => {}, 43 | }; 44 | 45 | export default Modal; 46 | -------------------------------------------------------------------------------- /src/components/Sortable/styles.module.css: -------------------------------------------------------------------------------- 1 | .sortable-item__arrow { 2 | position: relative; 3 | width: 10px; 4 | height: 10px; 5 | display: inline-block; 6 | } 7 | 8 | .sortable-item__arrow:hover { 9 | cursor: pointer; 10 | } 11 | 12 | .sortable-item__arrow:before { 13 | position: absolute; 14 | top: 50%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | content: ''; 18 | width: 0; 19 | height: 0; 20 | border-left: 5px solid rgba(0, 0, 0, 0); 21 | border-right: 5px solid rgba(0, 0, 0, 0); 22 | } 23 | 24 | .sortable-item__sort-desc { 25 | margin-left: 1rem; 26 | } 27 | 28 | .sortable-item__sort-asc { 29 | margin-left: 4px; 30 | } 31 | 32 | .sortable-item__sort-desc:before { 33 | border-top: 8px solid; 34 | } 35 | 36 | .sortable-item__sort-asc:before { 37 | border-bottom: 8px solid; 38 | } 39 | 40 | .sortable-item__sort-desc.active:before, 41 | .sortable-item__sort-desc:hover:before { 42 | border-top-color: #ffb417; 43 | } 44 | 45 | .sortable-item__sort-asc.active:before, 46 | .sortable-item__sort-asc:hover:before { 47 | border-bottom-color: #ffb417; 48 | } 49 | 50 | @media (prefers-color-scheme: dark) { 51 | .sortable-item__sort-desc:before { 52 | border-top-color: #454545; 53 | } 54 | 55 | .sortable-item__sort-asc:before { 56 | border-bottom-color: #454545; 57 | } 58 | } 59 | 60 | @media (prefers-color-scheme: light) { 61 | .sortable-item__sort-desc:before { 62 | border-top-color: #c8c8c8; 63 | } 64 | 65 | .sortable-item__sort-asc:before { 66 | border-bottom-color: #c8c8c8; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Library/components/FolderListItem/styles.module.css: -------------------------------------------------------------------------------- 1 | .folder-list-item__container { 2 | padding: 1.5rem 4.5rem 1.5rem 1.5rem; 3 | display: flex; 4 | align-items: center; 5 | border-style: solid; 6 | border-width: 1px 0 1px; 7 | position: relative; 8 | } 9 | 10 | .folder-list-item__container + .folder-list-item__container { 11 | margin-top: -1px; 12 | } 13 | 14 | .folder-list-item__container:hover { 15 | cursor: pointer; 16 | } 17 | 18 | .folder-list-item__container:hover .folder-list-item__delete-btn { 19 | opacity: 1; 20 | } 21 | 22 | .folder-list-item__name { 23 | display: flex; 24 | flex-grow: 1; 25 | overflow: hidden; 26 | margin-left: 1rem; 27 | } 28 | 29 | .folder-list-item__delete-btn { 30 | opacity: 0; 31 | position: absolute; 32 | top: 50%; 33 | right: 1rem; 34 | transform: translateY(-55%); 35 | } 36 | 37 | .folder-list-item__delete-btn svg path { 38 | fill: #fd5865; 39 | } 40 | 41 | .folder-list-item__container__icon { 42 | min-width: 1.8rem; 43 | max-width: 1.8rem; 44 | height: auto; 45 | } 46 | 47 | @media (prefers-color-scheme: dark) { 48 | .folder-list-item__container { 49 | background: #353535; 50 | border-color: #454545; 51 | } 52 | 53 | .folder-list-item__container > svg path { 54 | fill: #fff; 55 | } 56 | 57 | .folder-list-item__active { 58 | background-color: #404040; 59 | } 60 | } 61 | 62 | @media (prefers-color-scheme: light) { 63 | .folder-list-item__container { 64 | background: #fff; 65 | border-color: #eaeaea; 66 | } 67 | 68 | .folder-list-item__active { 69 | background-color: #eaeaea; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ToggleDarkMode/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import { connect } from 'react-redux'; 4 | import { ReactComponent as LightModeIcon } from './../../assets/icons/light-mode.svg'; 5 | import { ReactComponent as DarkModeIcon } from './../../assets/icons/dark-mode.svg'; 6 | import styles from './styles.module.css'; 7 | import { setDarkMode } from './../../reducers/settings/settingsSlice'; 8 | 9 | class ToggleDarkMode extends React.PureComponent { 10 | async componentDidMount() { 11 | // const isDarkMode = emit('dark-mode:enabled'); 12 | // this.props.dispatch(setDarkMode(isDarkMode)); 13 | } 14 | 15 | toggleDarkMode = async () => { 16 | const isDarkMode = !this.props.isDarkMode; 17 | await window.darkMode.set(isDarkMode); 18 | this.props.dispatch(setDarkMode(isDarkMode)); 19 | }; 20 | 21 | render() { 22 | return ( 23 |
29 |
30 | 31 |
32 |
33 | 34 |
35 |
36 | ); 37 | } 38 | } 39 | 40 | function mapStateToProps(state) { 41 | return { 42 | isDarkMode: state.settings.isDarkMode, 43 | }; 44 | } 45 | 46 | export default connect(mapStateToProps)(ToggleDarkMode); 47 | -------------------------------------------------------------------------------- /src/components/Modal/styles.module.css: -------------------------------------------------------------------------------- 1 | .modal__container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | display: flex; 8 | padding: 2rem; 9 | align-items: center; 10 | justify-content: center; 11 | background-color: rgba(0, 0, 0, 0.7); 12 | opacity: 0; 13 | pointer-events: none; 14 | cursor: default; 15 | z-index: 10; 16 | color: #000; 17 | transition-property: opacity; 18 | transition-duration: 0.3s; 19 | transition-timing-function: ease-in-out; 20 | } 21 | 22 | .open { 23 | opacity: 1; 24 | pointer-events: all; 25 | } 26 | 27 | .open .modal__content { 28 | transform: translateY(0); 29 | } 30 | 31 | .modal__content { 32 | max-width: 100%; 33 | width: 40rem; 34 | max-height: 100%; 35 | overflow-x: hidden; 36 | overflow-y: auto; 37 | background-color: #fff; 38 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15); 39 | border-radius: 4px; 40 | transform: translateY(-40%); 41 | transition-property: transform; 42 | transition-duration: 0.3s; 43 | transition-timing-function: ease-in-out; 44 | } 45 | 46 | .modal__header { 47 | display: flex; 48 | align-items: center; 49 | font-weight: bold; 50 | padding: 1.5rem; 51 | } 52 | 53 | .modal__header span { 54 | flex-grow: 1; 55 | } 56 | 57 | .modal__body { 58 | border-top: 1px solid #eaeaea; 59 | padding: 1.5rem; 60 | line-height: 1.5; 61 | } 62 | 63 | .size-medium .modal__content { 64 | width: 60rem; 65 | } 66 | 67 | .modal__close-icon { 68 | width: 1.5rem; 69 | height: 1.5rem; 70 | } 71 | 72 | .modal__close-icon:hover { 73 | cursor: pointer; 74 | } 75 | 76 | .modal__close-icon:focus, 77 | .modal__close-icon:active { 78 | opacity: 0.5; 79 | } 80 | 81 | .modal__actions { 82 | padding: 1.5rem; 83 | display: flex; 84 | justify-content: flex-end; 85 | border-top: 1px solid #eaeaea; 86 | } 87 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | branches: 6 | - '*' 7 | 8 | jobs: 9 | test-tauri: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | settings: 14 | - platform: 'macos-latest' 15 | args: '--target x86_64-apple-darwin' # Intel-based 16 | - platform: 'macos-latest' 17 | args: '--target aarch64-apple-darwin' # Apple Silicon 18 | - platform: 'ubuntu-20.04' 19 | args: '' 20 | - platform: 'windows-latest' 21 | args: '--target x86_64-pc-windows-msvc' # 64-bit 22 | - platform: 'windows-latest' 23 | args: '--target i686-pc-windows-msvc' # 32-bit 24 | 25 | runs-on: ${{ matrix.settings.platform }} 26 | steps: 27 | - uses: actions/checkout@v3 28 | 29 | - name: install Rust stable 30 | uses: dtolnay/rust-toolchain@stable 31 | with: 32 | targets: aarch64-apple-darwin 33 | 34 | - name: Rust cache 35 | uses: swatinem/rust-cache@v2 36 | with: 37 | workspaces: './src-tauri -> target' 38 | 39 | - name: Sync node version and setup cache 40 | uses: actions/setup-node@v3 41 | with: 42 | node-version: 16 43 | cache: 'yarn' 44 | 45 | - name: install dependencies (ubuntu only) 46 | if: matrix.settings.platform == 'ubuntu-20.04' 47 | run: | 48 | sudo apt-get update 49 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 50 | 51 | - name: install frontend dependencies 52 | run: yarn install # change this to npm or pnpm depending on which one you use 53 | 54 | - uses: tauri-apps/tauri-action@dev 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | with: 58 | args: ${{ matrix.settings.args }} 59 | -------------------------------------------------------------------------------- /src/assets/icons/light-mode.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "package": { 3 | "productName": "Pointless", 4 | "version": "../package.json" 5 | }, 6 | "build": { 7 | "distDir": "../build", 8 | "devPath": "http://localhost:3000", 9 | "beforeDevCommand": "npm run start", 10 | "beforeBuildCommand": "npm run build" 11 | }, 12 | "tauri": { 13 | "bundle": { 14 | "active": true, 15 | "targets": "all", 16 | "identifier": "com.pointless.app", 17 | "icon": [ 18 | "icons/32x32.png", 19 | "icons/128x128.png", 20 | "icons/128x128@2x.png", 21 | "icons/icon.icns", 22 | "icons/icon.ico" 23 | ], 24 | "resources": [], 25 | "externalBin": [], 26 | "copyright": "Kim 金可明", 27 | "category": "DeveloperTool", 28 | "shortDescription": "An endless drawing canvas.", 29 | "longDescription": "", 30 | "deb": { 31 | "depends": [] 32 | }, 33 | "macOS": { 34 | "frameworks": [], 35 | "exceptionDomain": "", 36 | "signingIdentity": null, 37 | "providerShortName": "Pointless", 38 | "entitlements": null 39 | }, 40 | "windows": { 41 | "certificateThumbprint": null, 42 | "digestAlgorithm": "sha256", 43 | "timestampUrl": "" 44 | } 45 | }, 46 | "updater": { 47 | "active": false 48 | }, 49 | "allowlist": { 50 | "os": { 51 | "all": true 52 | }, 53 | "fs": { 54 | "all": true, 55 | "scope": ["$DOWNLOAD/*"] 56 | }, 57 | "path": { 58 | "all": true 59 | }, 60 | "dialog": { 61 | "all": true, 62 | "open": true, 63 | "save": true 64 | } 65 | }, 66 | "windows": [ 67 | { 68 | "title": "Pointless", 69 | "width": 1024, 70 | "height": 768, 71 | "resizable": true, 72 | "fullscreen": false 73 | } 74 | ], 75 | "security": { 76 | "csp": null 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { os } from '@tauri-apps/api'; 2 | import { invoke } from '@tauri-apps/api/tauri'; 3 | import 'rc-tooltip/assets/bootstrap_white.css'; 4 | import { createRoot } from 'react-dom/client'; 5 | import { Provider } from 'react-redux'; 6 | import './assets/vendor/bootstrap/bootstrap-grid.min.css'; 7 | import App from './components/App'; 8 | import './index.css'; 9 | import { loadFolders } from './reducers/library/librarySlice'; 10 | import { 11 | loadSettings, 12 | setAppVersion, 13 | setDarkMode, 14 | setPlatform, 15 | } from './reducers/settings/settingsSlice'; 16 | import reportWebVitals from './reportWebVitals'; 17 | import { store } from './store'; 18 | import { getVersion } from '@tauri-apps/api/app'; 19 | import dayjs from 'dayjs'; 20 | 21 | dayjs.extend(require('dayjs/plugin/relativeTime')); 22 | 23 | // Load the library folders. 24 | invoke('load_library_folders').then((folders) => { 25 | if (folders) { 26 | store.dispatch(loadFolders(folders)); 27 | } 28 | }); 29 | 30 | // Load the saved user settings. 31 | invoke('load_settings').then((settings) => { 32 | if (settings) { 33 | store.dispatch(loadSettings(settings)); 34 | } 35 | }); 36 | 37 | // Get the current app version. 38 | getVersion().then((appVersion) => { 39 | store.dispatch(setAppVersion(appVersion)); 40 | }); 41 | 42 | // Update the isDarkMode value when the user changes theme. 43 | window 44 | .matchMedia('(prefers-color-scheme: dark)') 45 | .addEventListener('change', ({ matches: isDarkMode }) => { 46 | store.dispatch(setDarkMode(isDarkMode)); 47 | }); 48 | 49 | // Detect the users's platform. 50 | os.platform().then((platform) => { 51 | store.dispatch(setPlatform(platform)); 52 | }); 53 | 54 | createRoot(document.getElementById('root')).render( 55 | 56 | 57 | , 58 | ); 59 | 60 | // If you want to start measuring performance in your app, pass a function 61 | // to log results (for example: reportWebVitals(console.log)) 62 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 63 | reportWebVitals(); 64 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | import libraryReducer, { saveLibrary } from './reducers/library/librarySlice'; 4 | import paperReducer from './reducers/paper/paperSlice'; 5 | import routerReducer from './reducers/router/routerSlice'; 6 | import settingsReducer, { saveSettings } from './reducers/settings/settingsSlice'; 7 | 8 | const saveStateMiddleware = (store) => (next) => (action) => { 9 | const result = next(action); 10 | 11 | if (typeof action === 'object') { 12 | const reducerName = action.type.split('/')[0]; 13 | const actionName = action.type.split('/')[1]; 14 | 15 | // Auto-save the library for every library action. 16 | const whitelistedActions = [ 17 | 'updateFolderName', 18 | 'updatePaperName', 19 | 'deleteFolder', 20 | 'deletePaper', 21 | ]; 22 | 23 | if (reducerName === 'library' && whitelistedActions.includes(actionName)) { 24 | store.dispatch(saveLibrary()); 25 | } 26 | } 27 | 28 | return result; 29 | }; 30 | 31 | const saveSettingsMiddleware = (store) => (next) => (action) => { 32 | const result = next(action); 33 | 34 | if (typeof action === 'object') { 35 | const reducerName = action.type.split('/')[0]; 36 | const actionName = action.type.split('/')[1]; 37 | 38 | // Auto-save the library for every library action. 39 | const whitelistedActions = ['setSortPapersBy', 'setViewMode', 'setPreferredLinewidth']; 40 | 41 | if (reducerName === 'settings' && whitelistedActions.includes(actionName)) { 42 | store.dispatch(saveSettings()); 43 | } 44 | } 45 | 46 | return result; 47 | }; 48 | 49 | function configureAppStore() { 50 | const store = configureStore({ 51 | reducer: { 52 | paper: paperReducer, 53 | settings: settingsReducer, 54 | router: routerReducer, 55 | library: libraryReducer, 56 | }, 57 | middleware: [saveStateMiddleware, saveSettingsMiddleware, thunkMiddleware], 58 | }); 59 | 60 | return store; 61 | } 62 | 63 | export const store = configureAppStore(); 64 | -------------------------------------------------------------------------------- /src/components/Library/components/PaperListItem/styles.module.css: -------------------------------------------------------------------------------- 1 | .paper-list-item__container { 2 | position: relative; 3 | overflow: hidden; 4 | border-radius: 1rem; 5 | transition-property: box-shadow; 6 | transition-duration: 0.2s; 7 | transition-timing-function: ease-in-out; 8 | width: 100%; 9 | height: 0; 10 | padding-bottom: 56.25%; 11 | } 12 | 13 | .paper-list-item--view-mode--list { 14 | height: 4rem; 15 | padding: 1rem; 16 | } 17 | 18 | .paper-list-item--view-mode--list:hover { 19 | cursor: pointer; 20 | } 21 | 22 | .paper-list-item__container:hover { 23 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); 24 | cursor: pointer; 25 | } 26 | 27 | .paper-list-item--view-mode--list .paper-list-item__delete-btn, 28 | .paper-list-item__container:hover .paper-list-item__delete-btn { 29 | opacity: 1; 30 | } 31 | 32 | .paper-list-item__container > svg { 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | } 37 | 38 | .paper-list-item__name { 39 | position: absolute; 40 | bottom: 0; 41 | left: 0; 42 | width: 100%; 43 | font-size: 1rem; 44 | padding: 1rem; 45 | } 46 | 47 | .paper-list-item__container .paper-list-item__delete-btn { 48 | opacity: 0; 49 | position: absolute; 50 | bottom: -1px; 51 | right: 5px; 52 | } 53 | 54 | .paper-list-item__delete-btn svg { 55 | fill: #fd5865; 56 | width: 100%; 57 | height: 100%; 58 | } 59 | 60 | @media (prefers-color-scheme: dark) { 61 | .paper-list-item__name { 62 | border-top: 1px solid #353535; 63 | background-color: #454545; 64 | } 65 | 66 | .paper-list-item__container { 67 | border: 1px solid #353535; 68 | background-color: #292929; 69 | } 70 | 71 | .paper-list-item--view-mode--list:hover { 72 | background-color: #454545; 73 | } 74 | } 75 | 76 | @media (prefers-color-scheme: light) { 77 | .paper-list-item__name { 78 | border-top: 1px solid #d1d1d1; 79 | background-color: #f0f0f0; 80 | } 81 | 82 | .paper-list-item__container { 83 | border: 1px solid #d1d1d1; 84 | background-color: #fff; 85 | } 86 | 87 | .paper-list-item--view-mode--list:hover { 88 | background-color: #eaeaea; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - release 6 | 7 | jobs: 8 | publish-tauri: 9 | permissions: 10 | contents: write 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | settings: 15 | - platform: 'macos-latest' 16 | args: '--target x86_64-apple-darwin' # Intel-based 17 | - platform: 'macos-latest' 18 | args: '--target aarch64-apple-darwin' # Apple Silicon 19 | - platform: 'ubuntu-20.04' 20 | args: '' 21 | - platform: 'windows-latest' 22 | args: '--target x86_64-pc-windows-msvc' # 64-bit 23 | - platform: 'windows-latest' 24 | args: '--target i686-pc-windows-msvc' # 32-bit 25 | 26 | runs-on: ${{ matrix.settings.platform }} 27 | steps: 28 | - uses: actions/checkout@v3 29 | 30 | - name: install Rust stable 31 | uses: dtolnay/rust-toolchain@stable 32 | with: 33 | targets: aarch64-apple-darwin 34 | 35 | # - name: Rust cache 36 | # uses: swatinem/rust-cache@v2 37 | # with: 38 | # workspaces: './src-tauri -> target' 39 | 40 | - name: Sync node version and setup cache 41 | uses: actions/setup-node@v3 42 | with: 43 | node-version: 16 44 | cache: 'yarn' 45 | 46 | - name: install dependencies (ubuntu only) 47 | if: matrix.settings.platform == 'ubuntu-20.04' 48 | run: | 49 | sudo apt-get update 50 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 51 | 52 | - name: install frontend dependencies 53 | run: yarn install # change this to npm or pnpm depending on which one you use 54 | 55 | - uses: tauri-apps/tauri-action@dev 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version 60 | releaseName: 'Pointless v__VERSION__' 61 | releaseBody: 'See the assets to download this version and install.' 62 | releaseDraft: false 63 | prerelease: false 64 | args: ${{ matrix.settings.args }} 65 | -------------------------------------------------------------------------------- /src/components/Paper/styles.module.css: -------------------------------------------------------------------------------- 1 | .canvas__container { 2 | width: 100%; 3 | height: 100%; 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | cursor: cell; 8 | } 9 | 10 | .canvas__is-pan-mode { 11 | cursor: grab; 12 | } 13 | 14 | .canvas__is-panning { 15 | cursor: grabbing; 16 | } 17 | 18 | .canvas__element { 19 | vertical-align: middle; 20 | } 21 | 22 | .canvas__element { 23 | width: 100%; 24 | height: 100%; 25 | } 26 | 27 | .canvas__is-erase-mode { 28 | cursor: none; 29 | } 30 | 31 | .canvas--top-right-container { 32 | position: fixed; 33 | top: 2rem; 34 | right: 2rem; 35 | z-index: 10; 36 | } 37 | 38 | .canvas__element-readonly { 39 | height: calc(100% - 3.3rem); 40 | } 41 | 42 | .canvas--top-right-container > svg { 43 | margin-left: 2rem; 44 | } 45 | 46 | .back-button__container { 47 | position: fixed; 48 | top: 2rem; 49 | left: 2rem; 50 | display: flex; 51 | align-items: center; 52 | } 53 | 54 | .back-button__container svg { 55 | margin-right: 1rem; 56 | width: auto; 57 | height: 2.5rem; 58 | position: relative; 59 | left: 0; 60 | transition-property: left; 61 | transition-duration: 0.1s; 62 | transition-timing-function: ease-in-out; 63 | } 64 | 65 | .back-button__container:hover { 66 | cursor: pointer; 67 | } 68 | 69 | .back-button__container:hover svg { 70 | left: -5px; 71 | } 72 | 73 | .zoom-percentage { 74 | position: fixed; 75 | display: inline-block; 76 | bottom: 1rem; 77 | left: 1rem; 78 | padding: .8rem 1rem; 79 | font-size: 1.2rem; 80 | border-radius: 4px; 81 | box-shadow: 0 0 15px 0 rgba(0, 0, 0, .2); 82 | pointer-events: none; 83 | z-index: 2; 84 | } 85 | 86 | .zoom-percentage:not(.zoom-percentage--100) { 87 | color: #fd5865; 88 | } 89 | 90 | .path--select-shape:hover { 91 | cursor: grab; 92 | } 93 | 94 | .path--select-shape:active { 95 | cursor: grabbing; 96 | } 97 | 98 | @media (prefers-color-scheme: dark) { 99 | .back-button__container svg { 100 | fill: #fff; 101 | } 102 | 103 | .zoom-percentage { 104 | background: #323232; 105 | color: #fff; 106 | } 107 | } 108 | 109 | @media (prefers-color-scheme: light) { 110 | .zoom-percentage { 111 | background: #fff; 112 | border: 1px solid #d1d1d1; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pointless", 3 | "version": "1.11.0", 4 | "private": true, 5 | "description": "An endless drawing canvas.", 6 | "dependencies": { 7 | "@reduxjs/toolkit": "^1.9.5", 8 | "@tauri-apps/api": "^1.2.0", 9 | "brotli-unicode": "^1.0.2", 10 | "buffer": "^6.0.3", 11 | "classnames": "^2.3.2", 12 | "d3-ease": "^3.0.1", 13 | "d3-shape": "^3.2.0", 14 | "dayjs": "^1.11.7", 15 | "prop-types": "^15.8.1", 16 | "rc-tooltip": "^6.0.1", 17 | "react": "^18.2.0", 18 | "react-color": "^2.19.3", 19 | "react-dom": "^18.2.0", 20 | "react-draggable": "^4.4.6", 21 | "react-move": "^6.5.0", 22 | "react-redux": "^8.0.5", 23 | "react-scripts": "5.0.1", 24 | "uuid": "^9.0.0", 25 | "web-vitals": "^3.3.1" 26 | }, 27 | "scripts": { 28 | "prepare": "husky install", 29 | "start": "cross-env BROWSER=none react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "format": "prettier --write \"src/**/*.js\"", 34 | "lint": "eslint \"src/**/*.js\" --fix", 35 | "tauri": "tauri" 36 | }, 37 | "eslintConfig": { 38 | "extends": [ 39 | "react-app", 40 | "prettier" 41 | ], 42 | "rules": { 43 | "no-restricted-globals": "off", 44 | "comma-dangle": [ 45 | "warn", 46 | "always-multiline" 47 | ], 48 | "max-len": "off" 49 | } 50 | }, 51 | "husky": { 52 | "hooks": { 53 | "pre-commit": "lint-staged", 54 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 55 | } 56 | }, 57 | "lint-staged": { 58 | "*.js": [ 59 | "eslint --ext js", 60 | "prettier --write" 61 | ], 62 | "*.{md,html,json}": [ 63 | "prettier --write" 64 | ] 65 | }, 66 | "browserslist": { 67 | "production": [ 68 | ">0.2%", 69 | "not dead", 70 | "not op_mini all" 71 | ], 72 | "development": [ 73 | "last 1 chrome version", 74 | "last 1 firefox version", 75 | "last 1 safari version" 76 | ] 77 | }, 78 | "devDependencies": { 79 | "@commitlint/cli": "^17.6.1", 80 | "@commitlint/config-conventional": "^17.6.1", 81 | "@tauri-apps/cli": "^1.5.6", 82 | "cross-env": "^7.0.3", 83 | "eslint-config-prettier": "^8.8.0", 84 | "husky": "^8.0.3", 85 | "lint-staged": "^13.2.2", 86 | "prettier": "^2.8.8" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/Paper/components/Palette/styles.module.css: -------------------------------------------------------------------------------- 1 | .color-palette__container { 2 | cursor: default; 3 | position: fixed; 4 | top: 2rem; 5 | left: 50%; 6 | transform: translateX(-50%); 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | padding: 5px; 11 | border-radius: 7px; 12 | box-shadow: 0 0 15px 0 rgba(0, 0, 0, .2); 13 | } 14 | 15 | .color-palette__selector { 16 | background: #fff; 17 | cursor: default; 18 | position: fixed; 19 | top: 10rem; 20 | left: 50%; 21 | transform: translateX(-50%); 22 | display: block; 23 | align-items: center; 24 | justify-content: center; 25 | border-radius: 7px; 26 | box-shadow: 0 0 15px 0 rgba(0, 0, 0, .2); 27 | } 28 | 29 | .color-palette__color { 30 | width: 2.5rem; 31 | height: 2.5rem; 32 | margin: 5px; 33 | border-radius: 50%; 34 | transform: scale(1); 35 | transition: transform 0.15s ease-out; 36 | position: relative; 37 | } 38 | 39 | .color-palette__color:hover { 40 | cursor: pointer; 41 | } 42 | 43 | .color-palette__color:active { 44 | opacity: 0.5; 45 | } 46 | 47 | .color-palette__color-active { 48 | transform: scale(0.6); 49 | } 50 | 51 | .color-palette__custom-color svg { 52 | pointer-events: none; 53 | width: 100%; 54 | height: 100%; 55 | transform: scale(0.7) rotate(90deg); 56 | fill: #000; 57 | } 58 | 59 | .color-palette__custom-color--dark svg { 60 | fill: #fff; 61 | } 62 | 63 | .custom-color-container__handle { 64 | background-color: #fff; 65 | border-radius: 5px 5px 0 0; 66 | display: flex; 67 | justify-content: flex-end; 68 | position: relative; 69 | margin: 3px; 70 | top: 3px; 71 | } 72 | 73 | .custom-color-container__close-btn svg { 74 | height: 100%; 75 | min-width: 2.5rem; 76 | min-height: 2.5rem; 77 | max-width: 2.5rem; 78 | max-height: 2.5rem; 79 | padding: 5px; 80 | border-radius: 100%; 81 | display: flex; 82 | justify-content: space-between; 83 | } 84 | 85 | .custom-color-container__close-btn svg:hover { 86 | cursor: pointer; 87 | } 88 | 89 | @media (prefers-color-scheme: dark) { 90 | .color-palette__container { 91 | background: #323232; 92 | } 93 | } 94 | 95 | @media (prefers-color-scheme: light) { 96 | .color-palette__selector { 97 | border: 1px solid #d1d1d1; 98 | } 99 | .color-palette__container { 100 | background: #fff; 101 | border: 1px solid #d1d1d1; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/components/Library/components/FolderListItem/index.js: -------------------------------------------------------------------------------- 1 | import { confirm } from '@tauri-apps/api/dialog'; 2 | import classNames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | import InlineEdit from '../../../InlineEdit'; 5 | import { ReactComponent as FolderIcon } from './../../../../assets/icons/folder.svg'; 6 | import { ReactComponent as TrashcanIcon } from './../../../../assets/icons/trashcan.svg'; 7 | import { 8 | deleteFolder, 9 | loadFolderContents, 10 | updateFolderName, 11 | } from './../../../../reducers/library/librarySlice'; 12 | import { store } from './../../../../store'; 13 | import styles from './styles.module.css'; 14 | 15 | function FolderListItem(props) { 16 | const onEditDone = (name) => { 17 | store.dispatch( 18 | updateFolderName({ 19 | id: props.folder.id, 20 | name, 21 | }), 22 | ); 23 | }; 24 | 25 | const onClick = (e) => { 26 | // Do not trigger the onClick when we click on a button. 27 | if (e.target.nodeName === 'BUTTON') return false; 28 | 29 | store.dispatch(loadFolderContents(props.folder.id)); 30 | 31 | props.onClick(); 32 | }; 33 | 34 | const onDeleteFolder = () => { 35 | confirm(`Are you sure you want to delete the folder "${props.folder.name}" ?`).then( 36 | (shouldDelete) => { 37 | if (shouldDelete) { 38 | store.dispatch(deleteFolder(props.folder.id)); 39 | props.onDelete(); 40 | } 41 | }, 42 | ); 43 | }; 44 | 45 | return ( 46 |
52 | 53 | 54 | 55 | 56 | 62 |
63 | ); 64 | } 65 | 66 | FolderListItem.propTypes = { 67 | folder: PropTypes.object, 68 | onClick: PropTypes.func, 69 | onDelete: PropTypes.func, 70 | isActive: PropTypes.bool, 71 | }; 72 | 73 | FolderListItem.defaultProps = { 74 | onClick: () => {}, 75 | onDelete: () => {}, 76 | isActive: false, 77 | }; 78 | 79 | export default FolderListItem; 80 | -------------------------------------------------------------------------------- /src/reducers/settings/settingsSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { LINEWIDTH } from '../../components/Paper/constants'; 3 | import { SORT_BY, VIEW_MODE } from '../../constants'; 4 | import { invoke } from '@tauri-apps/api'; 5 | 6 | const initialState = { 7 | isDarkMode: window.matchMedia('(prefers-color-scheme: dark)'), 8 | platform: null, 9 | appVersion: null, 10 | libararysortPapersBy: SORT_BY.NAME_AZ, 11 | viewMode: VIEW_MODE.GRID, 12 | canvasPreferredLinewidth: LINEWIDTH.SMALL, 13 | }; 14 | 15 | const settingsSlice = createSlice({ 16 | name: 'settings', 17 | initialState, 18 | reducers: { 19 | setDarkMode: (state, action) => { 20 | state.isDarkMode = action.payload; 21 | }, 22 | setPlatform: (state, action) => { 23 | state.platform = action.payload; 24 | }, 25 | setAppVersion: (state, action) => { 26 | state.appVersion = action.payload; 27 | }, 28 | setPreferredLinewidth: (state, action) => { 29 | state.canvasPreferredLinewidth = action.payload; 30 | }, 31 | setSortPapersBy: (state, action) => { 32 | state.sortPapersBy = action.payload; 33 | }, 34 | setViewMode: (state, action) => { 35 | state.viewMode = action.payload; 36 | }, 37 | loadSettings: (state, action) => { 38 | if (action.payload && typeof action.payload === 'object') { 39 | if (Object.values(SORT_BY).includes(action.payload.sortPapersBy)) { 40 | state.sortPapersBy = action.payload.sortPapersBy; 41 | } 42 | 43 | if (Object.values(VIEW_MODE).includes(action.payload.viewMode)) { 44 | state.viewMode = action.payload.viewMode; 45 | } 46 | 47 | if (Object.values(LINEWIDTH).includes(action.payload.canvasPreferredLinewidth)) { 48 | state.canvasPreferredLinewidth = action.payload.canvasPreferredLinewidth; 49 | } 50 | } 51 | }, 52 | }, 53 | }); 54 | 55 | export const saveSettings = () => async (dispatch, getState) => { 56 | const { sortPapersBy, viewMode, canvasPreferredLinewidth } = getState().settings; 57 | const settings = { 58 | sortPapersBy, 59 | viewMode, 60 | canvasPreferredLinewidth, 61 | }; 62 | 63 | await invoke('save_settings', { settings: JSON.stringify(settings) }); 64 | }; 65 | 66 | export const { 67 | setDarkMode, 68 | setPlatform, 69 | setAppVersion, 70 | loadSettings, 71 | setSortPapersBy, 72 | setViewMode, 73 | setPreferredLinewidth, 74 | } = settingsSlice.actions; 75 | 76 | export default settingsSlice.reducer; 77 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export function formatDate(date, options) { 4 | const opts = { 5 | format: 'YYYY-MM-DD HH:mm', 6 | relative: true, 7 | ...options, 8 | }; 9 | 10 | return opts.relative ? dayjs(date).fromNow() : dayjs(date).format(opts.format); 11 | } 12 | 13 | export function removeDuplicates(arr) { 14 | return [...new Set(arr)]; 15 | } 16 | 17 | export function sanitizeFilename(filename) { 18 | // Remove the following characters that are not allowed by filesystems: 19 | // - < (less than) 20 | // - > (greater than) 21 | // - : (colon) 22 | // - " (double quote) 23 | // - / (forward slash) 24 | // - \ (backslash) 25 | // - | (vertical bar or pipe) 26 | // - ? (question mark) 27 | // - * (asterisk) 28 | return filename.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, '_'); 29 | } 30 | 31 | export function ctrlOrMetaChar(platform) { 32 | const metaKey = String.fromCharCode(8984); 33 | return platform === 'darwin' ? metaKey : 'CTRL'; 34 | } 35 | 36 | /** 37 | * Check if two objects are recursively the same. 38 | * 39 | * @param {object} obj1 - The first object to compare. 40 | * @param {object} obj2 - The second object to compare. 41 | * @returns {boolean} True if both object are exactly the same in key-values. 42 | */ 43 | export function isEqual(obj1, obj2) { 44 | if (obj1 === undefined && obj2 === undefined) { 45 | return true; 46 | } 47 | 48 | // Check if either object is undefined or null. 49 | if (obj1 === undefined || obj1 === null || obj2 === undefined || obj2 === null) { 50 | return false; 51 | } 52 | 53 | if (Object.keys(obj1).length !== Object.keys(obj2).length) { 54 | return false; 55 | } 56 | 57 | // Cecursively check each key-value pair in the objects. 58 | for (let key in obj1) { 59 | if (obj1.hasOwnProperty(key) !== obj2.hasOwnProperty(key)) { 60 | return false; 61 | } else if (typeof obj1[key] !== typeof obj2[key]) { 62 | return false; 63 | } 64 | 65 | if (typeof obj1[key] === 'object') { 66 | if (!isEqual(obj1[key], obj2[key])) { 67 | return false; 68 | } 69 | } else if (obj1[key] !== obj2[key]) { 70 | return false; 71 | } 72 | } 73 | 74 | return true; 75 | } 76 | 77 | /** 78 | * Check if a color is dark or light. 79 | * 80 | * @param {string} hex - The hex code to check. 81 | * @returns {boolean} True when the color is considered a dark color. 82 | */ 83 | export function isDarkColor(hex) { 84 | if (hex.length === 4) { 85 | hex = hex.replace(/#(.)(.)(.)/, '#$1$1$2$2$3$3'); 86 | } 87 | 88 | // Convert hex to RGB. 89 | let r = parseInt(hex.substring(1, 3), 16); 90 | let g = parseInt(hex.substring(3, 5), 16); 91 | let b = parseInt(hex.substring(5, 7), 16); 92 | 93 | let luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; 94 | 95 | // Check if the color is dark. 96 | return luminance < 0.5; 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](./screenshots/banner.jpg) 2 | ![app](./screenshots/app.png) 3 | 4 |

5 | tests build status 6 | Latest version 7 | 8 | License 9 | 10 |

11 | 12 | # Table of Contents 13 | 14 | - [Table of Contents](#table-of-contents) 15 | - [Introduction](#introduction) 16 | - [Features](#features) 17 | - [Prerequisites](#prerequisites) 18 | - [Installation](#installation) 19 | - [Development](#development) 20 | - [Creating a build](#creating-a-build) 21 | - [FAQ](#faq) 22 | - [License](#license) 23 | 24 | # Introduction 25 | 26 | Pointless is an endless drawing canvas that provides useful features when you're 27 | in need for a simple whiteboard/note app. 28 | 29 | It is build using Tauri (Rust) and React with a pure SVG canvas and local files 30 | are saved with brotli-unicode compression to ensure small file sizes. 31 | 32 | :package: If you want to use the app, grab yourself a prebuild binary 33 | [here](https://github.com/kkoomen/pointless/releases/latest) and enjoy! 34 | 35 | Arch linux users can use the pointless [AUR](https://aur.archlinux.org/packages/pointless) package. 36 | 37 | :handshake: Feel like contributing? Submit an issue with your ideas (or bugs) and 38 | we'll discuss it. 39 | 40 | # Features 41 | 42 | - [x] Export individual papers 43 | - [x] PNG 44 | - [x] JPEG 45 | - [x] SVG 46 | - [ ] Toolbar 47 | - [x] Undo 48 | - [x] Redo 49 | - [x] Pan 50 | - [x] Clear 51 | - [x] Zoom in 52 | - [x] Zoom out 53 | - [x] Scale to fit 54 | - [x] Create arrow shapes 55 | - [x] Create rectangle shapes 56 | - [x] Create ellipse shapes 57 | - [x] Selection 58 | - [x] Move selected shapes 59 | - [x] Change color of selected shapes 60 | - [x] Copy/paste selected shapes 61 | - [ ] Text 62 | - [x] Create folders 63 | - [x] Local file state persistence 64 | - [x] Light/dark theme 65 | - [x] Basic touch support 66 | 67 | # Prerequisites 68 | 69 | - [NodeJS](https://nodejs.org) 70 | - [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) 71 | - [Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) 72 | 73 | # Installation 74 | 75 | ``` 76 | $ git clone https://github.com/kkoomen/pointless.git && cd pointless 77 | $ yarn install 78 | ``` 79 | 80 | # Development 81 | 82 | Starting the development server can be done with `yarn run tauri dev` 83 | 84 | # Creating a build 85 | 86 | Creating a build can simply be done with `yarn run tauri build` 87 | 88 | # FAQ 89 | 90 | - **Pointless.app is damaged and can't be opened:** This mac issue occurs 91 | because non-signed apps are blocked. You can fix this by running 92 | `xattr -cr /Applications/Pointless.app` and then open the app again. 93 | 94 | # License 95 | 96 | Pointless is licensed under the GPL-3.0 license. 97 | -------------------------------------------------------------------------------- /src/components/Paper/components/InfoButton/index.js: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { updateFolderName, updatePaperName } from '../../../../reducers/library/librarySlice'; 4 | import { store } from '../../../../store'; 5 | import InlineEdit from '../../../InlineEdit'; 6 | import Modal from '../../../Modal'; 7 | import { ReactComponent as InfoIcon } from './../../../../assets/icons/info.svg'; 8 | import { formatDate } from './../../../../helpers'; 9 | import styles from './styles.module.css'; 10 | import Tooltip from 'rc-tooltip'; 11 | 12 | class InfoButton extends React.Component { 13 | state = { 14 | open: false, 15 | }; 16 | 17 | toggleOpen = () => { 18 | this.setState({ open: !this.state.open }); 19 | }; 20 | 21 | updatePaperName = (name) => { 22 | store.dispatch( 23 | updatePaperName({ 24 | id: this.props.paper.id, 25 | name, 26 | }), 27 | ); 28 | }; 29 | 30 | updateFolderName = (name) => { 31 | store.dispatch( 32 | updateFolderName({ 33 | id: this.props.folder.id, 34 | name, 35 | }), 36 | ); 37 | }; 38 | 39 | render() { 40 | return ( 41 | <> 42 | 48 | 49 |
50 |
Name
51 |
52 | 53 |
54 |
55 |
56 |
Folder
57 |
58 | 62 |
63 |
64 |
65 |
Last modified
66 | 70 | {formatDate(this.props.paper.updatedAt)} 71 | 72 |
73 |
74 |
Created
75 | 79 | {formatDate(this.props.paper.createdAt)} 80 | 81 |
82 |
83 | 84 | ); 85 | } 86 | } 87 | 88 | function mapStateToProps(state) { 89 | const { paperId } = state.paper; 90 | const paper = state.library.papers.find((paper) => paper.id === paperId); 91 | const folder = state.library.folders.find((folder) => folder.id === paper.folderId); 92 | return { paper, folder }; 93 | } 94 | 95 | export default connect(mapStateToProps)(memo(InfoButton)); 96 | -------------------------------------------------------------------------------- /src/components/Library/components/PaperListItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styles from './styles.module.css'; 4 | import Paper from '../../../Paper'; 5 | import classNames from 'classnames'; 6 | import { deletePaper, updatePaperName } from './../../../../reducers/library/librarySlice'; 7 | import InlineEdit from './../../../InlineEdit'; 8 | import { ReactComponent as TrashcanIcon } from './../../../../assets/icons/trashcan.svg'; 9 | import { store } from './../../../../store'; 10 | import { confirm } from '@tauri-apps/api/dialog'; 11 | import { VIEW_MODE } from '../../../../constants'; 12 | import { formatDate } from '../../../../helpers'; 13 | 14 | function PaperListItem(props) { 15 | const onDeletePaper = () => { 16 | confirm(`Are you sure you want to delete the paper "${props.paper.name}" ?`).then( 17 | (shouldDelete) => { 18 | if (shouldDelete) { 19 | store.dispatch(deletePaper(props.paper.id)); 20 | } 21 | }, 22 | ); 23 | }; 24 | 25 | const onClickListViewItem = (e) => { 26 | // Do not trigger the onClick when it's not a table cell. 27 | if (e.target.nodeName.toLowerCase() !== 'td') return false; 28 | 29 | props.onClick(); 30 | }; 31 | 32 | const onClickGridViewItem = (e) => { 33 | // Do not trigger the onClick when it's not the SVG. 34 | if (e.target.nodeName.toLowerCase() !== 'svg') return false; 35 | 36 | props.onClick(); 37 | }; 38 | 39 | const onEditPaperName = (newName) => { 40 | if (newName) { 41 | store.dispatch( 42 | updatePaperName({ 43 | id: props.paper.id, 44 | name: newName, 45 | }), 46 | ); 47 | } 48 | }; 49 | 50 | if (props.viewMode === VIEW_MODE.LIST) { 51 | return ( 52 | 53 | {props.index + 1} 54 | 55 | 56 | 57 | {formatDate(props.paper.updatedAt)} 58 | {formatDate(props.paper.createdAt)} 59 | 60 | 66 | 67 | 68 | ); 69 | } 70 | 71 | // Return grid view by default 72 | return ( 73 |
79 | {props.viewMode === VIEW_MODE.GRID && } 80 |
81 | 82 |
83 | 89 |
90 | ); 91 | } 92 | 93 | PaperListItem.propTypes = { 94 | paper: PropTypes.object.isRequired, 95 | viewMode: PropTypes.number, 96 | index: PropTypes.number, 97 | onClick: PropTypes.func, 98 | }; 99 | 100 | PaperListItem.defaultProps = { 101 | onClick: () => {}, 102 | }; 103 | 104 | export default PaperListItem; 105 | -------------------------------------------------------------------------------- /src/components/Paper/components/Toolbar/styles.module.css: -------------------------------------------------------------------------------- 1 | .toolbar__container { 2 | cursor: default; 3 | position: fixed; 4 | padding: 5px; 5 | border-radius: 7px; 6 | bottom: 2rem; 7 | left: 50%; 8 | transform: translateX(-50%); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | box-shadow: 0 0 15px 0 rgba(0, 0, 0, .2); 13 | } 14 | 15 | .toolbar__item-disabled { 16 | pointer-events: none !important; 17 | opacity: 0.3 !important; 18 | } 19 | 20 | .toolbar__item { 21 | color: #fff; 22 | width: 3rem; 23 | height: 3rem; 24 | margin: 5px; 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | } 29 | 30 | .toolbar__item:hover { 31 | cursor: pointer; 32 | } 33 | 34 | .toolbar__item:active { 35 | opacity: 0.5; 36 | } 37 | 38 | .toolbar-right__button { 39 | width: 4rem; 40 | height: 4rem; 41 | } 42 | 43 | .toolbar-right__button img, 44 | .toolbar-right__button svg, 45 | .toolbar__item img, 46 | .toolbar__item svg { 47 | pointer-events: none; 48 | max-width: 2.5rem; 49 | max-height: 2.5rem; 50 | } 51 | 52 | .toolbar__item-separator { 53 | min-height: 2.5rem; 54 | width: 1px; 55 | margin: 0 5px; 56 | } 57 | 58 | .toolbar__item-active.toolbar__item__linewidth { 59 | border-color: #f9bd3f; 60 | } 61 | 62 | .toolbar__item__linewidth { 63 | border-radius: 50%; 64 | border: 2px solid transparent; 65 | } 66 | 67 | .toolbar__item__linewidth:before { 68 | content: ''; 69 | border-radius: 50%; 70 | } 71 | 72 | .toolbar__item__linewidth-small:before { 73 | width: 7px; 74 | height: 7px; 75 | } 76 | 77 | .toolbar__item__linewidth-medium:before { 78 | width: 1.2rem; 79 | height: 1.2rem; 80 | } 81 | 82 | .toolbar__item__linewidth-large:before { 83 | width: 1.7rem; 84 | height: 1.7rem; 85 | } 86 | 87 | .toolbar__item-active { 88 | pointer-events: all; 89 | opacity: 1; 90 | } 91 | 92 | .toolbar__item.toolbar__item-active path { 93 | fill: #f9bd3f; 94 | } 95 | 96 | .toolbar-right__container { 97 | position: fixed; 98 | right: 2rem; 99 | bottom: 2rem; 100 | width: 5rem; 101 | } 102 | 103 | .toolbar-right__button { 104 | border-radius: 50%; 105 | display: flex; 106 | justify-content: center; 107 | align-items: center; 108 | padding: 1rem; 109 | box-shadow: 0 0 15px 0 rgba(0, 0, 0, .2); 110 | } 111 | 112 | .toolbar-right__button-delete svg path { 113 | fill: #fd5865; 114 | } 115 | 116 | .toolbar-right__button:active { 117 | opacity: 0.5; 118 | } 119 | 120 | .toolbar-right__button:hover { 121 | cursor: pointer; 122 | } 123 | 124 | .toolbar-right__button img { 125 | pointer-events: none; 126 | } 127 | 128 | .toolbar-right__button + .toolbar-right__button { 129 | margin-top: 1.5rem; 130 | } 131 | 132 | @media (prefers-color-scheme: dark) { 133 | .toolbar__item-separator { 134 | background-color: #707070; 135 | } 136 | 137 | .toolbar-right__button path, 138 | .toolbar__item path { 139 | fill: #fff; 140 | } 141 | 142 | .toolbar__item__linewidth:before { 143 | background-color: #fff; 144 | } 145 | 146 | .toolbar-right__button, 147 | .toolbar__container { 148 | background: #323232; 149 | } 150 | } 151 | 152 | @media (prefers-color-scheme: light) { 153 | .toolbar__item-separator { 154 | background-color: #d1d1d1; 155 | } 156 | 157 | .toolbar-right__button path, 158 | .toolbar__item path { 159 | fill: #000; 160 | } 161 | 162 | .toolbar__item__linewidth:before { 163 | background-color: #000; 164 | } 165 | 166 | .toolbar-right__button, 167 | .toolbar__container { 168 | background: #fff; 169 | border: 1px solid #d1d1d1; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/components/InlineEdit/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Tooltip from 'rc-tooltip'; 4 | import { KEY } from '../../constants'; 5 | import styles from './styles.module.css'; 6 | import classNames from 'classnames'; 7 | 8 | class InlineEdit extends React.Component { 9 | input = React.createRef(); 10 | state = { 11 | editable: false, 12 | value: null, 13 | pressedEnter: false, 14 | }; 15 | 16 | toggleEdit = (byEnterKey) => { 17 | const newState = { 18 | editable: !this.state.editable, 19 | pressedEnter: byEnterKey, 20 | }; 21 | 22 | const value = this.state.value !== null ? this.state.value.trim() : null; 23 | if (!newState.editable && value) { 24 | this.props.onEditDone(value); 25 | } 26 | 27 | if (!newState.editable) { 28 | newState.value = null; 29 | } 30 | 31 | this.setState(newState); 32 | }; 33 | 34 | onInputKeyPress = (e) => { 35 | if (e.which === KEY.ENTER) { 36 | this.input.current.blur(); 37 | this.toggleEdit(true); 38 | } 39 | }; 40 | 41 | onChange = (e) => { 42 | this.setState({ value: e.target.value }); 43 | }; 44 | 45 | onBlur = () => { 46 | if (!this.state.pressedEnter) { 47 | this.toggleEdit(); 48 | } 49 | }; 50 | 51 | componentDidUpdate(_, prevState) { 52 | if (!prevState.editable && this.state.editable) { 53 | this.input.current.focus(); 54 | this.input.current.select(); 55 | } 56 | 57 | if (prevState.editable && !this.state.editable && prevState.pressedEnter) { 58 | this.setState({ pressedEnter: false }); 59 | } 60 | } 61 | 62 | /** 63 | * Calculate the width for the input element by putting the current value 64 | * inside a temporariry span element and then use that width as the width 65 | * for the input element. 66 | */ 67 | getInputWidth = () => { 68 | var span = document.createElement('span'); 69 | 70 | if (this.input.current) { 71 | span.style.fontSize = window.getComputedStyle(this.input.current).fontSize; 72 | } 73 | 74 | span.classList.add(styles['inline-edit__tmp-element']); 75 | const value = this.state.value !== null ? this.state.value : this.props.defaultValue; 76 | span.innerHTML = value.replaceAll(' ', ' '); 77 | document.body.appendChild(span); 78 | const width = Math.round(span.getBoundingClientRect().width) + 2; 79 | document.body.removeChild(span); 80 | return `${width}px`; 81 | }; 82 | 83 | render() { 84 | if (this.state.editable) { 85 | return ( 86 | 97 | ); 98 | } 99 | 100 | return ( 101 | 102 | this.toggleEdit()} 105 | > 106 | {this.props.defaultValue} 107 | 108 | 109 | ); 110 | } 111 | } 112 | 113 | InlineEdit.propTypes = { 114 | onEditDone: PropTypes.func, 115 | defaultValue: PropTypes.string, 116 | maxlength: PropTypes.number, 117 | }; 118 | 119 | InlineEdit.defaultProps = { 120 | maxlength: 60, 121 | }; 122 | 123 | export default InlineEdit; 124 | -------------------------------------------------------------------------------- /src/components/Library/styles.module.css: -------------------------------------------------------------------------------- 1 | .app-version { 2 | color: #888; 3 | text-align: center; 4 | padding: 1.5rem; 5 | font-size: 1.4rem; 6 | } 7 | 8 | .library__container { 9 | display: flex; 10 | } 11 | 12 | .library__new-folder { 13 | background: #888; 14 | } 15 | 16 | .library__folders-heading { 17 | display: flex; 18 | align-items: center; 19 | justify-content: space-between; 20 | padding: 0 2rem; 21 | } 22 | 23 | .library__folders-container { 24 | height: 100vh; 25 | min-width: 30rem; 26 | max-width: 30rem; 27 | box-shadow: 2px 0 4px rgba(0, 0, 0, 0.15); 28 | display: flex; 29 | flex-direction: column; 30 | } 31 | 32 | .library__papers-container { 33 | padding: 2rem; 34 | flex-grow: 1; 35 | overflow-x: hidden; 36 | overflow-y: auto; 37 | height: 100vh; 38 | } 39 | 40 | .library__new-paper__container { 41 | position: relative; 42 | width: 100%; 43 | height: 0; 44 | padding-bottom: 56.25%; 45 | } 46 | 47 | .library__new-paper__container svg { 48 | width: 5rem; 49 | height: auto; 50 | } 51 | 52 | .folders-list__container { 53 | flex-grow: 1; 54 | overflow-x: hidden; 55 | overflow-y: auto; 56 | } 57 | 58 | .library__new-paper__inner-container { 59 | position: absolute; 60 | top: 0; 61 | left: 0; 62 | width: 100%; 63 | height: 100%; 64 | border: 1px dashed #f9bd3f; 65 | color: #f9bd3f; 66 | border-radius: 1rem; 67 | padding: 2rem; 68 | display: flex; 69 | align-items: center; 70 | justify-content: center; 71 | flex-direction: column; 72 | font-weight: bold; 73 | transition-property: background-color, color, box-shadow; 74 | transition-duration: 0.2s; 75 | transition-timing-function: ease-in-out; 76 | } 77 | 78 | .library__new-paper__inner-container:hover { 79 | background-color: #f9bd3f; 80 | color: #000; 81 | cursor: pointer; 82 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.15); 83 | } 84 | 85 | .no-folder-selected { 86 | display: flex; 87 | align-items: center; 88 | align-items: center; 89 | justify-content: center; 90 | flex-direction: column; 91 | } 92 | 93 | .library__paper-list-view { 94 | display: flex; 95 | flex-wrap: wrap; 96 | } 97 | 98 | /* Number column */ 99 | .library__paper-list-view table tr th:nth-child(1), 100 | .library__paper-list-view table tr td:nth-child(1) { 101 | width: 50px; 102 | text-align: right; 103 | } 104 | 105 | /* Last modified and Created column */ 106 | .library__paper-list-view table tr th:nth-last-child(3), 107 | .library__paper-list-view table tr td:nth-last-child(3), 108 | .library__paper-list-view table tr th:nth-last-child(2), 109 | .library__paper-list-view table tr td:nth-last-child(2) { 110 | width: 160px; 111 | } 112 | 113 | /* Actions column */ 114 | .library__paper-list-view table tr th:nth-last-child(1), 115 | .library__paper-list-view table tr td:nth-last-child(1) { 116 | width: 120px; 117 | } 118 | 119 | .library__paper-list-view__title { 120 | max-width: calc(100% - 20rem); 121 | } 122 | 123 | .library__paper-list-view__header { 124 | width: 100%; 125 | display: flex; 126 | justify-content: space-between; 127 | align-items: center; 128 | } 129 | 130 | .library__paper-list-view__filters { 131 | display: flex; 132 | flex-wrap: nowrap; 133 | } 134 | 135 | .library__paper-list-view__filter-group { 136 | white-space: nowrap; 137 | overflow: hidden; 138 | } 139 | 140 | .library__paper-list-view__filter-group + .library__paper-list-view__filter-group { 141 | margin-left: 2rem; 142 | } 143 | 144 | .library__paper-list-view__filters label { 145 | margin-right: 1rem; 146 | } 147 | 148 | @media (prefers-color-scheme: dark) { 149 | .library__folders-container { 150 | background-color: #353535; 151 | } 152 | } 153 | 154 | @media (prefers-color-scheme: light) { 155 | .library__folders-container { 156 | background-color: #fff; 157 | } 158 | } 159 | 160 | @media (max-width: 1000px) { 161 | .library__paper-list-view__filters label { 162 | display: none; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/reducers/library/librarySlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { invoke } from '@tauri-apps/api/tauri'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import { imageExport, svgExport } from '../../utils/paper-export'; 5 | import { exists } from '@tauri-apps/api/fs'; 6 | import { EXPORTS_DIR } from '../../constants'; 7 | 8 | const initialState = { 9 | folders: [], 10 | papers: [], 11 | }; 12 | 13 | const librarySlice = createSlice({ 14 | name: 'library', 15 | initialState, 16 | reducers: { 17 | newFolder: (state) => { 18 | state.folders.push({ 19 | id: uuidv4(), 20 | name: 'Untitled', 21 | updatedAt: new Date().toISOString(), 22 | createdAt: new Date().toISOString(), 23 | }); 24 | }, 25 | newPaperInFolder: (state, action) => { 26 | const folderId = action.payload; 27 | state.papers.push({ 28 | id: uuidv4(), 29 | name: 'Untitled', 30 | folderId: folderId, 31 | shapes: [], 32 | updatedAt: new Date().toISOString(), 33 | createdAt: new Date().toISOString(), 34 | }); 35 | }, 36 | updateFolderName: (state, action) => { 37 | const { id, name } = action.payload; 38 | for (let i = 0; i < state.folders.length; i++) { 39 | if (state.folders[i].id === id) { 40 | state.folders[i].name = name; 41 | state.folders[i].updatedAt = new Date().toISOString(); 42 | break; 43 | } 44 | } 45 | }, 46 | updatePaperName: (state, action) => { 47 | const { id, name } = action.payload; 48 | for (let i = 0; i < state.papers.length; i++) { 49 | if (state.papers[i].id === id) { 50 | state.papers[i].name = name; 51 | state.papers[i].updatedAt = new Date().toISOString(); 52 | break; 53 | } 54 | } 55 | }, 56 | setPaperShapes: (state, action) => { 57 | const { id, shapes } = action.payload; 58 | for (let i = 0; i < state.papers.length; i++) { 59 | if (state.papers[i].id === id) { 60 | state.papers[i].shapes = shapes; 61 | state.papers[i].updatedAt = new Date().toISOString(); 62 | break; 63 | } 64 | } 65 | }, 66 | deleteFolderFromState: (state, action) => { 67 | const folderId = action.payload; 68 | 69 | // Delete the folder itself. 70 | state.folders = state.folders.filter((folder) => folder.id !== folderId); 71 | 72 | // Delete all the corresponding papers. 73 | state.papers = state.papers.filter((paper) => paper.folderId !== folderId); 74 | }, 75 | deletePaperFromState: (state, action) => { 76 | const id = action.payload; 77 | state.papers = state.papers.filter((paper) => paper.id !== id); 78 | }, 79 | loadFolders: (state, action) => { 80 | if (Array.isArray(action.payload)) { 81 | state.folders = action.payload; 82 | } 83 | }, 84 | loadPapers: (state, action) => { 85 | if (Array.isArray(action.payload)) { 86 | state.papers = action.payload; 87 | } 88 | }, 89 | saveLibrary: (state) => { 90 | invoke('save_library', { libraryState: JSON.stringify(state) }); 91 | }, 92 | }, 93 | }); 94 | 95 | export const exportPaper = (payload) => async (dispatch, getState) => { 96 | const state = getState(); 97 | const { id, filename, exportType } = payload; 98 | 99 | const paper = state.library.papers.find((paper) => paper.id === id); 100 | const alreadyExists = await exists(`${filename}`, { dir: EXPORTS_DIR }); 101 | 102 | if (alreadyExists) { 103 | const overwrite = await confirm(`${filename} already exists, overwrite?`); 104 | if (!overwrite) return; 105 | } 106 | 107 | let fn = exportType === 'svg' ? svgExport : imageExport; 108 | fn(paper, filename, payload); 109 | }; 110 | 111 | export const loadFolderContents = (payload) => async (dispatch, getState) => { 112 | const folderId = payload; 113 | const papers = await invoke('load_library_folder_papers', { folderId }); 114 | dispatch(loadPapers(papers)); 115 | }; 116 | 117 | export const deleteFolder = (payload) => async (dispatch, getState) => { 118 | const folderId = payload; 119 | await invoke('delete_library_folder', { folderId }); 120 | dispatch(deleteFolderFromState(folderId)); 121 | }; 122 | 123 | export const deletePaper = (payload) => async (dispatch, getState) => { 124 | const state = getState(); 125 | const paperId = payload; 126 | const { folderId } = state.library.papers.find((paper) => paper.id === paperId); 127 | await invoke('delete_library_paper', { folderId, paperId }); 128 | dispatch(deletePaperFromState(paperId)); 129 | }; 130 | 131 | export const { 132 | newFolder, 133 | newPaperInFolder, 134 | updateFolderName, 135 | updatePaperName, 136 | setPaperShapes, 137 | loadPapers, 138 | deletePaperFromState, 139 | deleteFolderFromState, 140 | loadFolders, 141 | saveLibrary, 142 | } = librarySlice.actions; 143 | 144 | export default librarySlice.reducer; 145 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | *, *:before, *:after { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | font-size: 1.6rem; 13 | overflow: hidden; 14 | user-select: none; 15 | } 16 | 17 | html { 18 | font-size: 62.5%; 19 | user-select: none; 20 | } 21 | 22 | img { 23 | max-width: 100%; 24 | height: auto; 25 | } 26 | 27 | .hidden { 28 | display: none !important; 29 | } 30 | 31 | kbd { 32 | padding: 5px 1rem; 33 | line-height: 1; 34 | border-radius: 3px; 35 | font-weight: normal; 36 | display: inline-block; 37 | text-align: center; 38 | font-size: 2rem; 39 | background: #fff; 40 | box-shadow: 0 2px 0 0 #a1a1a1; 41 | } 42 | 43 | .container { 44 | max-width: 120rem; 45 | margin: 0 auto; 46 | } 47 | 48 | .kbd-shortcut { 49 | display: inline-block; 50 | margin-left: 1rem; 51 | } 52 | 53 | hr { 54 | margin: 5rem 0; 55 | border: 0; 56 | } 57 | 58 | .rc-tooltip { 59 | pointer-events: none; 60 | padding: 0; 61 | background-color: none; 62 | opacity: 1; 63 | box-shadow: 0 2px 1rem 0 rgb(0, 0, 0, 0.15); 64 | font-size: 1.4rem; 65 | } 66 | 67 | .rc-tooltip-arrow { 68 | margin: 0 !important; 69 | } 70 | 71 | .rc-tooltip-inner { 72 | border: 0; 73 | color: #000; 74 | font-weight: bold; 75 | background-color: #f9bd3f; 76 | } 77 | 78 | .rc-tooltip-placement-top .rc-tooltip-arrow { 79 | border-top-color: #f9bd3f; 80 | } 81 | 82 | .rc-tooltip-placement-bottom .rc-tooltip-arrow { 83 | border-bottom-color: #f9bd3f; 84 | } 85 | 86 | .rc-tooltip-placement-right .rc-tooltip-arrow { 87 | border-right-color: #f9bd3f; 88 | left: auto !important; 89 | right: 100%; 90 | top: 50% !important; 91 | transform: translateY(-50%); 92 | } 93 | 94 | .rc-tooltip-placement-left .rc-tooltip-arrow { 95 | border-left-color: #f9bd3f; 96 | right: auto !important; 97 | left: 100%; 98 | top: 50% !important; 99 | transform: translateY(-50%); 100 | } 101 | 102 | .display-flex { 103 | display: flex; 104 | } 105 | 106 | .form-group + .form-group { 107 | margin-top: 1rem; 108 | } 109 | 110 | .form-label { 111 | width: 100%; 112 | letter-spacing: 1; 113 | font-weight: bold; 114 | font-size: 1rem; 115 | text-transform: uppercase; 116 | display: flex; 117 | justify-content: space-between; 118 | color: #888; 119 | } 120 | 121 | .ellipsis { 122 | white-space: nowrap; 123 | text-overflow: ellipsis; 124 | overflow: hidden; 125 | display: inline-block; 126 | max-width: 100%; 127 | } 128 | 129 | .btn { 130 | border: 0; 131 | padding: 1rem 1.3rem; 132 | border-radius: 4px; 133 | font-size: 1.2rem; 134 | font-weight: bold; 135 | } 136 | 137 | .btn[disabled] { 138 | background-color: #d0d0d0; 139 | color: #757575; 140 | cursor: not-allowed; 141 | } 142 | 143 | .btn-icon { 144 | border-radius: 50%; 145 | min-width: 3rem; 146 | min-height: 3rem; 147 | max-width: 3rem; 148 | max-height: 3rem; 149 | padding: 8px; 150 | background: none; 151 | } 152 | 153 | .btn-icon:hover { 154 | background-color: rgba(0, 0, 0, 0.15); 155 | cursor: pointer; 156 | } 157 | 158 | .btn-icon svg { 159 | pointer-events: none; 160 | width: 100%; 161 | height: 100%; 162 | } 163 | 164 | .btn-thin { 165 | padding: 7px 1rem; 166 | } 167 | 168 | .btn-primary { 169 | background-color: #f9bd3f; 170 | color: #000; 171 | } 172 | 173 | .btn-primary:hover { 174 | cursor: pointer; 175 | } 176 | 177 | .btn-primary:hover, 178 | .btn-primary:focus, 179 | .btn-primary:active { 180 | background-color: #ffb417; 181 | } 182 | 183 | .row { 184 | --bs-gutter-y: 1.5rem; 185 | width: 100%; 186 | } 187 | 188 | .select { 189 | color: inherit; 190 | padding: 1rem; 191 | border-radius: 4px; 192 | transition-property: box-shadow; 193 | transition-duration: 0.2s; 194 | transition-timing-function: ease-in-out; 195 | background-image: none; 196 | -webkit-appearance: none; 197 | font-size: 1.4rem; 198 | } 199 | 200 | .select:active, 201 | .select:focus { 202 | outline: 0; 203 | } 204 | 205 | .select:hover { 206 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); 207 | cursor: pointer; 208 | } 209 | 210 | .text-align--right { 211 | text-align: right !important; 212 | } 213 | 214 | .table { 215 | border-collapse: collapse; 216 | width: 100%; 217 | } 218 | 219 | .table th { 220 | text-align: left; 221 | } 222 | 223 | .table th, 224 | .table td { 225 | padding: 4px 1rem; 226 | } 227 | 228 | .table.bordered td + td, 229 | .table.bordered th + th { 230 | border-left: 1px solid white; 231 | } 232 | 233 | .table .kbd-shortcut { 234 | margin-left: 0; 235 | } 236 | 237 | .table kbd { 238 | background-color: #f8f8f8; 239 | font-size: 1.4rem; 240 | padding: 4px 0.8rem; 241 | } 242 | 243 | @media (prefers-color-scheme: dark) { 244 | hr { 245 | border-top: 1px solid #454545; 246 | } 247 | 248 | body { 249 | background: #252525; 250 | color: #fff; 251 | } 252 | 253 | .select { 254 | background: #353535; 255 | border: 1px solid #454545; 256 | } 257 | } 258 | 259 | .sketch-picker { 260 | -webkit-box-shadow: none !important; 261 | box-shadow: none !important; 262 | } 263 | 264 | @media (prefers-color-scheme: light) { 265 | hr { 266 | border-top: 1px solid #d1d1d1; 267 | } 268 | 269 | body { 270 | background: #f8f8f8; 271 | color: #000; 272 | } 273 | 274 | .select { 275 | background: #fff; 276 | border-color: #eaeaea; 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/components/Paper/components/Palette/index.js: -------------------------------------------------------------------------------- 1 | import styles from './styles.module.css'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import { PALETTE_DARK, PALETTE_LIGHT } from './../../constants'; 5 | import { isDarkColor } from './../../../../helpers'; 6 | import { useSelector } from 'react-redux'; 7 | import { memo } from 'react'; 8 | import { useState } from 'react'; 9 | import { SketchPicker } from 'react-color'; 10 | import Draggable from 'react-draggable'; 11 | import { ReactComponent as CloseIcon } from './../../../../assets/icons/close.svg'; 12 | import { ReactComponent as AdjustIcon } from './../../../../assets/icons/adjust.svg'; 13 | import { useEffect } from 'react'; 14 | import Tooltip from 'rc-tooltip'; 15 | 16 | function Palette(props) { 17 | const isDarkMode = useSelector((state) => state.settings.isDarkMode); 18 | const palette = isDarkMode ? PALETTE_DARK : PALETTE_LIGHT; 19 | let selectedColor = props.selectedColor; 20 | 21 | const [paletteColor, setPaletteColor] = useState(isDarkMode ? '#fff' : '#000'); 22 | const [customColor, setCustomColor] = useState(false); 23 | const [colorSelectorToolVisible, enableColorSelectorTool] = useState(false); 24 | 25 | // If the user did select some shapes, we want to check if the shapes are all 26 | // of the same color. If so, we select that color. If not, we do not select 27 | // any color (because there are multiple) and then the user can optionally 28 | // select a color that will be set on all of those selected shapes. 29 | if (props.selectedShapes.length > 0) { 30 | const shapeColors = props.selectedShapes.map((shape) => shape.color); 31 | 32 | // Check if the colors are the same for all shapes. 33 | if (shapeColors.every((c) => c === shapeColors[0])) { 34 | selectedColor = shapeColors[0]; 35 | } else { 36 | selectedColor = null; 37 | } 38 | } 39 | 40 | // Change in custom color palette menu. 41 | const handlePaletteChange = (color) => { 42 | setPaletteColor(color.hex); 43 | }; 44 | 45 | // Change in custom color palette menu completed. 46 | const handleChangeComplete = (color) => { 47 | setPaletteColor(color.hex); 48 | props.onSelectColor(color.hex); 49 | }; 50 | 51 | // Custom color selected in top palette. 52 | const handleSelectCustom = () => { 53 | setCustomColor(true); 54 | props.onSelectColor(paletteColor); 55 | }; 56 | 57 | // Non-custom color selected in top palette. 58 | const handleSelectColor = (color) => { 59 | setCustomColor(false); 60 | props.onSelectColor(color); 61 | }; 62 | 63 | const handleColorSelectorTool = () => { 64 | enableColorSelectorTool(!colorSelectorToolVisible); 65 | }; 66 | 67 | useEffect(() => { 68 | const handleClickOutside = (event) => { 69 | const selector = document.querySelector(`.${styles['color-palette__selector']}`); 70 | if (selector && !selector.contains(event.target)) { 71 | enableColorSelectorTool(false); 72 | } 73 | }; 74 | 75 | document.addEventListener('mousedown', handleClickOutside); 76 | return () => document.removeEventListener('mousedown', handleClickOutside); 77 | }, [enableColorSelectorTool]); 78 | 79 | return ( 80 |
81 |
82 | {palette.map((color) => ( 83 |
handleSelectColor(color)} 86 | className={classNames(styles['color-palette__color'], { 87 | [styles['color-palette__color-active']]: color === selectedColor && !customColor, 88 | })} 89 | style={{ backgroundColor: color }} 90 | /> 91 | ))} 92 | 93 | 94 |
108 | 109 |
110 |
111 |
112 | {colorSelectorToolVisible && ( 113 | 114 |
115 |
116 |
enableColorSelectorTool(false)} 119 | > 120 | 121 |
122 |
123 | 128 |
129 |
130 | )} 131 |
132 | ); 133 | } 134 | 135 | Palette.propTypes = { 136 | paperId: PropTypes.string, 137 | onSelectColor: PropTypes.func, 138 | selectedColor: PropTypes.string, 139 | selectedShapes: PropTypes.array, 140 | }; 141 | 142 | Palette.defaultProps = { 143 | onSelectColor: () => {}, 144 | selectedShapeIndexes: [], 145 | }; 146 | 147 | export default memo(Palette); 148 | -------------------------------------------------------------------------------- /src/utils/paper-export.js: -------------------------------------------------------------------------------- 1 | // Helpers function for the paper exports, called by the libraryReducer. 2 | 3 | import { writeBinaryFile, writeTextFile } from '@tauri-apps/api/fs'; 4 | import { 5 | CANVAS_BACKGROUND_COLOR_DARKMODE, 6 | CANVAS_BACKGROUND_COLOR_LIGHTMODE, 7 | DEFAULT_STROKE_COLOR_DARKMODE, 8 | DEFAULT_STROKE_COLOR_LIGHTMODE, 9 | } from '../components/Paper/constants'; 10 | import { getSmoothPath } from '../components/Paper/helpers'; 11 | import { EXPORTS_DIR } from '../constants'; 12 | 13 | // Space around each side inside the exports. 14 | const PADDING = 25; 15 | 16 | // ============================================================================= 17 | // Helper functions 18 | // ============================================================================= 19 | 20 | function getShapesBBox(shapes) { 21 | // Calculate the bounding box of all the shapes 22 | const bbox = shapes.reduce( 23 | (acc, shape) => { 24 | shape.points.forEach(({ x, y }) => { 25 | acc.x1 = Math.min(acc.x1, x); 26 | acc.y1 = Math.min(acc.y1, y); 27 | acc.x2 = Math.max(acc.x2, x); 28 | acc.y2 = Math.max(acc.y2, y); 29 | }); 30 | return acc; 31 | }, 32 | { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }, 33 | ); 34 | bbox.width = bbox.x2 - bbox.x1; 35 | bbox.height = bbox.y2 - bbox.y1; 36 | return bbox; 37 | } 38 | 39 | function normalizeShapePoints(points, bbox) { 40 | // Move the origin to (0,0) by adjusting the coordinates of each point based 41 | // on the bounding box. 42 | return points.map(({ x, y }) => ({ 43 | x: x - bbox.x1 + PADDING, 44 | y: y - bbox.y1 + PADDING, 45 | })); 46 | } 47 | 48 | // ============================================================================= 49 | // Main export functions 50 | // ============================================================================= 51 | export async function svgExport(paper, filename, payload) { 52 | const bbox = getShapesBBox(paper.shapes); 53 | 54 | const svg = document.createElement('svg'); 55 | const g = document.createElement('g'); 56 | 57 | svg.setAttribute('version', '1.0'); 58 | svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); 59 | svg.setAttribute('width', bbox.width + PADDING * 2); 60 | svg.setAttribute('height', bbox.height + PADDING * 2); 61 | svg.appendChild(g); 62 | 63 | paper.shapes.forEach((shape) => { 64 | let shapeColor = shape.color; 65 | 66 | // Always use a black stroke color. 67 | if ([DEFAULT_STROKE_COLOR_DARKMODE, DEFAULT_STROKE_COLOR_LIGHTMODE].includes(shapeColor)) { 68 | shapeColor = DEFAULT_STROKE_COLOR_LIGHTMODE; 69 | } 70 | 71 | const shapePoints = normalizeShapePoints(shape.points, bbox); 72 | 73 | const path = document.createElement('path'); 74 | path.setAttribute('d', getSmoothPath({ ...shape, points: shapePoints })); 75 | path.setAttribute('fill', 'transparent'); 76 | path.setAttribute('stroke-linecap', 'round'); 77 | path.setAttribute('stroke-linejoin', 'round'); 78 | path.setAttribute('stroke', shapeColor); 79 | path.setAttribute('stroke-width', shape.linewidth); 80 | 81 | g.appendChild(path); 82 | }); 83 | 84 | await writeTextFile(filename, svg.outerHTML, { dir: EXPORTS_DIR }); 85 | } 86 | 87 | export function imageExport(paper, filename, payload) { 88 | return new Promise((resolve) => { 89 | const { theme, exportType, transparent } = payload; 90 | const exportDarkMode = theme === 'dark'; 91 | const isTransparentPNG = exportType === 'png' && transparent; 92 | const bbox = getShapesBBox(paper.shapes); 93 | 94 | var canvas = document.createElement('canvas'); 95 | var ctx = canvas.getContext('2d'); 96 | 97 | canvas.width = bbox.width + PADDING * 2; 98 | canvas.height = bbox.height + PADDING * 2; 99 | ctx.clearRect(0, 0, canvas.width, canvas.height); 100 | 101 | // Always add a background, unless its transparent PNG or SVG. 102 | if (!isTransparentPNG && exportType !== 'svg') { 103 | ctx.fillStyle = exportDarkMode 104 | ? CANVAS_BACKGROUND_COLOR_DARKMODE 105 | : CANVAS_BACKGROUND_COLOR_LIGHTMODE; 106 | ctx.fillRect(0, 0, canvas.width, canvas.height); 107 | } 108 | 109 | // Draw the shapes on the canvas. 110 | paper.shapes.forEach((shape) => { 111 | let shapeColor = shape.color; 112 | if ([DEFAULT_STROKE_COLOR_DARKMODE, DEFAULT_STROKE_COLOR_LIGHTMODE].includes(shapeColor)) { 113 | if (isTransparentPNG) { 114 | shapeColor = DEFAULT_STROKE_COLOR_LIGHTMODE; 115 | } else { 116 | shapeColor = exportDarkMode 117 | ? DEFAULT_STROKE_COLOR_DARKMODE 118 | : DEFAULT_STROKE_COLOR_LIGHTMODE; 119 | } 120 | } 121 | 122 | const shapePoints = normalizeShapePoints(shape.points, bbox); 123 | 124 | if (shapePoints.length === 1) { 125 | // draw a single dot 126 | const { x, y } = shapePoints[0]; 127 | const radius = shape.linewidth / 2; 128 | ctx.beginPath(); 129 | ctx.arc(x, y, radius, 0, 2 * Math.PI, false); 130 | ctx.fillStyle = shapeColor; 131 | ctx.fill(); 132 | } else { 133 | // draw a whole shape 134 | ctx.lineWidth = shape.linewidth; 135 | ctx.strokeStyle = shapeColor; 136 | ctx.lineCap = 'round'; 137 | ctx.lineJoin = 'round'; 138 | 139 | const adjustedPath = getSmoothPath({ ...shape, points: shapePoints }); 140 | ctx.stroke(new Path2D(adjustedPath)); 141 | } 142 | }); 143 | 144 | const mimeType = `image/${exportType}`; 145 | canvas.toBlob((blob) => { 146 | const fileReader = new FileReader(); 147 | fileReader.readAsArrayBuffer(blob); 148 | fileReader.onload = async () => { 149 | const fileContents = new Uint8Array(fileReader.result); 150 | await writeBinaryFile(filename, fileContents, { dir: EXPORTS_DIR }); 151 | resolve(); 152 | }; 153 | }, mimeType); 154 | }); 155 | } 156 | -------------------------------------------------------------------------------- /src-tauri/src/commands.rs: -------------------------------------------------------------------------------- 1 | use tauri::AppHandle; 2 | use std::path::{Path, PathBuf}; 3 | use crate::config; 4 | use crate::file::{compress, decompress, read_directory_contents}; 5 | use std::fs; 6 | use serde_json::Value; 7 | 8 | 9 | #[tauri::command] 10 | async fn load_library_folder_papers(handle: AppHandle, folder_id: String) -> Option> { 11 | let library_dir_path = config::get_library_dir_path(handle); 12 | let folder_path = format!("{}/{}", &library_dir_path, folder_id); 13 | 14 | if Path::new(&folder_path).exists() { 15 | let mut papers: Vec = vec![]; 16 | let paper_ids = read_directory_contents(&folder_path); 17 | for paper_id in paper_ids { 18 | if paper_id == "folder_info.dat" { 19 | continue; 20 | } 21 | let paper_path = format!("{}/{}", &folder_path, paper_id); 22 | let err_msg = format!("Failed to decompress \"{}/{}\"", &folder_path, &paper_id); 23 | let contents = decompress(&paper_path).expect(&err_msg); 24 | let json: serde_json::Value = serde_json::from_str(&contents).expect("Unable to parse paper"); 25 | papers.push(json); 26 | } 27 | return Some(papers); 28 | } 29 | 30 | None 31 | } 32 | 33 | #[tauri::command] 34 | async fn load_library_folders(handle: AppHandle) -> Option> { 35 | let library_dir_path = config::get_library_dir_path(handle); 36 | 37 | if Path::new(&library_dir_path).exists() { 38 | let mut folders: Vec = vec![]; 39 | 40 | let folder_ids = read_directory_contents(&library_dir_path); 41 | for folder_id in folder_ids { 42 | let folder_info_filepath = format!("{}/{}/folder_info.dat", &library_dir_path, folder_id); 43 | let contents = decompress(&folder_info_filepath).unwrap(); 44 | let json: serde_json::Value = serde_json::from_str(&contents).expect("Unable to parse folder info file"); 45 | folders.push(json); 46 | } 47 | return Some(folders); 48 | } 49 | 50 | None 51 | } 52 | 53 | #[tauri::command] 54 | async fn delete_library_folder(handle: AppHandle, folder_id: String) { 55 | let library_dir_path = config::get_library_dir_path(handle); 56 | let folder_path = format!("{}/{}", &library_dir_path, folder_id); 57 | 58 | let folder_path_buf = PathBuf::from(&folder_path); 59 | 60 | if folder_path_buf.exists() && folder_path_buf.is_dir() { 61 | match fs::remove_dir_all(&folder_path_buf) { 62 | Ok(_) => {}, 63 | Err(e) => eprintln!("Error removing folder {}: {:?}", folder_id, e), 64 | } 65 | } 66 | } 67 | 68 | #[tauri::command] 69 | async fn delete_library_paper(handle: AppHandle, folder_id: String, paper_id: String) { 70 | let library_dir_path = config::get_library_dir_path(handle); 71 | let paper_filepath = format!("{}/{}/{}.dat", &library_dir_path, folder_id, paper_id); 72 | 73 | let paper_filepath_buf = PathBuf::from(&paper_filepath); 74 | 75 | if paper_filepath_buf.exists() && paper_filepath_buf.is_file() { 76 | match fs::remove_file(&paper_filepath_buf) { 77 | Ok(_) => {}, 78 | Err(e) => eprintln!("Error removing folder {}: {:?}", folder_id, e), 79 | } 80 | } 81 | } 82 | 83 | #[tauri::command] 84 | async fn save_library(handle: AppHandle, library_state: String) { 85 | let json: Value = serde_json::from_str(&library_state).expect("Unable to parse paper"); 86 | let library_dir_path = config::get_library_dir_path(handle); 87 | 88 | 89 | // Loop through folders and create each one if it doesn't exist. 90 | if let Some(folders) = json.get("folders").and_then(|f| f.as_array()) { 91 | for folder in folders { 92 | if let Some(folder_id) = folder.get("id").and_then(|id| id.as_str()) { 93 | let folder_path = format!("{}/{}", &library_dir_path, folder_id); 94 | if !Path::new(&folder_path).exists() { 95 | fs::create_dir_all(&folder_path).expect("Unable to create folder"); 96 | } 97 | 98 | let folder_info_filepath = format!("{}/folder_info.dat", &folder_path); 99 | compress(&folder_info_filepath, &folder.to_string()); 100 | } 101 | } 102 | } 103 | 104 | // Loop through papers and save each one inside its corresponding folder. 105 | if let Some(papers) = json.get("papers").and_then(|p| p.as_array()) { 106 | for paper in papers { 107 | let folder_id = paper.get("folderId").and_then(|id| id.as_str()).unwrap(); 108 | let folder_path = format!("{}/{}", &library_dir_path, folder_id); 109 | let paper_id = paper.get("id").and_then(|id| id.as_str()).unwrap(); 110 | let paper_filepath = format!("{}/{}.dat", &folder_path, paper_id); 111 | compress(&paper_filepath, &paper.to_string()); 112 | } 113 | } 114 | } 115 | 116 | #[tauri::command] 117 | async fn save_settings(handle: AppHandle, settings: String) { 118 | let settings_filepath = config::get_settings_filepath(handle); 119 | compress(&settings_filepath, &settings); 120 | } 121 | 122 | #[tauri::command] 123 | async fn load_settings(handle: AppHandle) -> Option { 124 | let settings_filepath = config::get_settings_filepath(handle); 125 | 126 | if Path::new(&settings_filepath).exists() { 127 | let contents = decompress(&settings_filepath).unwrap(); 128 | let json: serde_json::Value = serde_json::from_str(&contents).expect("Unable to load settings"); 129 | return Some(json); 130 | } 131 | 132 | None 133 | } 134 | 135 | pub fn get_handlers() -> Box) + Send + Sync> { 136 | Box::new(tauri::generate_handler![ 137 | load_library_folders, 138 | load_library_folder_papers, 139 | delete_library_folder, 140 | delete_library_paper, 141 | save_library, 142 | load_settings, 143 | save_settings 144 | ]) 145 | } 146 | -------------------------------------------------------------------------------- /src/components/Paper/components/ExportButton/index.js: -------------------------------------------------------------------------------- 1 | import { downloadDir } from '@tauri-apps/api/path'; 2 | import React, { memo } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { exportPaper } from '../../../../reducers/library/librarySlice'; 5 | import { store } from '../../../../store'; 6 | import { FormCheckbox } from '../../../FormCheckbox'; 7 | import { FormSelect } from '../../../FormSelect'; 8 | import Modal from '../../../Modal'; 9 | import { ReactComponent as ExportIcon } from './../../../../assets/icons/export.svg'; 10 | import { sanitizeFilename } from './../../../../helpers'; 11 | import styles from './styles.module.css'; 12 | import classNames from 'classnames'; 13 | import InlineEdit from '../../../InlineEdit'; 14 | 15 | const ALLOWED_TYPES = ['jpeg', 'png', 'svg']; 16 | 17 | class ExportButton extends React.Component { 18 | constructor(props) { 19 | super(); 20 | 21 | this.initialState = { 22 | open: false, 23 | theme: props.isDarkMode ? 'dark' : 'light', 24 | exportType: ALLOWED_TYPES[0], 25 | transparent: false, 26 | location: 'unknown', 27 | filename: props.paper.name, 28 | }; 29 | 30 | this.state = this.initialState; 31 | 32 | downloadDir().then((location) => { 33 | this.setState({ location }); 34 | }); 35 | } 36 | 37 | componentDidUpdate(prevProps) { 38 | if (prevProps.paper.name !== this.props.paper.name) { 39 | this.setState({ filename: this.props.paper.name }); 40 | } 41 | } 42 | 43 | toggleOpen = () => { 44 | this.setState({ open: !this.state.open }); 45 | }; 46 | 47 | export = () => { 48 | store.dispatch( 49 | exportPaper({ 50 | id: this.props.paper.id, 51 | theme: this.state.theme, 52 | filename: this.getFilename(), 53 | exportType: this.state.exportType, 54 | transparent: this.state.transparent, 55 | }), 56 | ); 57 | 58 | this.setState({ open: false }); 59 | }; 60 | 61 | updateType = (event) => { 62 | const exportType = event.target.value; 63 | 64 | if (!ALLOWED_TYPES.includes(exportType)) { 65 | return false; 66 | } 67 | 68 | this.setState({ exportType }); 69 | }; 70 | 71 | setTransparentBackgroundValue = (transparent) => { 72 | this.setState({ transparent }); 73 | }; 74 | 75 | updateTheme = (event) => { 76 | this.setState({ 77 | theme: event.target.value, 78 | }); 79 | }; 80 | 81 | updateFilename = (filename) => { 82 | this.setState({ 83 | filename: filename.replace(new RegExp(`(.(${ALLOWED_TYPES.join('|')}))+`, 'g'), ''), 84 | }); 85 | }; 86 | 87 | revertFilenameBackToOriginal = () => { 88 | this.setState({ filename: this.props.paper.name }); 89 | }; 90 | 91 | getFilename = () => { 92 | return `${sanitizeFilename(this.state.filename)}.${this.state.exportType}`; 93 | }; 94 | 95 | getFilenameLabel = () => { 96 | if (sanitizeFilename(this.state.filename) === sanitizeFilename(this.props.paper.name)) { 97 | return 'Filename'; 98 | } 99 | 100 | return ( 101 | <> 102 | Filename (edited) 103 | 107 | revert 108 | 109 | 110 | ); 111 | }; 112 | 113 | render() { 114 | return ( 115 | <> 116 | 124 | 130 | Export 131 | 132 | } 133 | > 134 |
135 |
Location
136 |
{this.state.location}
137 |
138 |
139 |
{this.getFilenameLabel()}
140 |
141 | 142 |
143 |
144 | {this.state.exportType !== 'svg' && ( 145 |
146 |
Theme
147 |
148 | 149 | 150 | 151 | 152 |
153 |
154 | )} 155 |
156 |
Type
157 |
158 | 159 | 160 | 161 | 162 | 163 |
164 |
165 | {this.state.exportType === 'png' && ( 166 |
167 | 171 |
172 | )} 173 |
174 | 175 | ); 176 | } 177 | } 178 | 179 | function mapStateToProps(state) { 180 | const { paperId } = state.paper; 181 | const paper = state.library.papers.find((paper) => paper.id === paperId); 182 | return { 183 | paper, 184 | isDarkMode: state.settings.isDarkMode, 185 | }; 186 | } 187 | 188 | export default connect(mapStateToProps)(memo(ExportButton)); 189 | -------------------------------------------------------------------------------- /src/components/Paper/components/HelpButton/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Modal from '../../../Modal'; 3 | import { ReactComponent as HelpIcon } from './../../../../assets/icons/help.svg'; 4 | import styles from './styles.module.css'; 5 | import { ctrlOrMetaChar } from '../../../../helpers'; 6 | import { useSelector } from 'react-redux'; 7 | 8 | export default function HelpButton() { 9 | const platform = useSelector((state) => state.settings.platform); 10 | const ctrlOrMeta = ctrlOrMetaChar(platform); 11 | 12 | const [open, setOpen] = useState(false); 13 | 14 | const toggleOpen = () => { 15 | setOpen(!open); 16 | }; 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 36 | 37 | 38 | 39 | 44 | 45 | 46 | 47 | 52 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | 68 | 69 | 70 | 71 | 76 | 77 | 78 | 79 | 84 | 85 | 86 | 87 | 92 | 93 | 94 | 95 | 100 | 101 | 102 | 103 | 108 | 109 | 110 | 111 | 116 | 117 | 118 | 119 | 124 | 125 | 126 | 127 | 132 | 133 | 134 | 135 | 140 | 141 | 142 | 143 | 148 | 149 | 150 | 151 | 156 | 157 | 158 | 159 | 164 | 165 | 166 | 167 | 172 | 173 | 174 | 175 | 180 | 181 | 182 | 183 |
Key sequenceDescription
32 |
33 | f 34 |
35 |
Freehand drawing
40 |
41 | e 42 |
43 |
Draw ellipse
48 |
49 | r 50 |
51 |
Draw rectangle
56 |
57 | a 58 |
59 |
Draw arrow
64 |
65 | Spacebar 66 |
67 |
Pan around (hold spacebar)
72 |
73 | {ctrlOrMeta} + e 74 |
75 |
Eraser
80 |
81 | {ctrlOrMeta} + x 82 |
83 |
Clear canvas
88 |
89 | {ctrlOrMeta} + z 90 |
91 |
Undo
96 |
97 | {ctrlOrMeta} + shift + z 98 |
99 |
Redo
104 |
105 | {ctrlOrMeta} + + 106 |
107 |
Zoom in
112 |
113 | {ctrlOrMeta} + - 114 |
115 |
Zoom out
120 |
121 | {ctrlOrMeta} + 0 122 |
123 |
Reset zoom level
128 |
129 | ] 130 |
131 |
Increase eraser size
136 |
137 | [ 138 |
139 |
Decrease eraser size
144 |
145 | {ctrlOrMeta} + c 146 |
147 |
Copy selected shape
152 |
153 | {ctrlOrMeta} + v 154 |
155 |
Paste selected shape
160 |
161 | Delete or Backspace 162 |
163 |
Delete selected shape
168 |
169 | Shift 170 |
171 |
Preserve aspect ratio while drawing shapes
176 |
177 | {ctrlOrMeta} + q 178 |
179 |
Quit application
184 |
185 | 186 | ); 187 | } 188 | -------------------------------------------------------------------------------- /src/components/Paper/helpers.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3-shape'; 2 | import { removeDuplicates } from '../../helpers'; 3 | 4 | /** 5 | * Rotate a point around another point in 2D. 6 | * 7 | * @param {number} cx - The x value of the pivot point. 8 | * @param {number} cy - The y value of the pivot point. 9 | * @param {number} x - The x value of the point that rotates. 10 | * @param {number} y - The y value of the point that rotates. 11 | * @param {number} angle - The angle between (x,y) and (cx, cy). 12 | * @returns {number[]} The new x and y coordinates. 13 | * 14 | * @see https://stackoverflow.com/a/17411276 15 | */ 16 | export function rotateAroundPoint(cx, cy, x, y, angle) { 17 | const radians = (Math.PI / 180) * angle; 18 | const cos = Math.cos(radians); 19 | const sin = Math.sin(radians); 20 | const nx = cos * (x - cx) + sin * (y - cy) + cx; 21 | const ny = cos * (y - cy) - sin * (x - cx) + cy; 22 | return [nx, ny]; 23 | } 24 | 25 | /** 26 | * Generate a line of points based on two given points. 27 | * 28 | * @param {array} p1 - The [x,y] coordinates of point 1 29 | * @param {array} p2 - The [x,y] coordinates of point 2 30 | * @returns {array} The array of points representing the line from p1 to p2. 31 | */ 32 | export function createLine(p1, p2, scale = 1) { 33 | const [x1, y1] = p1; 34 | const [x2, y2] = p2; 35 | 36 | // If it's just a single dot being drawn, then return a single dot point. 37 | if (x1 === x2 && y1 === y2) { 38 | return [{ x: x1, y: y1 }]; 39 | } 40 | 41 | const dx = x2 - x1; 42 | const dy = y2 - y1; 43 | const numPoints = Math.max(Math.abs(dx), Math.abs(dy)) * scale; 44 | const stepX = dx / numPoints; 45 | const stepY = dy / numPoints; 46 | const linePoints = []; 47 | for (let i = 0; i <= numPoints; i++) { 48 | const x = x1 + i * stepX; 49 | const y = y1 + i * stepY; 50 | linePoints.push({ x, y }); 51 | } 52 | return linePoints; 53 | } 54 | 55 | /** 56 | * Generate an SVG d-string with smooth curves for a given shape. 57 | * 58 | * @param {object} shape - The shape object. 59 | * @returns {string} An SVG d-string of points. 60 | */ 61 | export function getSmoothPath(shape, simplifyPointsTolerance) { 62 | simplifyPointsTolerance = simplifyPointsTolerance || 1.0; 63 | const points = 64 | shape.type === 'freehand' 65 | ? simplifyPoints(shape.points, simplifyPointsTolerance) 66 | : shape.points; 67 | 68 | let line = d3 69 | .line() 70 | .x((d) => d.x) 71 | .y((d) => d.y); 72 | 73 | if (shape.type === 'freehand' && shape.points.length > 1) { 74 | line = line.curve(d3.curveCatmullRom.alpha(0.5)); 75 | } 76 | 77 | return line(points); 78 | } 79 | 80 | function simplifyPoints(points, tolerance = 1.0) { 81 | if (points.length <= 2) { 82 | return points; 83 | } 84 | 85 | const line = [points[0], points[points.length - 1]]; 86 | let maxDistance = 0; 87 | let maxIndex = 0; 88 | 89 | for (let i = 1; i < points.length - 1; i++) { 90 | const distance = perpendicularDistance(points[i], line); 91 | if (distance > maxDistance) { 92 | maxDistance = distance; 93 | maxIndex = i; 94 | } 95 | } 96 | 97 | if (maxDistance > tolerance) { 98 | const left = points.slice(0, maxIndex + 1); 99 | const right = points.slice(maxIndex); 100 | 101 | const simplifiedLeft = simplifyPoints(left, tolerance); 102 | const simplifiedRight = simplifyPoints(right, tolerance); 103 | 104 | return [...simplifiedLeft.slice(0, -1), ...simplifiedRight]; 105 | } else { 106 | return [points[0], points[points.length - 1]]; 107 | } 108 | } 109 | 110 | function perpendicularDistance(point, line) { 111 | const [p1, p2] = line; 112 | const dx = p2.x - p1.x; 113 | const dy = p2.y - p1.y; 114 | const numerator = Math.abs(dy * point.x - dx * point.y + p2.x * p1.y - p2.y * p1.x); 115 | const denominator = Math.sqrt(dy ** 2 + dx ** 2); 116 | return numerator / denominator; 117 | } 118 | 119 | /** 120 | * Use the ray-casting algorithm to check if a point is within the boundaries of 121 | * a given list of points. 122 | * 123 | * @param {array} points - The list of points representing a certain shape. 124 | * @param {array} point - The point to check if it is inside. 125 | * @returns {boolean} true if the point is within the boundaries of points. 126 | */ 127 | export function isPointInsideShape(points, point) { 128 | let intersections = 0; 129 | 130 | // Iterate over each edge of the shape 131 | for (let i = 0, j = points.length - 1; i < points.length; j = i++) { 132 | const xi = points[i].x; 133 | const yi = points[i].y; 134 | const xj = points[j].x; 135 | const yj = points[j].y; 136 | 137 | // Check if the edge intersects with a horizontal ray from the point 138 | if ( 139 | yi > point[1] !== yj > point[1] && 140 | point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi 141 | ) { 142 | intersections++; 143 | } 144 | } 145 | 146 | // If the number of intersections is odd, the point is inside the shape 147 | return intersections % 2 === 1; 148 | } 149 | 150 | /** 151 | * Shift all the points by a certain value. 152 | * 153 | * @param {object[]} points - List of points. 154 | * @param {number} xOffset - The offset that will be added to each x value. 155 | * @param {number} [yOffset] - The offset that will be added to each y value. 156 | * @returns {object[]} New list of points. 157 | */ 158 | export function shiftPoints(points, xOffset, yOffset = null) { 159 | if (yOffset === null) { 160 | yOffset = xOffset; 161 | } 162 | 163 | return points.map((point) => ({ 164 | x: point.x + xOffset, 165 | y: point.y + yOffset, 166 | })); 167 | } 168 | 169 | /** 170 | * Shift all the points by a certain value inside a shape object. 171 | * 172 | * @param {object} shape - The shape object that contains the points. 173 | * @param {number} xOffset - The offset that will be added to each x value. 174 | * @param {number} [yOffset] - The offset that will be added to each y value. 175 | * @returns {object} New shape object. 176 | */ 177 | export function shiftShapePoints(shape, xOffset, yOffset = null) { 178 | if (yOffset === null) { 179 | yOffset = xOffset; 180 | } 181 | 182 | return { 183 | ...shape, 184 | points: shiftPoints(shape.points, xOffset, yOffset), 185 | }; 186 | } 187 | 188 | /** 189 | * Create a list of points representing a rectangle given its coordinates. 190 | * 191 | * @param {number} x1 - The top-left x-coordinate. 192 | * @param {number} y2 - The top-left y-coorindate. 193 | * @param {number} x2 - The bottom-right x-coordinate. 194 | * @param {number} y2 - The bottom-right y-coordinate. 195 | * @param {boolean} preserveAspectRatio - Whether it should always be a square. 196 | * @returns {object[]} List of points 197 | */ 198 | export function createRectangularShapePoints(x1, y1, x2, y2, preserveAspectRatio = false) { 199 | let height = Math.ceil(Math.abs(y2 - y1)); 200 | let width = Math.ceil(Math.abs(x2 - x1)); 201 | 202 | if (preserveAspectRatio) { 203 | const maxVal = Math.max(height, width); 204 | height = width = maxVal; 205 | x2 = x2 > x1 ? x1 + width : x1 - width; 206 | y2 = y2 > y1 ? y1 + height : y1 - height; 207 | } 208 | 209 | // convert the 4 sides to shapes 210 | let topBar, rightBar, bottomBar, leftBar; 211 | 212 | // top left to top right 213 | topBar = Array(width) 214 | .fill(Math.min(x1, x2)) 215 | .map((value, index) => ({ x: value + index, y: Math.min(y1, y2) })); 216 | 217 | // top right to right bottom 218 | rightBar = Array(height) 219 | .fill(Math.min(y1, y2)) 220 | .map((value, index) => ({ x: Math.max(x1, x2), y: value + index })); 221 | 222 | // right bottom to left bottom 223 | bottomBar = Array(width) 224 | .fill(Math.max(x1, x2)) 225 | .map((value, index) => ({ x: value - index, y: Math.max(y1, y2) })); 226 | 227 | // left bottom to left top 228 | leftBar = Array(height) 229 | .fill(Math.max(y1, y2)) 230 | .map((value, index) => ({ x: Math.min(x1, x2), y: value - index })); 231 | 232 | if (preserveAspectRatio) { 233 | } 234 | 235 | return removeDuplicates([...topBar, ...rightBar, ...bottomBar, ...leftBar]); 236 | } 237 | 238 | /** 239 | * Create a new rectangular selection area shape based on a list of shapes. 240 | * 241 | * @param {object[]} shapes - List of shapes. 242 | * @returns {object} Rectangular selection area shape object. 243 | */ 244 | export function createSelectionAreaAroundShapes(shapes) { 245 | let x1 = Infinity; 246 | let y1 = Infinity; 247 | let x2 = -Infinity; 248 | let y2 = -Infinity; 249 | let offset = 20; 250 | 251 | shapes.forEach((shape) => { 252 | shape.points.forEach((point) => { 253 | if (point.x < x1) x1 = point.x; 254 | if (point.x > x2) x2 = point.x; 255 | if (point.y < y1) y1 = point.y; 256 | if (point.y > y2) y2 = point.y; 257 | }); 258 | }); 259 | 260 | x1 -= offset; 261 | y1 -= offset; 262 | x2 += offset; 263 | y2 += offset; 264 | 265 | return createRectangularShapePoints(x1, y1, x2, y2); 266 | } 267 | -------------------------------------------------------------------------------- /src/components/Library/index.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import dayjs from 'dayjs'; 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | import { newFolder, newPaperInFolder } from './../../reducers/library/librarySlice'; 6 | import { setCurrentPaper } from './../../reducers/paper/paperSlice'; 7 | import { to } from './../../reducers/router/routerSlice'; 8 | import FolderListItem from './components/FolderListItem'; 9 | import PaperListItem from './components/PaperListItem'; 10 | import styles from './styles.module.css'; 11 | import { SORT_BY, VIEW_MODE } from '../../constants'; 12 | import { setSortPapersBy, setViewMode } from '../../reducers/settings/settingsSlice'; 13 | import Sortable from '../Sortable'; 14 | 15 | class Library extends React.Component { 16 | constructor(props) { 17 | super(); 18 | 19 | this.state = { 20 | currentFolderId: props.activeFolderId, 21 | sortBy: props.preferredSortBy, 22 | viewMode: props.preferredViewMode, 23 | }; 24 | } 25 | 26 | newFolder = () => { 27 | this.props.dispatch(newFolder()); 28 | }; 29 | 30 | setCurrentFolder = (folderId) => { 31 | this.setState({ 32 | currentFolderId: folderId, 33 | }); 34 | }; 35 | 36 | sortFolders = (objA, objB) => { 37 | if (objA.name < objB.name) { 38 | return -1; 39 | } 40 | 41 | if (objA.name > objB.name) { 42 | return 1; 43 | } 44 | 45 | return 0; 46 | }; 47 | 48 | renderFolders = () => { 49 | const { folders } = this.props.library; 50 | if (folders.length === 0) return null; 51 | 52 | return [...folders] 53 | .sort(this.sortFolders) 54 | .map((folder) => ( 55 | this.setCurrentFolder(folder.id)} 60 | onDelete={() => this.setCurrentFolder(null)} 61 | /> 62 | )); 63 | }; 64 | 65 | openPaper = (paperId) => { 66 | this.props.dispatch(setCurrentPaper(paperId)); 67 | this.props.dispatch( 68 | to({ 69 | name: 'paper', 70 | args: { paperId }, 71 | }), 72 | ); 73 | }; 74 | 75 | newPaperInFolder = () => { 76 | this.props.dispatch(newPaperInFolder(this.state.currentFolderId)); 77 | }; 78 | 79 | onSort = (sortBy) => { 80 | this.setState({ sortBy }); 81 | this.props.dispatch(setSortPapersBy(sortBy)); 82 | }; 83 | 84 | onChangeViewMode = (e) => { 85 | const viewMode = parseInt(e.target.value); 86 | 87 | this.setState({ viewMode }); 88 | this.props.dispatch(setViewMode(viewMode)); 89 | 90 | // We display less options in grid mode for the 'sort by' filter, so 91 | // map the missing values to existing ones. 92 | if (viewMode === VIEW_MODE.GRID) { 93 | if (this.state.sortBy === SORT_BY.CREATED_ASC) { 94 | this.onSort(SORT_BY.CREATED_DESC); 95 | } else if (this.state.sortBy === SORT_BY.LAST_MODIFIED_ASC) { 96 | this.onSort(SORT_BY.LAST_MODIFIED_DESC); 97 | } 98 | } 99 | }; 100 | 101 | renderPaperView = (papers) => { 102 | if (this.state.viewMode === VIEW_MODE.LIST) { 103 | return ( 104 | 105 | 106 | 107 | 108 | 118 | 128 | 138 | 143 | 144 | 145 | 146 | {papers.map((paper, index) => ( 147 | this.openPaper(paper.id)} 152 | viewMode={this.state.viewMode} 153 | /> 154 | ))} 155 | 156 |
# 109 | this.onSort(SORT_BY.NAME_AZ)} 113 | onSortDesc={() => this.onSort(SORT_BY.NAME_ZA)} 114 | > 115 | Name 116 | 117 | 119 | this.onSort(SORT_BY.LAST_MODIFIED_ASC)} 123 | onSortDesc={() => this.onSort(SORT_BY.LAST_MODIFIED_DESC)} 124 | > 125 | Last modified 126 | 127 | 129 | this.onSort(SORT_BY.CREATED_ASC)} 133 | onSortDesc={() => this.onSort(SORT_BY.CREATED_DESC)} 134 | > 135 | Created 136 | 137 | 139 | 142 |
157 | ); 158 | } 159 | 160 | // Render by default the grid view. 161 | return ( 162 |
163 |
164 |
165 |
169 | new paper 170 |
171 |
172 |
173 | 174 | {papers.map((paper) => ( 175 |
176 | this.openPaper(paper.id)} 179 | viewMode={this.state.viewMode} 180 | /> 181 |
182 | ))} 183 |
184 | ); 185 | }; 186 | 187 | renderPapers = () => { 188 | const folder = this.props.library.folders.find( 189 | (folder) => folder.id === this.state.currentFolderId, 190 | ); 191 | if (!folder) return null; 192 | 193 | let papers = this.props.library.papers.filter( 194 | (paper) => paper.folderId === this.state.currentFolderId, 195 | ); 196 | 197 | switch (this.state.sortBy) { 198 | case SORT_BY.NAME_AZ: 199 | papers = papers.sort((a, b) => { 200 | const nameA = a.name.toLowerCase(); 201 | const nameB = b.name.toLowerCase(); 202 | if (nameA < nameB) { 203 | return -1; 204 | } else if (nameA > nameB) { 205 | return 1; 206 | } else { 207 | return 0; 208 | } 209 | }); 210 | break; 211 | 212 | case SORT_BY.NAME_ZA: 213 | papers = papers.sort((a, b) => { 214 | const nameA = a.name.toLowerCase(); 215 | const nameB = b.name.toLowerCase(); 216 | if (nameA < nameB) { 217 | return 1; 218 | } else if (nameA > nameB) { 219 | return -1; 220 | } 221 | 222 | return 0; 223 | }); 224 | break; 225 | 226 | case SORT_BY.CREATED_ASC: 227 | papers = papers.sort((a, b) => { 228 | if (dayjs(a.createdAt).isBefore(dayjs(b.createdAt))) { 229 | return -1; 230 | } else if (dayjs(a.createdAt).isAfter(dayjs(b.createdAt))) { 231 | return 1; 232 | } 233 | 234 | return 0; 235 | }); 236 | break; 237 | 238 | case SORT_BY.CREATED_DESC: 239 | papers = papers.sort((a, b) => { 240 | if (dayjs(a.createdAt).isBefore(dayjs(b.createdAt))) { 241 | return 1; 242 | } else if (dayjs(a.createdAt).isAfter(dayjs(b.createdAt))) { 243 | return -1; 244 | } 245 | 246 | return 0; 247 | }); 248 | break; 249 | 250 | case SORT_BY.LAST_MODIFIED_ASC: 251 | papers = papers.sort((a, b) => { 252 | if (dayjs(a.updatedAt).isBefore(dayjs(b.updatedAt))) { 253 | return -1; 254 | } else if (dayjs(a.updatedAt).isAfter(dayjs(b.updatedAt))) { 255 | return 1; 256 | } 257 | 258 | return 0; 259 | }); 260 | break; 261 | 262 | case SORT_BY.LAST_MODIFIED_DESC: 263 | default: 264 | papers = papers.sort((a, b) => { 265 | if (dayjs(a.updatedAt).isBefore(dayjs(b.updatedAt))) { 266 | return 1; 267 | } else if (dayjs(a.updatedAt).isAfter(dayjs(b.updatedAt))) { 268 | return -1; 269 | } 270 | 271 | return 0; 272 | }); 273 | break; 274 | } 275 | 276 | return ( 277 |
278 |
279 |

280 | {folder.name} 281 |

282 |
283 | {this.state.viewMode === VIEW_MODE.GRID && ( 284 |
285 | 286 | 297 |
298 | )} 299 | 300 |
301 | 302 | 311 |
312 |
313 |
314 | {this.renderPaperView(papers)} 315 |
316 | ); 317 | }; 318 | 319 | renderVersion = () => { 320 | if (!this.props.appVersion) return null; 321 | 322 | return
Pointless v{this.props.appVersion}
; 323 | }; 324 | 325 | render() { 326 | return ( 327 |
328 |
329 |
330 |

My folders

331 | 334 |
335 |
{this.renderFolders()}
336 | {this.renderVersion()} 337 |
338 | 339 |
344 | {this.state.currentFolderId ? ( 345 | this.renderPapers() 346 | ) : ( 347 | <> 348 |

Nothing to see here

349 | Create or select a folder on the left side 350 | 351 | )} 352 |
353 |
354 | ); 355 | } 356 | } 357 | 358 | function mapStateToProps(state) { 359 | return { 360 | library: state.library, 361 | activeFolderId: state.router.current.args.activeFolderId, 362 | appVersion: state.settings.appVersion, 363 | preferredSortBy: state.settings.sortPapersBy, 364 | preferredViewMode: state.settings.viewMode, 365 | }; 366 | } 367 | 368 | export default connect(mapStateToProps)(Library); 369 | -------------------------------------------------------------------------------- /src/components/Paper/components/Toolbar/index.js: -------------------------------------------------------------------------------- 1 | import styles from './styles.module.css'; 2 | import classNames from 'classnames'; 3 | import { LINEWIDTH, MODE } from './../../constants'; 4 | 5 | import { ReactComponent as PanIcon } from './../../../../assets/icons/move.svg'; 6 | import { ReactComponent as EraserIcon } from './../../../../assets/icons/eraser.svg'; 7 | import { ReactComponent as SelectIcon } from './../../../../assets/icons/select.svg'; 8 | import { ReactComponent as MaximizeIcon } from './../../../../assets/icons/maximize.svg'; 9 | import { ReactComponent as ZoomInIcon } from './../../../../assets/icons/zoom-in.svg'; 10 | import { ReactComponent as ZoomOutIcon } from './../../../../assets/icons/zoom-out.svg'; 11 | import { ReactComponent as UndoIcon } from './../../../../assets/icons/undo.svg'; 12 | import { ReactComponent as RedoIcon } from './../../../../assets/icons/redo.svg'; 13 | import { ReactComponent as HomeIcon } from './../../../../assets/icons/home.svg'; 14 | import { ReactComponent as TrashcanIcon } from './../../../../assets/icons/trashcan.svg'; 15 | import { ReactComponent as FreehandToolIcon } from './../../../../assets/icons/tool-freehand.svg'; 16 | import { ReactComponent as EllipseToolIcon } from './../../../../assets/icons/tool-ellipse.svg'; 17 | import { ReactComponent as RectangleToolIcon } from './../../../../assets/icons/tool-rectangle.svg'; 18 | import { ReactComponent as ArrowToolIcon } from './../../../../assets/icons/tool-arrow.svg'; 19 | import Tooltip from 'rc-tooltip'; 20 | import { useSelector } from 'react-redux'; 21 | import { memo } from 'react'; 22 | import { ctrlOrMetaChar } from '../../../../helpers'; 23 | 24 | function Toolbar(props) { 25 | const platform = useSelector((state) => state.settings.platform); 26 | const ctrlOrMeta = ctrlOrMetaChar(platform); 27 | 28 | return ( 29 | <> 30 |
31 | 32 |
props.onLinewidthChange(LINEWIDTH.SMALL)} 34 | className={classNames( 35 | styles['toolbar__item'], 36 | styles['toolbar__item__linewidth'], 37 | styles['toolbar__item__linewidth-small'], 38 | { 39 | [styles['toolbar__item-disabled']]: !props.isDrawMode, 40 | [styles['toolbar__item-active']]: props.linewidth === LINEWIDTH.SMALL, 41 | }, 42 | )} 43 | /> 44 | 45 | 46 |
props.onLinewidthChange(LINEWIDTH.MEDIUM)} 48 | className={classNames( 49 | styles['toolbar__item'], 50 | styles['toolbar__item__linewidth'], 51 | styles['toolbar__item__linewidth-medium'], 52 | { 53 | [styles['toolbar__item-disabled']]: !props.isDrawMode, 54 | [styles['toolbar__item-active']]: props.linewidth === LINEWIDTH.MEDIUM, 55 | }, 56 | )} 57 | /> 58 | 59 | 60 |
props.onLinewidthChange(LINEWIDTH.LARGE)} 62 | className={classNames( 63 | styles['toolbar__item'], 64 | styles['toolbar__item__linewidth'], 65 | styles['toolbar__item__linewidth-large'], 66 | { 67 | [styles['toolbar__item-disabled']]: !props.isDrawMode, 68 | [styles['toolbar__item-active']]: props.linewidth === LINEWIDTH.LARGE, 69 | }, 70 | )} 71 | /> 72 | 73 |
74 | 78 | freehand 79 |
80 | f 81 |
82 | 83 | } 84 | > 85 |
91 | 92 |
93 |
94 | 98 | ellipse 99 |
100 | e 101 |
102 | 103 | } 104 | > 105 |
111 | 112 |
113 |
114 | 118 | rectangle 119 |
120 | r 121 |
122 | 123 | } 124 | > 125 |
131 | 132 |
133 |
134 | 138 | arrow 139 |
140 | a 141 |
142 | 143 | } 144 | > 145 |
151 | 152 |
153 |
154 |
155 | 159 | eraser 160 |
161 | {ctrlOrMeta} + e 162 |
163 | 164 | } 165 | > 166 |
172 | 173 |
174 |
175 | 179 | selection 180 |
181 | {ctrlOrMeta} + s 182 |
183 | 184 | } 185 | > 186 |
192 | 193 |
194 |
195 | 199 | toggle pan mode 200 |
201 | space 202 |
203 | 204 | } 205 | > 206 |
213 | 214 |
215 |
216 | 217 |
223 | 224 |
225 |
226 |
227 | 231 | undo 232 |
233 | {ctrlOrMeta} + z 234 |
235 | 236 | } 237 | > 238 |
244 | 245 |
246 |
247 | 251 | redo 252 |
253 | {ctrlOrMeta} + shift + z 254 |
255 | 256 | } 257 | > 258 |
264 | 265 |
266 |
267 |
268 |
269 | 273 | reset zoom level 274 |
275 | {ctrlOrMeta} + 0 276 |
277 | 278 | } 279 | > 280 |
287 | 288 |
289 |
290 | 294 | zoom in 295 |
296 | + 297 |
298 | 299 | } 300 | > 301 |
307 | 308 |
309 |
310 | 314 | zoom out 315 |
316 | - 317 |
318 | 319 | } 320 | > 321 |
327 | 328 |
329 |
330 | 334 | clear canvas 335 |
336 | {ctrlOrMeta} + x 337 |
338 | 339 | } 340 | > 341 |
351 | 352 |
353 |
354 |
355 | 356 | ); 357 | } 358 | 359 | export default memo(Toolbar); 360 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | Pointless - Endless drawing canvas desktop app. 635 | Copyright (C) 2022 Kim Koomen 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Pointless Copyright (C) 2022 Kim Koomen 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------