├── 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 |
7 | {children}
8 |
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 |
24 | {label}
25 |
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 | You need to enable JavaScript to run this app.
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 |
60 |
61 |
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 | 
2 | 
3 |
4 |
5 |
6 |
7 |
8 |
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 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | // Return grid view by default
72 | return (
73 |
79 | {props.viewMode === VIEW_MODE.GRID &&
}
80 |
81 |
82 |
83 |
87 |
88 |
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 |
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 | dark
150 | light
151 |
152 |
153 |
154 | )}
155 |
156 |
Type
157 |
158 |
159 | jpeg
160 | png
161 | svg
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 | Key sequence
26 | Description
27 |
28 |
29 |
30 |
31 |
32 |
33 | f
34 |
35 |
36 | Freehand drawing
37 |
38 |
39 |
40 |
41 | e
42 |
43 |
44 | Draw ellipse
45 |
46 |
47 |
48 |
49 | r
50 |
51 |
52 | Draw rectangle
53 |
54 |
55 |
56 |
57 | a
58 |
59 |
60 | Draw arrow
61 |
62 |
63 |
64 |
65 | Spacebar
66 |
67 |
68 | Pan around (hold spacebar)
69 |
70 |
71 |
72 |
73 | {ctrlOrMeta} + e
74 |
75 |
76 | Eraser
77 |
78 |
79 |
80 |
81 | {ctrlOrMeta} + x
82 |
83 |
84 | Clear canvas
85 |
86 |
87 |
88 |
89 | {ctrlOrMeta} + z
90 |
91 |
92 | Undo
93 |
94 |
95 |
96 |
97 | {ctrlOrMeta} + shift + z
98 |
99 |
100 | Redo
101 |
102 |
103 |
104 |
105 | {ctrlOrMeta} + +
106 |
107 |
108 | Zoom in
109 |
110 |
111 |
112 |
113 | {ctrlOrMeta} + -
114 |
115 |
116 | Zoom out
117 |
118 |
119 |
120 |
121 | {ctrlOrMeta} + 0
122 |
123 |
124 | Reset zoom level
125 |
126 |
127 |
128 |
129 | ]
130 |
131 |
132 | Increase eraser size
133 |
134 |
135 |
136 |
137 | [
138 |
139 |
140 | Decrease eraser size
141 |
142 |
143 |
144 |
145 | {ctrlOrMeta} + c
146 |
147 |
148 | Copy selected shape
149 |
150 |
151 |
152 |
153 | {ctrlOrMeta} + v
154 |
155 |
156 | Paste selected shape
157 |
158 |
159 |
160 |
161 | Delete or Backspace
162 |
163 |
164 | Delete selected shape
165 |
166 |
167 |
168 |
169 | Shift
170 |
171 |
172 | Preserve aspect ratio while drawing shapes
173 |
174 |
175 |
176 |
177 | {ctrlOrMeta} + q
178 |
179 |
180 | Quit application
181 |
182 |
183 |
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 |
109 | this.onSort(SORT_BY.NAME_AZ)}
113 | onSortDesc={() => this.onSort(SORT_BY.NAME_ZA)}
114 | >
115 | Name
116 |
117 |
118 |
119 | this.onSort(SORT_BY.LAST_MODIFIED_ASC)}
123 | onSortDesc={() => this.onSort(SORT_BY.LAST_MODIFIED_DESC)}
124 | >
125 | Last modified
126 |
127 |
128 |
129 | this.onSort(SORT_BY.CREATED_ASC)}
133 | onSortDesc={() => this.onSort(SORT_BY.CREATED_DESC)}
134 | >
135 | Created
136 |
137 |
138 |
139 |
140 | new paper
141 |
142 |
143 |
144 |
145 |
146 | {papers.map((paper, index) => (
147 | this.openPaper(paper.id)}
152 | viewMode={this.state.viewMode}
153 | />
154 | ))}
155 |
156 |
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 | sort by
286 | this.onSort(parseInt(e.target.value))}
290 | value={this.state.sortBy}
291 | >
292 | Name A-Z
293 | Name Z-A
294 | Last modified
295 | Created
296 |
297 |
298 | )}
299 |
300 |
301 | view mode
302 |
308 | Grid
309 | List
310 |
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 |
332 | new folder
333 |
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 |
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 |
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 |
--------------------------------------------------------------------------------