├── .eslintrc.yml ├── .github └── workflows │ ├── dev.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierrc.yml ├── LICENSE ├── README.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── public └── vite.svg ├── renovate.json ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── icons │ ├── 1024x1024.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 512x512.png │ ├── 64x64.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ └── main.rs └── tauri.conf.json ├── src ├── App.tsx ├── assets │ ├── font │ │ └── novel-regular.woff2 │ └── react.svg ├── components │ ├── Editor │ │ └── index.tsx │ ├── Fallback │ │ └── index.tsx │ ├── Header │ │ └── index.tsx │ ├── Infobar │ │ └── index.tsx │ ├── Setting │ │ └── index.tsx │ ├── Svg │ │ └── index.tsx │ └── TextModal │ │ └── index.tsx ├── consts │ └── index.ts ├── hooks │ ├── keybind.ts │ └── tauri.ts ├── index.css ├── lib │ ├── clipboard.ts │ ├── command.ts │ ├── config.ts │ ├── fs.ts │ ├── selection.ts │ └── suspense.ts ├── main.tsx ├── plugins │ ├── AutoFocusPlugin.tsx │ ├── AutoHorizontalScrollPlugin.tsx │ ├── InitPlugin.tsx │ └── TreeViewPlugin.tsx ├── store │ └── index.ts └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: 5 | - eslint:recommended 6 | - plugin:react/recommended 7 | - plugin:@typescript-eslint/recommended 8 | - prettier 9 | parser: "@typescript-eslint/parser" 10 | parserOptions: 11 | ecmaFeatures: 12 | jsx: true 13 | ecmaVersion: latest 14 | sourceType: module 15 | plugins: 16 | - react 17 | - "@typescript-eslint" 18 | - import 19 | rules: 20 | "import/order": 21 | - warn 22 | - groups: 23 | - builtin 24 | - external 25 | - internal 26 | - parent 27 | - sibling 28 | - index 29 | - object 30 | - type 31 | pathGroups: 32 | - pattern: "{tauri,@tauri-apps/**}" 33 | group: builtin 34 | position: after 35 | - pattern: "{react,react-dom/**,react-router-dom}" 36 | group: builtin 37 | position: after 38 | - pattern: "{daisyui,react-daisyui}" 39 | group: builtin 40 | position: after 41 | - pattern: "{lexical,@lexical/**}" 42 | group: builtin 43 | position: after 44 | - pattern: "{recoil}" 45 | group: builtin 46 | position: after 47 | pathGroupsExcludedImportTypes: 48 | - builtin 49 | alphabetize: 50 | order: asc 51 | newlines-between: always 52 | "@typescript-eslint/consistent-type-imports": 53 | - warn 54 | - prefer: type-imports 55 | react/jsx-uses-react: "off" 56 | react/react-in-jsx-scope: "off" 57 | settings: 58 | react: 59 | version: detect 60 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: "test-on-push" 2 | on: 3 | push: 4 | branches: 5 | - dev 6 | 7 | env: 8 | node-version: 18 9 | pnpm-version: latest 10 | 11 | jobs: 12 | test-tauri: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | platform: [macos-latest, ubuntu-22.04, windows-latest] 17 | 18 | runs-on: ${{ matrix.platform }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Setup mold linker 22 | uses: rui314/setup-mold@v1 23 | - name: Setup node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ env.node-version }} 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v4.0.0 29 | with: 30 | version: ${{ env.pnpm-version }} 31 | run_install: false 32 | - name: Get pnpm store directory 33 | id: pnpm-cache 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 37 | - uses: actions/cache@v4 38 | name: Setup pnpm cache 39 | with: 40 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 41 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 42 | restore-keys: | 43 | ${{ runner.os }}-pnpm-store- 44 | - name: Install node dependencies 45 | run: pnpm install 46 | - name: Install Rust stable 47 | uses: actions-rs/toolchain@v1 48 | with: 49 | profile: minimal 50 | toolchain: stable 51 | - uses: Swatinem/rust-cache@v2 52 | with: 53 | workspaces: "src-tauri -> target" 54 | cache-on-failure: true 55 | - name: Install dependencies (ubuntu only) 56 | if: matrix.platform == 'ubuntu-22.04' 57 | run: | 58 | sudo apt update 59 | sudo apt install -y build-essential curl wget libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev patchelf 60 | - name: Install app dependencies and build it 61 | run: pnpm build 62 | - uses: tauri-apps/tauri-action@v0 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "publish" 2 | on: 3 | push: 4 | branches: 5 | - release 6 | 7 | env: 8 | node-version: 18 9 | pnpm-version: latest 10 | 11 | jobs: 12 | test-tauri: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | platform: [macos-latest, ubuntu-22.04, windows-latest] 17 | 18 | runs-on: ${{ matrix.platform }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Setup mold linker 22 | uses: rui314/setup-mold@v1 23 | - name: Setup node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ env.node-version }} 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v4.0.0 29 | with: 30 | version: ${{ env.pnpm-version }} 31 | run_install: false 32 | - name: Get pnpm store directory 33 | id: pnpm-cache 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 37 | - uses: actions/cache@v4 38 | name: Setup pnpm cache 39 | with: 40 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 41 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 42 | restore-keys: | 43 | ${{ runner.os }}-pnpm-store- 44 | - name: Install node dependencies 45 | run: pnpm install 46 | - name: Install Rust stable 47 | uses: actions-rs/toolchain@v1 48 | with: 49 | profile: minimal 50 | toolchain: stable 51 | - uses: Swatinem/rust-cache@v2 52 | with: 53 | workspaces: "src-tauri -> target" 54 | cache-on-failure: true 55 | - name: Install dependencies (ubuntu only) 56 | if: matrix.platform == 'ubuntu-22.04' 57 | run: | 58 | sudo apt update 59 | sudo apt install -y build-essential curl wget libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev patchelf 60 | - name: Install app dependencies and build it 61 | run: pnpm build 62 | - uses: tauri-apps/tauri-action@v0 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version 67 | releaseName: "hotate v__VERSION__" 68 | releaseBody: "See the assets to download this version and install." 69 | releaseDraft: true 70 | prerelease: true 71 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "test-on-pr" 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | node-version: 18 10 | pnpm-version: latest 11 | 12 | jobs: 13 | test-tauri: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | platform: [macos-latest, ubuntu-22.04, windows-latest] 18 | 19 | runs-on: ${{ matrix.platform }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Setup mold linker 23 | uses: rui314/setup-mold@v1 24 | - name: Setup node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ env.node-version }} 28 | - name: Install pnpm 29 | uses: pnpm/action-setup@v4.0.0 30 | with: 31 | version: ${{ env.pnpm-version }} 32 | run_install: false 33 | - name: Get pnpm store directory 34 | id: pnpm-cache 35 | shell: bash 36 | run: | 37 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 38 | - uses: actions/cache@v4 39 | name: Setup pnpm cache 40 | with: 41 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 42 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpm-store- 45 | - name: Install node dependencies 46 | run: pnpm install 47 | - name: Install Rust stable 48 | uses: actions-rs/toolchain@v1 49 | with: 50 | profile: minimal 51 | toolchain: stable 52 | - uses: Swatinem/rust-cache@v2 53 | with: 54 | workspaces: "src-tauri -> target" 55 | cache-on-failure: true 56 | - name: Install dependencies (ubuntu only) 57 | if: matrix.platform == 'ubuntu-22.04' 58 | run: | 59 | sudo apt update 60 | sudo apt install -y build-essential curl wget libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev patchelf 61 | - name: Install app dependencies and build it 62 | run: pnpm build 63 | - uses: tauri-apps/tauri-action@v0 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - prettier-plugin-tailwindcss 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 haxibami 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hotate 2 | 3 | hotate(ほたて)は、Linux / MacOS / Windows で動作する縦書きエディタです。 4 | 5 | ![image](https://user-images.githubusercontent.com/57034105/186642350-91de5782-c506-412a-8e37-cfb1a4c1fad9.png) 6 | ![image_dark](https://user-images.githubusercontent.com/57034105/186642057-d5049f1d-523e-405f-89ae-c698497b613c.png) 7 | 8 | ## 開発 9 | 10 | ```sh 11 | pnpm install 12 | pnpm tauri dev 13 | ``` 14 | 15 | ## ビルド 16 | 17 | ```sh 18 | pnpm tauri build 19 | ``` 20 | 21 | `src-tauri/target/release`以下にバイナリ・パッケージ等が出力されます。 22 | 23 | ## 実装する可能性がある機能 24 | 25 | - ファイル自動保存 26 | - 検索 27 | - カスタムテーマ 28 | - テキスト整形(行頭空白挿入など) 29 | - (ルビ記法のプレビュー表示) 30 | 31 | ## 実装しない機能 32 | 33 | - `.txt` ファイル以外の編集 34 | - (ごく簡易的なものを除く)印刷・フォーマット変換 35 | - 組版機能 36 | - その他単純さを損ねる様々なもの 37 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Hotate 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hotate", 3 | "private": true, 4 | "version": "0.1.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri", 11 | "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", 12 | "lint-fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'", 13 | "format": "prettier --check 'src/**/*.{js,jsx,ts,tsx}'", 14 | "format-fix": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'" 15 | }, 16 | "engines": { 17 | "node": ">=16.0.0" 18 | }, 19 | "dependencies": { 20 | "@lexical/react": "0.12.4", 21 | "@lexical/selection": "0.12.4", 22 | "@lexical/utils": "0.12.4", 23 | "@tauri-apps/api": "1.5.1", 24 | "lexical": "0.12.4", 25 | "react": "18.2.0", 26 | "react-dom": "18.2.0", 27 | "recoil": "0.7.7", 28 | "tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1" 29 | }, 30 | "devDependencies": { 31 | "@tailwindcss/typography": "0.5.16", 32 | "@tauri-apps/cli": "1.6.3", 33 | "@types/node": "20.17.57", 34 | "@types/react": "18.3.23", 35 | "@types/react-dom": "18.3.7", 36 | "@typescript-eslint/eslint-plugin": "8.33.0", 37 | "@typescript-eslint/parser": "8.33.0", 38 | "@vitejs/plugin-react": "4.5.0", 39 | "autoprefixer": "10.4.21", 40 | "daisyui": "4.12.24", 41 | "eslint": "9.28.0", 42 | "eslint-config-prettier": "9.1.0", 43 | "eslint-plugin-import": "2.31.0", 44 | "eslint-plugin-react": "7.37.5", 45 | "postcss": "8.5.4", 46 | "prettier": "3.5.3", 47 | "prettier-plugin-tailwindcss": "0.6.12", 48 | "react-daisyui": "5.0.5", 49 | "tailwindcss": "3.4.17", 50 | "typescript": "5.8.3", 51 | "vite": "5.4.19" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "tailwindcss/nesting": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", ":timezone(Asia/Tokyo)"], 4 | "schedule": ["after 9am on monday", "before 12am on monday"], 5 | "dependencyDashboard": true, 6 | "ignoreDeps": [], 7 | "packageRules": [ 8 | { 9 | "groupName": "tauri-backend", 10 | "matchFileNames": ["src-tauri/**"], 11 | "groupSlug": "allTauriBackend", 12 | "automerge": false 13 | }, 14 | { 15 | "groupName": "tauri", 16 | "automerge": false, 17 | "matchPackageNames": ["/^@tauri-apps/", "/^tauri-plugin/"] 18 | }, 19 | { 20 | "groupName": "react", 21 | "automerge": false, 22 | "matchPackageNames": [ 23 | "/react/", 24 | "/react-dom/", 25 | "/@types/react/", 26 | "/@types/react-dom/" 27 | ] 28 | }, 29 | { 30 | "groupName": "lexical", 31 | "automerge": false, 32 | "matchPackageNames": ["/^lexical/", "/^@lexical/"] 33 | }, 34 | { 35 | "groupName": "devDependencies", 36 | "matchDepTypes": ["devDependencies"], 37 | "automerge": true 38 | }, 39 | { 40 | "groupName": "dependencies (minor update)", 41 | "matchDepTypes": ["dependencies"], 42 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 43 | "automerge": true 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hotate" 3 | version = "0.1.1" 4 | description = "A vertical-writing editor" 5 | authors = ["haxibami"] 6 | license = "MIT" 7 | repository = "https://github.com/haxibami/hotate" 8 | default-run = "hotate" 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.5.1", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "1.5.2", features = ["api-all"] } 21 | 22 | [dependencies.tauri-plugin-store] 23 | git = "https://github.com/tauri-apps/plugins-workspace" 24 | branch = "v1" 25 | 26 | [features] 27 | # by default Tauri runs in production mode 28 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 29 | default = ["custom-protocol"] 30 | # this feature is used used for production builds where `devPath` points to the filesystem 31 | # DO NOT remove this 32 | custom-protocol = ["tauri/custom-protocol"] 33 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/1024x1024.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/256x256.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/512x512.png -------------------------------------------------------------------------------- /src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | fn main() { 7 | tauri::Builder::default() 8 | .plugin(tauri_plugin_store::Builder::default().build()) 9 | .run(tauri::generate_context!()) 10 | .expect("error while running tauri application"); 11 | } 12 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "pnpm build", 5 | "beforeDevCommand": "pnpm dev", 6 | "devPath": "http://localhost:5173", 7 | "distDir": "../dist" 8 | }, 9 | "package": { 10 | "productName": "hotate", 11 | "version": "0.1.1" 12 | }, 13 | "tauri": { 14 | "allowlist": { 15 | "all": true, 16 | "fs": { 17 | "scope": [ 18 | "**" 19 | ] 20 | } 21 | }, 22 | "bundle": { 23 | "active": true, 24 | "category": "Productivity", 25 | "copyright": "Copyright © 2022 haxibami (haxibami.net)", 26 | "deb": { 27 | "depends": [] 28 | }, 29 | "externalBin": [], 30 | "icon": [ 31 | "icons/32x32.png", 32 | "icons/128x128.png", 33 | "icons/128x128@2x.png", 34 | "icons/icon.icns", 35 | "icons/icon.ico" 36 | ], 37 | "identifier": "net.haxibami.hotate", 38 | "longDescription": "A vertical-writing editor", 39 | "macOS": { 40 | "entitlements": null, 41 | "exceptionDomain": "", 42 | "frameworks": [], 43 | "providerShortName": null, 44 | "signingIdentity": null 45 | }, 46 | "resources": [], 47 | "shortDescription": "", 48 | "targets": "all", 49 | "windows": { 50 | "certificateThumbprint": null, 51 | "digestAlgorithm": "sha256", 52 | "timestampUrl": "" 53 | } 54 | }, 55 | "security": { 56 | "csp": null 57 | }, 58 | "updater": { 59 | "active": false 60 | }, 61 | "windows": [ 62 | { 63 | "fullscreen": false, 64 | "resizable": true, 65 | "title": "Hotate" 66 | } 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { readTextFile } from "@tauri-apps/api/fs"; 2 | 3 | import { Drawer } from "react-daisyui"; 4 | 5 | import { LexicalComposer } from "@lexical/react/LexicalComposer"; 6 | 7 | import { useRecoilState, useRecoilValue } from "recoil"; 8 | 9 | import Editor from "./components/Editor"; 10 | import Setting from "./components/Setting"; 11 | import { $setTextContent } from "./lib/fs"; 12 | import { useData } from "./lib/suspense"; 13 | import { isDrawerOpenState, targetFileState } from "./store"; 14 | 15 | function App() { 16 | const [isDrawerOpen, setIsDrawerOpen] = useRecoilState(isDrawerOpenState); 17 | const targetFile = useRecoilValue(targetFileState); 18 | 19 | const onError = (error: Error) => { 20 | console.error(error); 21 | }; 22 | 23 | const loadContent = () => { 24 | if (targetFile !== "") { 25 | return readTextFile(targetFile); 26 | } else { 27 | return Promise.resolve(""); 28 | } 29 | }; 30 | 31 | const text = useData(targetFile, loadContent); 32 | 33 | return ( 34 |
35 | $setTextContent(text), 40 | }} 41 | > 42 | } 44 | open={isDrawerOpen} 45 | onClickOverlay={() => setIsDrawerOpen(!isDrawerOpen)} 46 | > 47 |
48 | 49 | {/* 50 | TODO: fix modal related issue 51 | */} 52 |
53 |
54 |
55 |
56 | ); 57 | } 58 | 59 | export default App; 60 | -------------------------------------------------------------------------------- /src/assets/font/novel-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haxibami/hotate/0f6d947dc4b2230366887d28a87c09e34eccf044/src/assets/font/novel-regular.woff2 -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Editor/index.tsx: -------------------------------------------------------------------------------- 1 | import type { WheelEvent } from "react"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 5 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; 6 | import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; 7 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; 8 | import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; 9 | import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; 10 | // import { TreeView } from "@lexical/react/LexicalTreeView"; 11 | import { $getRoot } from "lexical"; 12 | 13 | import { useRecoilValue, useSetRecoilState } from "recoil"; 14 | 15 | import Header from "../../components/Header"; 16 | import Infobar from "../../components/Infobar"; 17 | import { PLACEHOLDER_CONTENT } from "../../consts"; 18 | import AutoFocusPlugin from "../../plugins/AutoFocusPlugin"; 19 | import { AutoHorizontalScrollPlugin } from "../../plugins/AutoHorizontalScrollPlugin"; 20 | import InitPlugin from "../../plugins/InitPlugin"; 21 | import { 22 | lineWordsState, 23 | isLineNumOnState, 24 | lineHeightState, 25 | fontSizeState, 26 | textLengthState, 27 | // targetFileState, 28 | // isEditableState, 29 | } from "../../store"; 30 | 31 | const Editor = () => { 32 | const containerRef = useRef(null); 33 | 34 | const handleWheel = (e: WheelEvent) => { 35 | if (containerRef.current) { 36 | containerRef.current.scrollBy({ 37 | top: 0, 38 | left: -e.deltaY, 39 | behavior: "smooth", 40 | }); 41 | } 42 | }; 43 | 44 | const lw = useRecoilValue(lineWordsState); 45 | 46 | const [editor] = useLexicalComposerContext(); 47 | // const [targetFile] = useRecoilValue(targetFileState); 48 | 49 | const isLineNumOn = useRecoilValue(isLineNumOnState); 50 | const lineHeight = useRecoilValue(lineHeightState); 51 | const fontSize = useRecoilValue(fontSizeState); 52 | // const isEditable = useRecoilValue(isEditableState); 53 | const setTextLength = useSetRecoilState(textLengthState); 54 | 55 | useEffect(() => { 56 | if (containerRef.current) { 57 | containerRef.current.setAttribute( 58 | "style", 59 | ` 60 | height: calc(${lw}em + 7rem); 61 | line-height: ${lineHeight}; 62 | font-size: ${fontSize}rem; 63 | ` 64 | ); 65 | } 66 | }, [lw, lineHeight, fontSize]); 67 | 68 | const onChange = () => { 69 | setTextLength( 70 | editor 71 | .getEditorState() 72 | .read(() => $getRoot().getTextContent().replace(/\n/g, "").length) 73 | ); 74 | }; 75 | 76 | return ( 77 |
81 |
82 |
83 |
88 | 94 | } 95 | placeholder={ 96 |
97 | {PLACEHOLDER_CONTENT} 98 |
99 | } 100 | ErrorBoundary={LexicalErrorBoundary} 101 | /> 102 |
103 | 104 | 105 | 106 | 107 | 108 | {/* for debug */} 109 | {/**/} 117 |
118 | 119 |
120 | ); 121 | }; 122 | 123 | export default Editor; 124 | -------------------------------------------------------------------------------- /src/components/Fallback/index.tsx: -------------------------------------------------------------------------------- 1 | const Fallback = () => { 2 | return ( 3 |
4 |
5 |
6 | ); 7 | }; 8 | 9 | export default Fallback; 10 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { getName, getVersion, getTauriVersion } from "@tauri-apps/api/app"; 2 | import { message } from "@tauri-apps/api/dialog"; 3 | import { arch, platform } from "@tauri-apps/api/os"; 4 | import { appWindow } from "@tauri-apps/api/window"; 5 | // import { open } from "@tauri-apps/api/shell"; 6 | 7 | import { useState } from "react"; 8 | import type { FC } from "react"; 9 | 10 | import { 11 | Button, 12 | Navbar, 13 | Table, 14 | Dropdown, 15 | Swap, 16 | Kbd, 17 | WindowMockup, 18 | } from "react-daisyui"; 19 | 20 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 21 | import { UNDO_COMMAND, REDO_COMMAND } from "lexical"; 22 | 23 | import { useRecoilState } from "recoil"; 24 | 25 | import * as Svg from "../../components/Svg"; 26 | import TextModal from "../../components/TextModal"; 27 | import { PLACEHOLDER_TITLE } from "../../consts"; 28 | import { askandSave, askAndOpen, silentSave } from "../../lib/fs"; 29 | import { 30 | isDrawerOpenState, 31 | targetFileState, 32 | // isEditableState, 33 | } from "../../store"; 34 | 35 | const Header: FC = () => { 36 | const [editor] = useLexicalComposerContext(); 37 | const [isDrawerOpen, setIsDrawerOpen] = useRecoilState(isDrawerOpenState); 38 | // const [isEditable, setIsEditable] = useRecoilState(isEditableState); 39 | const [targetFile, setTargetFile] = useRecoilState(targetFileState); 40 | const [isHelpOpen, setIsHelpOpen] = useState(false); 41 | 42 | const handleEditable = () => { 43 | editor.isEditable() ? editor.setEditable(false) : editor.setEditable(true); 44 | // setIsEditable(!isEditable); 45 | }; 46 | 47 | const handleOpen = async () => { 48 | const openedName = await askAndOpen(editor); 49 | if (openedName) { 50 | setTargetFile(openedName); 51 | } 52 | }; 53 | 54 | // TODO: show saved indicator 55 | const handleSave = async (ask: boolean) => { 56 | if (ask) { 57 | // 保存 58 | const fileName = await askandSave(editor); 59 | setTargetFile(fileName); 60 | } else { 61 | // 上書き保存 62 | if (targetFile !== "") { 63 | await silentSave(editor, targetFile); 64 | } else { 65 | const savedName = await askandSave(editor); 66 | setTargetFile(savedName); 67 | } 68 | } 69 | }; 70 | 71 | return ( 72 |
75 | 79 |

ツールバー

80 |

81 | ウィンドウ上部にカーソルをホバーすると出現します。下のサンプルでそれぞれの機能を確認できます。 82 |

83 |
84 | 89 |
90 | 91 |
92 |
96 | 99 |
100 |
104 | 107 |
108 |
112 | 115 |
116 |
120 | 124 | } 125 | onElement={ 126 | 127 | } 128 | className="h-6 w-6" 129 | /> 130 |
131 |
132 |
136 | file.txt 137 |
138 |
139 |
140 | 143 |
144 |
145 |
146 |
147 |
148 |
149 |

キーバインド

150 |
151 | 152 | 153 | キー 154 | 機能 155 | 156 | 157 | 158 | 159 | Ctrl + S 160 | 161 | 保存 162 | 163 | 164 |
165 |
166 |
167 | } 168 | open={isHelpOpen} 169 | onClick={() => setIsHelpOpen(!isHelpOpen)} 170 | /> 171 | 172 | 173 |
174 |
175 | 176 | 177 | 178 | 179 | 180 | handleOpen()}> 181 | 182 | 開く 183 | 184 | handleSave(true)}> 185 | 186 | 保存 187 | 188 | handleSave(false)}> 189 | 190 | 上書き保存 191 | 192 | setIsDrawerOpen(!isDrawerOpen)}> 193 | 194 | 設定 195 | 196 | await appWindow.close()}> 197 | 198 | 終了 199 | 200 | 201 | 202 |
203 |
204 | 212 |
213 |
214 | 222 |
223 |
224 | } 227 | onElement={} 228 | onChange={handleEditable} 229 | className="h-8 w-8" 230 | /> 231 |
232 |
233 |
234 | {targetFile !== "" ? targetFile : PLACEHOLDER_TITLE} 235 |
236 |
237 |
238 | 239 | 240 | 241 | 242 | 243 | setIsHelpOpen(!isHelpOpen)}> 244 | 245 | ヘルプ 246 | 247 | { 249 | const name = await getName(); 250 | const version = await getVersion(); 251 | const tauriVersion = await getTauriVersion(); 252 | const platformName = await platform(); 253 | const archName = await arch(); 254 | return await message( 255 | `Version: ${version}\nTauri version: ${tauriVersion}\nPlatform: ${platformName} (${archName})\n\n© 2022- haxibami`, 256 | { 257 | title: name, 258 | } 259 | ); 260 | }} 261 | > 262 | 263 | このアプリについて 264 | 265 | 266 | 267 |
268 |
269 |
270 | 271 | ); 272 | }; 273 | 274 | export default Header; 275 | -------------------------------------------------------------------------------- /src/components/Infobar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "react-daisyui"; 2 | 3 | import { useRecoilState } from "recoil"; 4 | 5 | import { textLengthState, isLenCountOnState } from "../../store"; 6 | 7 | const Infobar = () => { 8 | const [textLength] = useRecoilState(textLengthState); 9 | const [isLenCountOn] = useRecoilState(isLenCountOnState); 10 | return ( 11 |
12 |
13 | 17 | {textLength} 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default Infobar; 25 | -------------------------------------------------------------------------------- /src/components/Setting/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { Range, Form, Toggle } from "react-daisyui"; 4 | 5 | import { useRecoilState } from "recoil"; 6 | 7 | import { 8 | lineWordsState, 9 | lineHeightState, 10 | fontSizeState, 11 | isLineNumOnState, 12 | isLenCountOnState, 13 | colorModeState, 14 | isOsColorModeState, 15 | } from "../../store"; 16 | 17 | const Setting = () => { 18 | const [lineWords, setLineWords] = useRecoilState(lineWordsState); 19 | const [lineHeight, setLineHeight] = useRecoilState(lineHeightState); 20 | const [isLineNumOn, setIsLineNumOn] = useRecoilState(isLineNumOnState); 21 | const [isLenCountOn, setIsLenCountOn] = useRecoilState(isLenCountOnState); 22 | const [colorMode, setColorMode] = useRecoilState(colorModeState); 23 | const [isOsColorMode, setIsOsColorMode] = useRecoilState(isOsColorModeState); 24 | const [fontSize, setFontSize] = useRecoilState(fontSizeState); 25 | 26 | const handleColorModeChange = () => { 27 | const newMode = colorMode === "dark" ? "light" : "dark"; 28 | setColorMode(newMode); 29 | }; 30 | 31 | // TODO: support system color mode 32 | useEffect(() => { 33 | if (isOsColorMode) { 34 | document.getElementsByTagName("html")[0].removeAttribute("data-theme"); 35 | } else { 36 | document 37 | .getElementsByTagName("html")[0] 38 | .setAttribute("data-theme", colorMode); 39 | } 40 | }, [colorMode, isOsColorMode]); 41 | 42 | return ( 43 |
44 |
45 |
46 | 47 | setIsLineNumOn(!isLineNumOn)} 51 | /> 52 | 53 |
54 |
55 | 56 | setIsLenCountOn(!isLenCountOn)} 60 | /> 61 | 62 |
63 |
64 | 68 | setIsOsColorMode(!isOsColorMode)} 72 | /> 73 | 74 |
75 |
76 | 77 | 83 | 84 |
85 |
86 |
87 | 102 | 117 | 132 |
133 |
134 | ); 135 | }; 136 | 137 | export default Setting; 138 | -------------------------------------------------------------------------------- /src/components/Svg/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | export const Bars: FC = ({ className }) => { 8 | return ( 9 | 15 | 21 | 22 | ); 23 | }; 24 | 25 | export const Ellipsis: FC = ({ className }) => { 26 | return ( 27 | 33 | 39 | 40 | ); 41 | }; 42 | 43 | export const Gear: FC = ({ className }) => { 44 | return ( 45 | 50 | {/**/} 51 | 52 | 53 | ); 54 | }; 55 | 56 | export const File: FC = ({ className }) => { 57 | return ( 58 | 63 | {/**/} 64 | 65 | 66 | ); 67 | }; 68 | 69 | export const Save: FC = ({ className }) => { 70 | return ( 71 | 76 | {/**/} 77 | 78 | 79 | ); 80 | }; 81 | 82 | export const Sync: FC = ({ className }) => { 83 | return ( 84 | 89 | {/**/} 90 | 91 | 92 | ); 93 | }; 94 | 95 | export const Undo: FC = ({ className }) => { 96 | return ( 97 | 102 | {/* */} 103 | 104 | 105 | ); 106 | }; 107 | 108 | export const Redo: FC = ({ className }) => { 109 | return ( 110 | 115 | {/**/} 116 | 117 | 118 | ); 119 | }; 120 | 121 | export const Info: FC = ({ className }) => { 122 | return ( 123 | 128 | {/**/} 129 | 130 | 131 | ); 132 | }; 133 | 134 | export const Question: FC = ({ className }) => { 135 | return ( 136 | 141 | {/**/} 142 | 143 | 144 | ); 145 | }; 146 | 147 | export const EyeOpen: FC = ({ className }) => { 148 | return ( 149 | 154 | {/**/} 155 | 156 | 157 | ); 158 | }; 159 | 160 | export const Pen: FC = ({ className }) => { 161 | return ( 162 | 167 | {/**/} 168 | 169 | 170 | ); 171 | }; 172 | 173 | export const Sun: FC = ({ className }) => { 174 | return ( 175 | 180 | 181 | 182 | ); 183 | }; 184 | 185 | export const Moon: FC = ({ className }) => { 186 | return ( 187 | 192 | 193 | 194 | ); 195 | }; 196 | 197 | export const Xmark: FC = ({ className }) => { 198 | return ( 199 | 204 | {/**/} 205 | 206 | 207 | ); 208 | }; 209 | -------------------------------------------------------------------------------- /src/components/TextModal/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from "react"; 2 | 3 | import { Modal, Button } from "react-daisyui"; 4 | 5 | interface Props { 6 | header: string; 7 | body: ReactNode; 8 | open: boolean; 9 | onClick: () => void; 10 | } 11 | 12 | const TextModal: FC = ({ header, body, open, onClick }) => { 13 | return ( 14 | 15 | {header} 16 | 17 | {body} 18 | 19 | 20 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default TextModal; 29 | -------------------------------------------------------------------------------- /src/consts/index.ts: -------------------------------------------------------------------------------- 1 | export const PLACEHOLDER_CONTENT = "テキストを入力"; 2 | 3 | export const PLACEHOLDER_TITLE = "無題"; 4 | -------------------------------------------------------------------------------- /src/hooks/keybind.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from "react"; 2 | 3 | import { $moveCharacter } from "@lexical/selection"; 4 | import { mergeRegister } from "@lexical/utils"; 5 | import type { TextFormatType, LexicalEditor } from "lexical"; 6 | import { 7 | $getSelection, 8 | $isRangeSelection, 9 | KEY_ARROW_DOWN_COMMAND, 10 | KEY_ARROW_UP_COMMAND, 11 | KEY_ARROW_LEFT_COMMAND, 12 | KEY_ARROW_RIGHT_COMMAND, 13 | KEY_ENTER_COMMAND, 14 | KEY_TAB_COMMAND, 15 | COMMAND_PRIORITY_EDITOR, 16 | COMMAND_PRIORITY_HIGH, 17 | INSERT_PARAGRAPH_COMMAND, 18 | PASTE_COMMAND, 19 | FORMAT_TEXT_COMMAND, 20 | } from "lexical"; 21 | 22 | import { onPasteForRichText } from "../lib/clipboard"; 23 | import { $moveLine } from "../lib/selection"; 24 | 25 | export function useKeybind(editor: LexicalEditor): void { 26 | useLayoutEffect(() => { 27 | return mergeRegister( 28 | // bind arrow key to WYSIWYG caret move 29 | editor.registerCommand( 30 | KEY_ARROW_LEFT_COMMAND, 31 | (payload) => { 32 | const selection = $getSelection(); 33 | if (!$isRangeSelection(selection)) { 34 | return false; 35 | } 36 | const isHoldingShift = payload.shiftKey; 37 | // if we prevent default, cannot autoscroll following caret on Chrome & Webkit 38 | payload.preventDefault(); 39 | // $moveCharacter(selection, isHoldingShift, false); 40 | $moveLine(selection, isHoldingShift, false); 41 | return true; 42 | }, 43 | COMMAND_PRIORITY_EDITOR 44 | ), 45 | editor.registerCommand( 46 | KEY_ARROW_RIGHT_COMMAND, 47 | (payload) => { 48 | const selection = $getSelection(); 49 | if (!$isRangeSelection(selection)) { 50 | return false; 51 | } 52 | const isHoldingShift = payload.shiftKey; 53 | // if we prevent default, cannot autoscroll following caret on Chrome & Webkit 54 | payload.preventDefault(); 55 | // $moveCharacter(selection, isHoldingShift, true); 56 | $moveLine(selection, isHoldingShift, true); 57 | return true; 58 | }, 59 | COMMAND_PRIORITY_EDITOR 60 | ), 61 | editor.registerCommand( 62 | KEY_ARROW_UP_COMMAND, 63 | (payload) => { 64 | const selection = $getSelection(); 65 | if (!$isRangeSelection(selection)) { 66 | return false; 67 | } 68 | const isHoldingShift = payload.shiftKey; 69 | payload.preventDefault(); 70 | $moveCharacter(selection, isHoldingShift, true); 71 | return true; 72 | }, 73 | COMMAND_PRIORITY_EDITOR 74 | ), 75 | editor.registerCommand( 76 | KEY_ARROW_DOWN_COMMAND, 77 | (payload) => { 78 | const selection = $getSelection(); 79 | if (!$isRangeSelection(selection)) { 80 | return false; 81 | } 82 | const isHoldingShift = payload.shiftKey; 83 | payload.preventDefault(); 84 | $moveCharacter(selection, isHoldingShift, false); 85 | return true; 86 | }, 87 | COMMAND_PRIORITY_EDITOR 88 | ), 89 | // overwrite default rich text editor behavior 90 | editor.registerCommand( 91 | INSERT_PARAGRAPH_COMMAND, 92 | () => { 93 | const selection = $getSelection(); 94 | if (!$isRangeSelection(selection)) { 95 | return false; 96 | } 97 | selection.insertParagraph(); 98 | return true; 99 | }, 100 | COMMAND_PRIORITY_HIGH 101 | ), 102 | editor.registerCommand( 103 | KEY_ENTER_COMMAND, 104 | (event) => { 105 | const selection = $getSelection(); 106 | if (!$isRangeSelection(selection)) { 107 | return false; 108 | } 109 | if (event !== null) { 110 | // TODO: handle iOS & Safari 111 | // if ((IS_IOS || IS_SAFARI) && CAN_USE_BEFORE_INPUT) { 112 | // return false; 113 | // } 114 | event.preventDefault(); 115 | if (event.shiftKey) { 116 | return editor.dispatchCommand( 117 | INSERT_PARAGRAPH_COMMAND, 118 | undefined 119 | ); 120 | } 121 | } 122 | return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); 123 | }, 124 | COMMAND_PRIORITY_HIGH 125 | ), 126 | editor.registerCommand( 127 | PASTE_COMMAND, 128 | (event) => { 129 | const selection = $getSelection(); 130 | if ($isRangeSelection(selection)) { 131 | onPasteForRichText(event, editor); 132 | return true; 133 | } 134 | return false; 135 | }, 136 | COMMAND_PRIORITY_HIGH 137 | ), 138 | editor.registerCommand( 139 | KEY_TAB_COMMAND, 140 | (event) => { 141 | event.preventDefault(); 142 | return true; 143 | }, 144 | COMMAND_PRIORITY_HIGH 145 | ), 146 | editor.registerCommand( 147 | FORMAT_TEXT_COMMAND, 148 | () => { 149 | return true; 150 | }, 151 | COMMAND_PRIORITY_HIGH 152 | ) 153 | ); 154 | }, [editor]); 155 | } 156 | -------------------------------------------------------------------------------- /src/hooks/tauri.ts: -------------------------------------------------------------------------------- 1 | import { register, unregister } from "@tauri-apps/api/globalShortcut"; 2 | import type { ShortcutHandler } from "@tauri-apps/api/globalShortcut"; 3 | 4 | import { useEffect } from "react"; 5 | 6 | export const useKeyboardShortcut = (shortcut: string, cb: ShortcutHandler) => { 7 | useEffect(() => { 8 | register(shortcut, cb).then(); 9 | 10 | return () => { 11 | unregister(shortcut).then(); 12 | }; 13 | }, [cb, shortcut]); 14 | }; 15 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .vertical { 7 | writing-mode: vertical-rl; 8 | } 9 | .preview { 10 | -webkit-text-combine: horizontal; 11 | text-combine: horizontal; 12 | -ms-text-combine-horizontal: all; 13 | text-combine-horizontal: digit 2; 14 | text-combine-upright: digit 2; 15 | } 16 | .linenum { 17 | counter-reset: line-number; 18 | 19 | p { 20 | counter-increment: line-number; 21 | position: relative; 22 | /* needed for */ 23 | /*display: inline-block;*/ 24 | } 25 | 26 | p::before { 27 | /*display: inline-block;*/ 28 | position: absolute; 29 | /* since webkit doesn't support upright number, 30 | currently we don't use it */ 31 | /* text-combine-upright: all;*/ 32 | /* transform: rotate(-90deg);*/ 33 | content: counter(line-number); 34 | color: #aaa; 35 | top: -3em; 36 | right: -0.1em; 37 | } 38 | } 39 | 40 | /* Edge (Chromium) scrollbar is shit */ 41 | /* for css hacks, see https://qiita.com/feo52/items/b58de2c43e1ba7b10b2e */ 42 | 43 | /* vertical bar */ 44 | _:host-context(x), 45 | ::-webkit-scrollbar { 46 | width: 0.4em; 47 | } 48 | 49 | _:host-context(x), 50 | ::-webkit-scrollbar-thumb { 51 | border-radius: 0.2em; 52 | box-shadow: inset 0 0 0.1em 0.1em #999; 53 | border: solid 0.1em transparent; 54 | } 55 | 56 | _:host-context(x), 57 | ::-webkit-scrollbar-thumb:hover { 58 | background-color: #909090; 59 | } 60 | 61 | /* horizontal bar */ 62 | _:host-context(x), 63 | .scrollbar::-webkit-scrollbar { 64 | height: 0.4em; 65 | } 66 | 67 | _:host-context(x), 68 | .scrollbar::-webkit-scrollbar-thumb { 69 | border-radius: 0.2em; 70 | box-shadow: inset 0 0 0.1em 0.1em #999; 71 | border: solid 0.1em transparent; 72 | } 73 | 74 | _:host-context(x), 75 | .scrollbar::-webkit-scrollbar-thumb:hover { 76 | background-color: #909090; 77 | } 78 | } 79 | 80 | html { 81 | font-size: 18px; 82 | } 83 | 84 | @font-face { 85 | font-family: "novel"; 86 | src: url("./assets/font/novel-regular.woff2") format("woff2"); 87 | } 88 | -------------------------------------------------------------------------------- /src/lib/clipboard.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | RangeSelection, 3 | GridSelection, 4 | PASTE_COMMAND, 5 | CommandPayloadType, 6 | LexicalEditor, 7 | } from "lexical"; 8 | import { $getSelection, $isRangeSelection } from "lexical"; 9 | 10 | // All pasted text is treated as multi-line plain text 11 | function $insertDataTransferForNovelText( 12 | dataTransfer: DataTransfer, 13 | selection: RangeSelection | GridSelection 14 | ): void { 15 | // Multi-line plain text in rich text mode pasted as separate paragraphs 16 | // instead of single paragraph with linebreaks. 17 | const text = dataTransfer.getData("text/plain"); 18 | if (text != null) { 19 | if ($isRangeSelection(selection)) { 20 | const lines = text.split(/\r?\n/); 21 | const linesLength = lines.length; 22 | 23 | for (let i = 0; i < linesLength; i++) { 24 | selection.insertText(lines[i]); 25 | if (i < linesLength - 1) { 26 | selection.insertParagraph(); 27 | } 28 | } 29 | console.log("hoge"); 30 | } else { 31 | selection.insertRawText(text); 32 | } 33 | } 34 | } 35 | 36 | export function onPasteForRichText( 37 | event: CommandPayloadType, 38 | editor: LexicalEditor 39 | ): void { 40 | event.preventDefault(); 41 | editor.update( 42 | () => { 43 | const selection = $getSelection(); 44 | const clipboardData = 45 | event instanceof InputEvent || event instanceof KeyboardEvent 46 | ? null 47 | : event.clipboardData; 48 | if (clipboardData != null && $isRangeSelection(selection)) { 49 | $insertDataTransferForNovelText(clipboardData, selection); 50 | } 51 | }, 52 | { 53 | tag: "paste", 54 | } 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/command.ts: -------------------------------------------------------------------------------- 1 | import type { LexicalCommand } from "lexical"; 2 | import { createCommand } from "lexical"; 3 | 4 | export const SAVE_COMMAND: LexicalCommand = createCommand(); 5 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "tauri-plugin-store-api"; 2 | 3 | export const store = new Store("config.json"); 4 | -------------------------------------------------------------------------------- /src/lib/fs.ts: -------------------------------------------------------------------------------- 1 | import { save, open } from "@tauri-apps/api/dialog"; 2 | import { writeTextFile, readTextFile } from "@tauri-apps/api/fs"; 3 | 4 | import { $getRoot, $createParagraphNode, $createTextNode } from "lexical"; 5 | import type { LexicalEditor } from "lexical"; 6 | 7 | export const getOpenFilePath = async () => { 8 | const filePath = await open({ 9 | multiple: false, 10 | title: "ファイルを開く", 11 | filters: [{ name: "Text Files", extensions: ["txt"] }], 12 | }); 13 | if (filePath && typeof filePath === "string") { 14 | return filePath; 15 | } 16 | }; 17 | 18 | export function $setTextContent(text: string) { 19 | const root = $getRoot(); 20 | if (root.getFirstChild()) { 21 | root.clear(); 22 | } 23 | // initialize editor with dirty way 24 | text 25 | .split("\n") 26 | .slice(0, -1) 27 | .forEach((line) => { 28 | const paragraph = $createParagraphNode(); 29 | if (line.length === 0) { 30 | // empty line 31 | } else { 32 | paragraph.append($createTextNode(line)); 33 | } 34 | root.append(paragraph); 35 | }); 36 | } 37 | 38 | export const silentOpen = async (editor: LexicalEditor, filePath: string) => { 39 | const text = await readTextFile(filePath); 40 | editor.update(() => { 41 | $setTextContent(text); 42 | }); 43 | }; 44 | 45 | export const askAndOpen = async (editor: LexicalEditor) => { 46 | const filePath = await getOpenFilePath(); 47 | if (filePath) { 48 | await silentOpen(editor, filePath); 49 | return filePath; 50 | } else { 51 | // some message or error 52 | } 53 | }; 54 | 55 | export const getSaveFilePath = async () => { 56 | const filePath = await save({ 57 | title: "ファイルを保存", 58 | filters: [{ name: "Text Files", extensions: ["txt"] }], 59 | }); 60 | return filePath; 61 | }; 62 | 63 | export const silentSave = async (editor: LexicalEditor, filePath: string) => { 64 | // TODO: check whether file exists 65 | // TODO: improve text extraction logic 66 | // since we show plain text in rich editor mode, we need to convert each paragraph to line 67 | const text = editor 68 | .getEditorState() 69 | .read(() => 70 | $getRoot() 71 | .getChildren() 72 | .map((n) => n.getTextContent()) 73 | ) 74 | .join("\n"); 75 | // TODO: check if we can correctly handle linebreak 76 | await writeTextFile(filePath, `${text}\n`); 77 | // return filePath; 78 | }; 79 | 80 | export const askandSave = async (editor: LexicalEditor) => { 81 | const filePath = await getSaveFilePath(); 82 | if (filePath) { 83 | await silentSave(editor, filePath); 84 | } 85 | return filePath ?? ""; 86 | }; 87 | -------------------------------------------------------------------------------- /src/lib/selection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $isTextNode, 3 | $isElementNode, 4 | $getAdjacentNode, 5 | $isDecoratorNode, 6 | } from "lexical"; 7 | import type { 8 | TextNode, 9 | ElementNode, 10 | RangeSelection, 11 | NodeKey, 12 | GridSelection, 13 | } from "lexical"; 14 | 15 | type TextPointType = { 16 | _selection: RangeSelection | GridSelection; 17 | getNode: () => TextNode; 18 | is: (point: PointType) => boolean; 19 | isBefore: (point: PointType) => boolean; 20 | key: NodeKey; 21 | offset: number; 22 | set: (key: NodeKey, offset: number, type: "text" | "element") => void; 23 | type: "text"; 24 | }; 25 | 26 | type ElementPointType = { 27 | _selection: RangeSelection | GridSelection; 28 | getNode: () => ElementNode; 29 | is: (point: PointType) => boolean; 30 | isBefore: (point: PointType) => boolean; 31 | key: NodeKey; 32 | offset: number; 33 | set: (key: NodeKey, offset: number, type: "text" | "element") => void; 34 | type: "element"; 35 | }; 36 | 37 | type PointType = TextPointType | ElementPointType; 38 | 39 | const getDOMSelection = (): Selection | null => window.getSelection(); 40 | 41 | function $moveNativeSelection( 42 | domSelection: Selection, 43 | alter: "move" | "extend", 44 | direction: "backward" | "forward" | "left" | "right", 45 | granularity: "character" | "word" | "lineboundary" | "line" 46 | ): void { 47 | domSelection.modify(alter, direction, granularity); 48 | } 49 | 50 | function $setPointValues( 51 | point: PointType, 52 | key: NodeKey, 53 | offset: number, 54 | type: "text" | "element" 55 | ): void { 56 | point.key = key; 57 | point.offset = offset; 58 | point.type = type; 59 | } 60 | 61 | function $swapPoints(selection: RangeSelection): void { 62 | const focus = selection.focus; 63 | const anchor = selection.anchor; 64 | const anchorKey = anchor.key; 65 | const anchorOffset = anchor.offset; 66 | const anchorType = anchor.type; 67 | 68 | $setPointValues(anchor, focus.key, focus.offset, focus.type); 69 | $setPointValues(focus, anchorKey, anchorOffset, anchorType); 70 | selection._cachedNodes = null; 71 | } 72 | 73 | function $modifyLineSelection( 74 | selection: RangeSelection, 75 | alter: "move" | "extend", 76 | isBackward: boolean, 77 | granularity: "character" | "word" | "lineboundary" | "line" 78 | ): void { 79 | const focus = selection.focus; 80 | const anchor = selection.anchor; 81 | const collapse = alter === "move"; 82 | 83 | // Handle the selection movement around decorators. 84 | // TODO: remove decorator logic 85 | const possibleNode = $getAdjacentNode(focus, isBackward); 86 | if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) { 87 | const sibling = isBackward 88 | ? possibleNode.getPreviousSibling() 89 | : possibleNode.getNextSibling(); 90 | 91 | if (!$isTextNode(sibling)) { 92 | const parent = possibleNode.getParentOrThrow(); 93 | let offset; 94 | let elementKey; 95 | 96 | if ($isElementNode(sibling)) { 97 | elementKey = sibling.__key; 98 | offset = isBackward ? sibling.getChildrenSize() : 0; 99 | } else { 100 | offset = possibleNode.getIndexWithinParent(); 101 | elementKey = parent.__key; 102 | if (!isBackward) { 103 | offset++; 104 | } 105 | } 106 | focus.set(elementKey, offset, "element"); 107 | if (collapse) { 108 | anchor.set(elementKey, offset, "element"); 109 | } 110 | return; 111 | } else { 112 | const siblingKey = sibling.__key; 113 | const offset = isBackward ? sibling.getTextContent().length : 0; 114 | focus.set(siblingKey, offset, "text"); 115 | if (collapse) { 116 | anchor.set(siblingKey, offset, "text"); 117 | } 118 | return; 119 | } 120 | } 121 | 122 | const domSelection = getDOMSelection(); 123 | 124 | if (!domSelection) { 125 | return; 126 | } 127 | 128 | $moveNativeSelection( 129 | domSelection, 130 | alter, 131 | isBackward ? "backward" : "forward", 132 | granularity 133 | ); 134 | 135 | if (domSelection.rangeCount > 0) { 136 | const range = domSelection.getRangeAt(0); 137 | selection.applyDOMRange(range); 138 | selection.dirty = true; 139 | 140 | if ( 141 | (!collapse && domSelection.anchorNode !== range.startContainer) || 142 | domSelection.anchorOffset !== range.startOffset 143 | ) { 144 | $swapPoints(selection); 145 | } 146 | } 147 | } 148 | 149 | export function $moveCaretSelection( 150 | selection: RangeSelection, 151 | isHoldingShift: boolean, 152 | isBackward: boolean, 153 | granularity: "character" | "word" | "lineboundary" | "line" 154 | ): void { 155 | $modifyLineSelection( 156 | selection, 157 | isHoldingShift ? "extend" : "move", 158 | isBackward, 159 | granularity 160 | ); 161 | } 162 | 163 | export function $moveLine( 164 | selection: RangeSelection, 165 | isHoldingShift: boolean, 166 | isBackward: boolean 167 | ): void { 168 | $moveCaretSelection(selection, isHoldingShift, isBackward, "line"); 169 | } 170 | -------------------------------------------------------------------------------- /src/lib/suspense.ts: -------------------------------------------------------------------------------- 1 | const dataMap: Map = new Map(); 2 | 3 | // TODO: fix data management 4 | export function useData(cacheKey: string, fetch: () => Promise): T { 5 | const cachedData = dataMap.get(cacheKey) as T | undefined; 6 | if (cachedData === undefined) { 7 | throw fetch().then((d) => dataMap.set(cacheKey, d)); 8 | } 9 | return cachedData; 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import { RecoilRoot } from "recoil"; 5 | 6 | import App from "./App"; 7 | import Fallback from "./components/Fallback"; 8 | import "./index.css"; 9 | 10 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 11 | 12 | 13 | }> 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/plugins/AutoFocusPlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { useEffect } from "react"; 3 | 4 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 5 | 6 | import { useRecoilState } from "recoil"; 7 | 8 | import { isDrawerOpenState } from "../store"; 9 | 10 | const AutoFocusPlugin: FC = () => { 11 | const [editor] = useLexicalComposerContext(); 12 | const [isDrawerOpen] = useRecoilState(isDrawerOpenState); 13 | 14 | useEffect(() => { 15 | if (!isDrawerOpen) { 16 | editor.focus(); 17 | } 18 | }, [editor, isDrawerOpen]); 19 | 20 | return null; 21 | }; 22 | 23 | export default AutoFocusPlugin; 24 | -------------------------------------------------------------------------------- /src/plugins/AutoHorizontalScrollPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from "react"; 2 | 3 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 4 | import { $isElementNode, $getSelection, $isRangeSelection } from "lexical"; 5 | 6 | type Props = Readonly<{ 7 | scrollRef: { 8 | current: HTMLElement | null; 9 | }; 10 | }>; 11 | 12 | export function AutoHorizontalScrollPlugin({ 13 | scrollRef, 14 | }: Props): JSX.Element | null { 15 | const [editor] = useLexicalComposerContext(); 16 | 17 | useLayoutEffect(() => { 18 | return editor.registerUpdateListener(({ editorState, tags }) => { 19 | const scrollElement = scrollRef.current; 20 | 21 | if (scrollElement === null) { 22 | return; 23 | } 24 | 25 | const selection = editorState.read(() => $getSelection()); 26 | 27 | if (!$isRangeSelection(selection)) { 28 | return; 29 | } 30 | 31 | const anchorElement = editor.getElementByKey(selection.anchor.key); 32 | 33 | if (anchorElement === null) { 34 | return; 35 | } 36 | 37 | const scrollRect = scrollElement.getBoundingClientRect(); 38 | 39 | let anchorNode = editorState.read(() => selection.anchor.getNode()); 40 | if (anchorNode === null) { 41 | return; 42 | } 43 | if ($isElementNode(anchorNode)) { 44 | const descendantNode = editorState.read(() => 45 | anchorNode?.getDescendantByIndex(selection.anchor.offset - 1) 46 | ); 47 | if (descendantNode !== null) { 48 | anchorNode = descendantNode; 49 | } 50 | } 51 | 52 | const element = editor.getElementByKey(anchorNode.__key) as Element; 53 | 54 | if (element !== null) { 55 | const rect = element.getBoundingClientRect(); 56 | 57 | if (rect.width > scrollRect.width) { 58 | // if text wider than screen is given without linebreak, 59 | // we have no way to detect current cursor position from element. 60 | // so currently we can do nothing. 61 | // tips: firefox can handle this situation 62 | return; 63 | } 64 | 65 | if (rect.left < scrollRect.left) { 66 | element.scrollIntoView(false); 67 | } else if (rect.right > scrollRect.right) { 68 | element.scrollIntoView(); 69 | } else { 70 | // Rects can returning decimal numbers that differ due to rounding 71 | // differences. So let's normalize the values. 72 | if (Math.floor(rect.left) < Math.floor(scrollRect.left)) { 73 | element.scrollIntoView(); 74 | } else if (Math.floor(rect.right) > Math.floor(scrollRect.right)) { 75 | element.scrollIntoView(false); 76 | } 77 | } 78 | } 79 | tags.add("scroll-into-view"); 80 | }); 81 | }, [editor, scrollRef]); 82 | 83 | return null; 84 | } 85 | -------------------------------------------------------------------------------- /src/plugins/InitPlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { useEffect } from "react"; 3 | 4 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 5 | import { mergeRegister } from "@lexical/utils"; 6 | import { COMMAND_PRIORITY_HIGH, KEY_MODIFIER_COMMAND } from "lexical"; 7 | 8 | import { useRecoilState } from "recoil"; 9 | 10 | // import { useGlobalShortcut, useKeyboardShortcut } from "../hooks/tauri"; 11 | import { useKeybind } from "../hooks/keybind"; 12 | import { SAVE_COMMAND } from "../lib/command"; 13 | import { silentSave, askandSave } from "../lib/fs"; 14 | import { targetFileState } from "../store"; 15 | 16 | const InitPlugin: FC = () => { 17 | const [editor] = useLexicalComposerContext(); 18 | const [targetFile, setTargetFile] = useRecoilState(targetFileState); 19 | 20 | // TODO: improve Ctrl+S shortcut 21 | const handleSave = async (ask: boolean) => { 22 | if (ask) { 23 | // 保存 24 | const savedName = await askandSave(editor); 25 | setTargetFile(savedName); 26 | } else { 27 | // 上書き保存 28 | if (targetFile !== "") { 29 | await silentSave(editor, targetFile); 30 | } else { 31 | const savedName = await askandSave(editor); 32 | setTargetFile(savedName); 33 | } 34 | } 35 | }; 36 | 37 | // TODO: switch to app-wide keybind 38 | // currently tauri on Wayland does not support app-wide keybinding 39 | // 40 | // useKeyboardShortcut("CommandOrControl+S", (shortcut) => { 41 | // 42 | // }); 43 | 44 | useKeybind(editor); 45 | 46 | useEffect(() => { 47 | return mergeRegister( 48 | editor.registerCommand( 49 | KEY_MODIFIER_COMMAND, 50 | (event) => { 51 | if (event.ctrlKey && event.key === "s") { 52 | return editor.dispatchCommand(SAVE_COMMAND, event); 53 | } else { 54 | return false; 55 | } 56 | }, 57 | COMMAND_PRIORITY_HIGH 58 | ), 59 | editor.registerCommand( 60 | SAVE_COMMAND, 61 | (event) => { 62 | event.preventDefault(); 63 | (async () => { 64 | await handleSave(false); 65 | })(); 66 | return true; 67 | }, 68 | COMMAND_PRIORITY_HIGH 69 | ) 70 | ); 71 | }, [editor, targetFile]); 72 | 73 | return null; 74 | }; 75 | 76 | export default InitPlugin; 77 | -------------------------------------------------------------------------------- /src/plugins/TreeViewPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 | import { TreeView } from "@lexical/react/LexicalTreeView"; 3 | 4 | export default function TreeViewPlugin() { 5 | const [editor] = useLexicalComposerContext(); 6 | return ( 7 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { exists } from "@tauri-apps/api/fs"; 2 | 3 | import { atom, DefaultValue } from "recoil"; 4 | 5 | import { store } from "../lib/config"; 6 | 7 | import type { AtomEffect } from "recoil"; 8 | 9 | // Utils 10 | type TauriStoreEffect = (key: string) => AtomEffect; 11 | 12 | const tauriStoreEffect: TauriStoreEffect = 13 | (key: string) => 14 | ({ setSelf, onSet }) => { 15 | // If there's a persisted value - set it on load 16 | setSelf( 17 | store.get(key).then( 18 | // TODO: type 19 | (savedValue: any) => 20 | savedValue != null ? savedValue : new DefaultValue() // Abort initialization if no value was stored 21 | ) 22 | ); 23 | 24 | // Subscribe to state changes and persist them to localForage 25 | onSet((newValue, _, isReset) => { 26 | isReset ? store.delete(key) : store.set(key, newValue); 27 | }); 28 | }; 29 | 30 | const fileExistsEffect: TauriStoreEffect = 31 | (key: string) => 32 | ({ setSelf }) => { 33 | setSelf( 34 | store.get(key).then( 35 | async (savedValue: any) => await exists(savedValue) ? savedValue : new DefaultValue() 36 | ) 37 | ); 38 | }; 39 | 40 | // Atoms 41 | 42 | // Editor 43 | export const targetFileState = atom({ 44 | key: "editor/file-name", 45 | default: "", 46 | effects: [tauriStoreEffect("target-file"), fileExistsEffect("target-file")], 47 | }); 48 | 49 | export const textLengthState = atom({ 50 | key: "editor/text-length", 51 | default: 0, 52 | }); 53 | 54 | export const isEditableState = atom({ 55 | key: "editor/is-editable", 56 | default: true, 57 | }); 58 | 59 | // UI 60 | // TODO: enable fontsize setting 61 | export const fontSizeState = atom({ 62 | key: "config/font-size", 63 | default: 1, 64 | effects: [tauriStoreEffect("font-size")], 65 | }); 66 | 67 | export const lineWordsState = atom({ 68 | key: "config/line-words", 69 | default: 30, 70 | effects: [tauriStoreEffect("line-words")], 71 | }); 72 | 73 | export const lineHeightState = atom({ 74 | key: "config/line-height", 75 | default: 2, 76 | effects: [tauriStoreEffect("line-height")], 77 | }); 78 | 79 | export const isLineNumOnState = atom({ 80 | key: "config/is-line-number-on", 81 | default: true, 82 | effects: [tauriStoreEffect("is-line-number-on")], 83 | }); 84 | 85 | export const isLenCountOnState = atom({ 86 | key: "config/is-len-count-on", 87 | default: true, 88 | effects: [tauriStoreEffect("is-len-count-on")], 89 | }); 90 | 91 | export const isOsColorModeState = atom({ 92 | key: "config/is-os-color-mode", 93 | default: true, 94 | effects: [tauriStoreEffect("is-os-color-mode")], 95 | }); 96 | 97 | // TODO: type ColorMode 98 | export const colorModeState = atom({ 99 | key: "config/color-mode", 100 | default: "light", 101 | effects: [tauriStoreEffect("color-mode")], 102 | }); 103 | 104 | // UI 105 | export const isDrawerOpenState = atom({ 106 | key: "ui/is-drawer-open", 107 | default: false, 108 | }); 109 | 110 | export const isModalOpenState = atom({ 111 | key: "ui/is-modal-open", 112 | default: false, 113 | }); 114 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | // mode: "jit", 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,jsx,ts,tsx,css,html,json,scss,md,mdx}", 7 | "node_modules/daisyui/dist/**/*.js", 8 | "node_modules/react-daisyui/dist/**/*.js", 9 | ], 10 | // darkMode: "media", 11 | theme: { 12 | extend: {}, 13 | fontFamily: { 14 | serif: ["novel", "serif"], 15 | sans: ["sans-serif"], 16 | mono: ["monospace"], 17 | }, 18 | }, 19 | plugins: [ 20 | require("@tailwindcss/typography"), 21 | require("daisyui"), 22 | function ({ addVariant }) { 23 | addVariant("child", "& > *"); 24 | addVariant("child-hover", "& > *:hover"); 25 | addVariant("child-before", "& > *:before"); 26 | addVariant("child-after", "& > *:after"); 27 | }, 28 | ], 29 | daisyui: { 30 | logs: false, 31 | themes: [ 32 | { 33 | light: { 34 | primary: "#e6bbbe", 35 | // "primary-content": "#ffffff", 36 | secondary: "#cbd3eb", 37 | // "secondary-content": "#ffffff", 38 | accent: "#b7aad2", 39 | // "accent-content": "#d99c99", 40 | neutral: "#292929", 41 | // "neutral-content": "#415558", 42 | "base-100": "#f9f9f9", 43 | // "base-200": "#f9f9f9", 44 | // "base-300": "#f9f9f9", 45 | // "base-content": "#464841", 46 | info: "#a5cec7", 47 | success: "#b8d2aa", 48 | warning: "#e4d7a4", 49 | error: "#d9989c", 50 | }, 51 | }, 52 | { 53 | dark: { 54 | primary: "#e6bbbe", 55 | // "primary-content": "#ffffff", 56 | secondary: "#cbd3eb", 57 | // "secondary-content": "#ffffff", 58 | accent: "#b7aad2", 59 | // "accent-content": "#d99c99", 60 | neutral: "#292929", 61 | // "neutral-content": "#415558", 62 | "base-100": "#46484e", 63 | // "base-200": "#f9f9f9", 64 | // "base-300": "#f9f9f9", 65 | "base-content": "#edebef", 66 | info: "#a5cec7", 67 | success: "#b8d2aa", 68 | warning: "#e4d7a4", 69 | error: "#d9989c", 70 | }, 71 | }, 72 | ], 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | --------------------------------------------------------------------------------