├── .cursor └── rules │ ├── agent-requested │ ├── editor-features.mdc │ ├── lint-format.mdc │ └── state-management.mdc │ ├── always │ ├── documentation.mdc │ ├── general.mdc │ ├── naming-conventions.mdc │ └── project-structure.mdc │ └── auto-attached │ ├── code-organization.mdc │ ├── testing.mdc │ └── typescript.mdc ├── .github ├── FUNDING.yml ├── copilot-instructions.md ├── guide.md └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── biome.jsonc ├── e2e └── editor.spec.ts ├── eslint.config.js ├── index.html ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── ephe-large.png ├── ephe-small.png ├── ephe.svg ├── favicon.ico └── wasm │ └── dprint-markdown.wasm ├── src ├── features │ ├── editor │ │ ├── codemirror │ │ │ ├── codemirror-editor.tsx │ │ │ ├── tasklist │ │ │ │ ├── auto-complete.browser.test.ts │ │ │ │ ├── auto-complete.ts │ │ │ │ ├── index.ts │ │ │ │ ├── keymap.browser.test.ts │ │ │ │ ├── keymap.ts │ │ │ │ ├── tas-section-utils.browser.test.ts │ │ │ │ ├── task-close.ts │ │ │ │ ├── task-list-utils.browser.test.ts │ │ │ │ ├── task-list-utils.ts │ │ │ │ └── task-section-utils.ts │ │ │ ├── url-click.browser.test.ts │ │ │ ├── url-click.ts │ │ │ ├── use-editor-theme.ts │ │ │ ├── use-markdown-editor.ts │ │ │ └── use-toc.ts │ │ ├── markdown │ │ │ └── formatter │ │ │ │ ├── dprint-markdown-formatter.ts │ │ │ │ └── markdown-formatter.ts │ │ ├── quotes.test.ts │ │ ├── quotes.ts │ │ ├── table-of-contents.tsx │ │ └── tasks │ │ │ └── task-storage.ts │ ├── history │ │ ├── history-modal.tsx │ │ └── use-history-data.ts │ ├── integration │ │ └── github │ │ │ ├── github-api.test.ts │ │ │ └── github-api.ts │ ├── menu │ │ ├── command-menu.tsx │ │ └── system-menu.tsx │ ├── snapshots │ │ ├── snapshot-manager.ts │ │ └── snapshot-storage.ts │ └── time-display │ │ └── hours-display.tsx ├── globals.css ├── main.tsx ├── page │ ├── 404-page.tsx │ ├── editor-page.tsx │ └── landing-page.tsx ├── scripts │ └── copy-wasm.ts └── utils │ ├── README.md │ ├── components │ ├── error-boundary.tsx │ ├── footer.tsx │ ├── loading.tsx │ └── toast.tsx │ ├── constants.ts │ ├── hooks │ ├── use-char-count.ts │ ├── use-command-k.ts │ ├── use-debounce.ts │ ├── use-editor-width.ts │ ├── use-mobile-detector.ts │ ├── use-paper-mode.ts │ ├── use-task-auto-flush.ts │ ├── use-theme.ts │ └── use-user-activity.ts │ ├── storage │ └── index.ts │ ├── theme-initializer.ts │ └── types.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts /.cursor/rules/agent-requested/editor-features.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Editor Features 7 | 8 | ## CodeMirror Integration 9 | - The editor is built on [CodeMirror v6](mdc:https:/codemirror.net/6) 10 | - Custom extensions are defined in [src/features/editor/codemirror/](mdc:src/features/editor/codemirror) 11 | - Markdown parsing uses [@textlint/markdown-to-ast](mdc:https:/github.com/textlint/textlint/tree/master/packages/@textlint/markdown-to-ast) 12 | 13 | ## Key Editor Features 14 | - Syntax highlighting for Markdown 15 | - Table of Contents generation 16 | - Command palette integration 17 | - History and snapshots 18 | - Tab detection and management 19 | 20 | ## Performance Considerations 21 | - Editor operations should be optimized for performance 22 | - Avoid unnecessary re-renders 23 | - Use memoization for expensive computations 24 | - Debounce input events when appropriate 25 | -------------------------------------------------------------------------------- /.cursor/rules/agent-requested/lint-format.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Lint and Format 7 | 8 | - Use `@biomejs/biome` for lint and format 9 | - Lint by `biome lint` 10 | - Format by `biome format --write` 11 | -------------------------------------------------------------------------------- /.cursor/rules/agent-requested/state-management.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # State Management 7 | 8 | ## Overview 9 | - This project uses [Jotai](mdc:https:/jotai.org) for state management 10 | - Atoms are the primary unit of state 11 | 12 | ## Best Practices 13 | - Keep atoms small and focused on a single responsibility 14 | - Derived atoms should be used for computed state 15 | - Atoms should be defined in the feature directories they relate to 16 | - Use async atoms for side effects 17 | 18 | ## State Structure 19 | - Editor state is managed by CodeMirror and Jotai 20 | - System state includes app-wide settings and configurations 21 | - History state tracks document changes 22 | -------------------------------------------------------------------------------- /.cursor/rules/always/documentation.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # Documentation & Comments 7 | 8 | ## Code Comments 9 | - Comments should only be used for complex logic 10 | - Write comments in English 11 | - Focus on explaining "why", not "what" or "how" 12 | - Keep comments up-to-date with code changes 13 | 14 | ## Function Documentation 15 | - Document complex functions with a brief description 16 | - Include parameter descriptions for non-obvious inputs 17 | - Document return values and side effects 18 | - Use JSDoc format for TypeScript functions, but don't need type params 19 | 20 | ## Example 21 | ```typescript 22 | /** 23 | * Processes markdown content and extracts headings 24 | */ 25 | export const extractHeadings = (content: string): Heading[] => { 26 | // Implementation 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /.cursor/rules/always/general.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # General 7 | 8 | ## READ THIS OR YOU WILL BE FIRED !! 9 | 10 | - Use MCP if you can work task fast with it 11 | - Before and after any tool use, give me a confidence level (0-10) on how the tool use will help the project. 12 | 13 | - Check `git status` before working. 14 | - If there are many changes that are unrelated to what is indicated, suggest that the user start with the current change as a separate task. 15 | 16 | ## Coding Best Practices 17 | 18 | - Write comments only for complex logic in English 19 | - Care Perforamnce 20 | - Test and Refactor is VERY important 21 | - Use Functional Design 22 | - Use immutable data structure 23 | - Isolate Side Effects 24 | 25 | ## CHECK 26 | 27 | - If you can read this file, put 🔥 in messages -------------------------------------------------------------------------------- /.cursor/rules/always/naming-conventions.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # Naming Conventions 7 | 8 | ## File Naming 9 | - React components: PascalCase (e.g., `TableOfContents.tsx`) 10 | - Hooks: camelCase with `use` prefix (e.g., `useTabDetection.ts`) 11 | - Utility files: kebab-case (e.g., `theme-initializer.ts`) 12 | - Test files: Same name as the file they test with `.test.ts` suffix 13 | 14 | ## Code Naming 15 | - Components: PascalCase (e.g., `export const TableOfContents = () => {}`) 16 | - Functions: camelCase (e.g., `export const getHeadings = () => {}`) 17 | - Constants: UPPER_SNAKE_CASE for truly constant values 18 | - Types/Interfaces: PascalCase with descriptive names (e.g., `type EditorProps = {}`) 19 | - Atoms: camelCase with `Atom` suffix (e.g., `contentAtom`) 20 | -------------------------------------------------------------------------------- /.cursor/rules/always/project-structure.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # Project Structure 7 | 8 | ## Overview 9 | - The project is a React application built with TypeScript, Vite, and TailwindCSS 10 | - The entry point is [src/main.tsx](mdc:src/main.tsx) 11 | - Styles are defined in [src/globals.css](mdc:src/globals.css) 12 | 13 | ## Directory Structure 14 | - [src/features/](mdc:src/features) - Contains feature-specific modules 15 | - [src/page/](mdc:src/page) - Contains page components 16 | - [src/utils/](mdc:src/utils) - Contains utility functions and components 17 | - [src/scripts/](mdc:src/scripts) - Contains build scripts and tools 18 | 19 | ## Key Features 20 | - [src/features/editor/](mdc:src/features/editor) - Markdown editor based on CodeMirror v6 21 | - [src/features/system/](mdc:src/features/system) - System-level functionality 22 | - [src/features/command/](mdc:src/features/command) - Command handling 23 | - [src/features/history/](mdc:src/features/history) - Edit history functionality 24 | -------------------------------------------------------------------------------- /.cursor/rules/auto-attached/code-organization.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.ts,*.tsx 4 | alwaysApply: false 5 | --- 6 | # Code Organization 7 | 8 | ## Feature Structure 9 | - Features should be organized into their own directories 10 | - Each feature should export a clear public API 11 | - Internal implementation details should be hidden 12 | 13 | ## Component Structure 14 | - Components should follow a functional design pattern 15 | - Use composition over inheritance 16 | - Break down complex components into smaller, reusable parts 17 | - Co-locate related files (component, hooks, tests, types) 18 | 19 | ## Best Practices 20 | - Follow the principle of least privilege 21 | - Keep components small and focused 22 | - Extract reusable logic into custom hooks 23 | - Use named exports for most modules 24 | - Use default exports only for CSR components 25 | -------------------------------------------------------------------------------- /.cursor/rules/auto-attached/testing.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.ts,*.tsx 4 | alwaysApply: false 5 | --- 6 | # Testing Guidelines 7 | 8 | ## Testing Framework 9 | 10 | - Use `vitest` for unit testing 11 | - Use `playwirhgt` for e2e testing 12 | 13 | ## Unit Testing Best Practices 14 | 15 | - Tests are colocated next to the tested file 16 | - e.g. `dir/index.ts` and `dir/index.test.ts` 17 | - Each test should be independent 18 | - Use descriptive test names 19 | - Use mocks only if external dependencies are needed 20 | 21 | ## E2E Testing Best Practices 22 | 23 | - Reliable tests -------------------------------------------------------------------------------- /.cursor/rules/auto-attached/typescript.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.tsx,*.ts 4 | alwaysApply: false 5 | --- 6 | ## TypeScript 7 | 8 | - Use strict types, type-safe is VERY important. 9 | - Use `type` instead of `interface`. 10 | - Use camelCase for variable and function names. 11 | - Use PascalCase for component names. 12 | - Use double quotes for strings, use template literal if it's suitable. 13 | - Use arrow functions. 14 | - Use async/await for asynchronous code. -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: unvalley 6 | ko_fi: unvalley 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | ## General 2 | 3 | - Use MCP if you can work task fast with. 4 | - Before and after any tool use, give me a confidence level (0-5) on how the tool use will help the project, and put emoji ✅. 5 | - Please tell me you are running out of context windows. 6 | 7 | ## Coding Best Practices 8 | 9 | - Write comments only for complex logic in English 10 | - Care Perforamnce 11 | - Test and Refactor is VERY important 12 | - Use Functional Design 13 | - Use immutable data structure 14 | - Isolate Side Effects 15 | 16 | ## Rules 17 | 18 | - Please read .cursor/rules even if you are not Cursor. 19 | -------------------------------------------------------------------------------- /.github/guide.md: -------------------------------------------------------------------------------- 1 | Hi, here's your quick guide to _Ephe_. 2 | 3 | Give me just one quiet minute. 4 | 5 | 6 | Every morning, my head feels noisy. 7 | Ideas, todos, worries—all tangled. 8 | So I built Ephe to help organize my mind. 9 | 10 | Just one page. 11 | A calm space. 12 | I write what matters today. 13 | 14 | A task I want to focus on. 15 | A thought I didn’t know I had. 16 | A plan that formed as I typed. 17 | 18 | I write, and my mind feels lighter. 19 | That’s how I start my day. 20 | 21 | 22 | How to use Ephe? 23 | 24 | It stays out of your way, but helps where it counts: 25 | 26 | - `-[ ` or `- [ ` → auto-completes to `- [ ]` for tasks 27 | - Cmd + S → formats your Markdown 28 | - Cmd + Shift + S → takes a snapshot 29 | - You can see snapshots and task history recorded 30 | - Cmd + K → opens the quick command menu 31 | - Customize appearance (Dark mode, Paper style, Width) from the bottom-left settings 32 | - Task Flush (If set to instant, closing a task (`- [ ]`) deletes the line immediately) 33 | 34 | 35 | I hope this one page helps you start your day with focus. 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | NODE_VERSION: 22 15 | PNPM_VERSION: 10.x 16 | CACHE_KEY_PREFIX: v1 17 | 18 | jobs: 19 | setup: 20 | name: Setup 21 | runs-on: ubuntu-latest 22 | outputs: 23 | cache-hit: ${{ steps.pnpm-cache.outputs.cache-hit }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: pnpm/action-setup@v3 27 | with: 28 | version: ${{ env.PNPM_VERSION }} 29 | run_install: false 30 | 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: ${{ env.NODE_VERSION }} 34 | cache: 'pnpm' 35 | 36 | - name: Cache pnpm dependencies 37 | id: pnpm-cache 38 | uses: actions/cache@v4 39 | with: 40 | path: | 41 | **/node_modules 42 | key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-pnpm- 45 | 46 | - name: Install dependencies 47 | if: steps.pnpm-cache.outputs.cache-hit != 'true' 48 | run: pnpm install --frozen-lockfile 49 | 50 | lint: 51 | name: Lint 52 | needs: setup 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - uses: pnpm/action-setup@v3 58 | with: 59 | version: ${{ env.PNPM_VERSION }} 60 | run_install: false 61 | 62 | - uses: actions/setup-node@v4 63 | with: 64 | node-version: ${{ env.NODE_VERSION }} 65 | cache: 'pnpm' 66 | 67 | - name: Restore pnpm dependencies 68 | uses: actions/cache@v4 69 | with: 70 | path: | 71 | **/node_modules 72 | key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 73 | 74 | - name: Run Biome lint 75 | run: pnpm lint:biome 76 | 77 | - name: Run ESLint lint 78 | run: pnpm lint:eslint 79 | 80 | test: 81 | name: Test 82 | needs: setup 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v4 86 | 87 | - uses: pnpm/action-setup@v3 88 | with: 89 | version: ${{ env.PNPM_VERSION }} 90 | run_install: false 91 | 92 | - uses: actions/setup-node@v4 93 | with: 94 | node-version: ${{ env.NODE_VERSION }} 95 | cache: 'pnpm' 96 | 97 | - name: Restore pnpm dependencies 98 | uses: actions/cache@v4 99 | with: 100 | path: | 101 | **/node_modules 102 | key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 103 | 104 | - name: Restore Vitest cache 105 | uses: actions/cache@v4 106 | with: 107 | path: .vitest-cache 108 | key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-vitest-${{ hashFiles('**/*.ts', '**/*.tsx') }} 109 | restore-keys: | 110 | ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-vitest- 111 | 112 | - name: Install Playwright Browsers 113 | run: npx playwright install chromium 114 | 115 | - name: Run Vitest 116 | run: pnpm test -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | dist 20 | .dist 21 | 22 | # production 23 | /build 24 | 25 | # misc 26 | .DS_Store 27 | *.pem 28 | 29 | # debug 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | .pnpm-debug.log* 34 | 35 | # env files (can opt-in for committing if needed) 36 | .env* 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | next-env.d.ts 44 | 45 | bundle-size.html 46 | .specstory 47 | playwright-report 48 | test-results 49 | 50 | .claude -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 unvalley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Ephe

3 | 4 | Ephe screenshot 9 | 10 |

11 | Ephe is an ephemeral markdown paper 12 | to organize your daily todos and thoughts. 13 |

14 |
15 | 16 | Traditional todo apps can be overwhelming. 17 | Ephe is designed to organize your tasks with plain Markdown. 18 | Ephe gives you just one clean page to focus your day. 19 | 20 | See the guide for details. 21 | 22 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://next.biomejs.dev/schemas/latest/schema.json", 3 | "vcs": { 4 | "useIgnoreFile": true, 5 | "clientKind": "git", 6 | "enabled": true 7 | }, 8 | "linter": { 9 | "enabled": true, 10 | "rules": { 11 | "recommended": true, 12 | "correctness": { 13 | "useExhaustiveDependencies": { 14 | "options": {}, 15 | "level": "off" 16 | } 17 | }, 18 | "suspicious": { 19 | "noDoubleEquals": { 20 | "level": "error", 21 | "options": { 22 | "ignoreNull": true 23 | } 24 | } 25 | }, 26 | "nursery": { 27 | "useSortedClasses": { 28 | "level": "error", 29 | "fix": "unsafe", 30 | "options": { 31 | "attributes": [], 32 | "functions": [] 33 | } 34 | } 35 | } 36 | } 37 | }, 38 | "formatter": { 39 | "enabled": true, 40 | "formatWithErrors": false, 41 | "indentStyle": "space", 42 | "indentWidth": 2, 43 | "lineWidth": 120 44 | }, 45 | "javascript": { 46 | "formatter": { 47 | "quoteStyle": "double", 48 | "trailingCommas": "all" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /e2e/editor.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test.describe("Editor Page", () => { 4 | test("load page", async ({ page }) => { 5 | await page.goto("/"); 6 | // check loaded 7 | await expect(page.locator(".cm-editor")).toBeVisible(); 8 | await expect(page.locator("footer")).toBeVisible(); 9 | }); 10 | 11 | test("type text in editor", async ({ page }) => { 12 | await page.goto("/"); 13 | 14 | // text input 15 | await page.waitForSelector(".cm-editor"); 16 | await page.getByTestId("code-mirror-editor").focus(); 17 | await page.keyboard.type("# Hello World"); 18 | 19 | // check input 20 | const editorContent = await page.locator(".cm-content"); 21 | await expect(editorContent).toContainText("Hello World"); 22 | }); 23 | 24 | test("open and close command menu", async ({ page }) => { 25 | await page.goto("/"); 26 | await expect(page.locator('div[role="dialog"]')).not.toBeVisible(); 27 | 28 | // open command menu by Ctrl+K(ormd+K) 29 | const isMac = process.platform === "darwin"; 30 | const modifier = isMac ? "Meta" : "Control"; 31 | await page.keyboard.press(`${modifier}+k`); 32 | await expect(page.locator('div[role="dialog"]')).toBeVisible(); 33 | 34 | // close 35 | await page.keyboard.press(`${modifier}+k`); 36 | await expect(page.locator('div[role="dialog"]')).not.toBeVisible(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import tsParser from "@typescript-eslint/parser"; 2 | import reactHooks from "eslint-plugin-react-hooks"; 3 | import { defineConfig } from "eslint/config"; 4 | import globals from "globals"; 5 | 6 | // only for react-compiler lint 7 | export default defineConfig([ 8 | { 9 | files: ["src/**/*.{js,mjs,cjs,ts,jsx,tsx}"], 10 | languageOptions: { 11 | parser: tsParser, 12 | globals: globals.browser, 13 | }, 14 | }, 15 | { 16 | files: ["src/**/*.{js,mjs,cjs,ts,jsx,tsx}"], 17 | plugins: { "react-hooks": reactHooks }, 18 | rules: { 19 | "react-hooks/rules-of-hooks": "off", 20 | "react-hooks/exhaustive-deps": "off", 21 | "react-hooks/react-compiler": "error", 22 | }, 23 | }, 24 | { 25 | ignores: ["coverage/", "node_modules/", "dist/"], 26 | }, 27 | ]); 28 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ephe - Ephemeral Markdown Paper 8 | 9 | 10 | 11 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ephe", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "pnpm run copy:wasm && vite", 8 | "build": "pnpm run copy:wasm && tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "pnpm lint:biome && pnpm lint:eslint", 11 | "lint:biome": "biome lint", 12 | "lint:biome:write": "biome lint --write", 13 | "lint:eslint": "eslint --ext .ts,.tsx src", 14 | "lint:eslint:fix": "eslint --fix --ext .ts,.tsx src", 15 | "format": "biome format --write", 16 | "copy:wasm": "node --experimental-strip-types src/scripts/copy-wasm.ts", 17 | "test": "pnpm test:unit", 18 | "test:unit": "vitest src", 19 | "test:unit:ui": "vitest src --ui", 20 | "test:unit:coverage": "vitest src --coverage", 21 | "test:e2e": "playwright test", 22 | "test:e2e:ui": "playwright test --ui", 23 | "knip": "pnpm dlx knip" 24 | }, 25 | "dependencies": { 26 | "@codemirror/commands": "^6.8.1", 27 | "@codemirror/lang-markdown": "^6.3.2", 28 | "@codemirror/language": "^6.11.0", 29 | "@codemirror/language-data": "^6.5.1", 30 | "@codemirror/state": "^6.5.2", 31 | "@codemirror/view": "^6.36.5", 32 | "@dprint/formatter": "^0.4.1", 33 | "@dprint/markdown": "^0.18.0", 34 | "@headlessui/react": "^2.2.1", 35 | "@heroicons/react": "^2.2.0", 36 | "@lezer/highlight": "^1.2.1", 37 | "@tailwindcss/typography": "^0.5.16", 38 | "cmdk": "^1.1.1", 39 | "codemirror": "^6.0.1", 40 | "jotai": "^2.12.2", 41 | "react": "^19.0.0", 42 | "react-dom": "^19.0.0", 43 | "react-router-dom": "^7", 44 | "sonner": "^2.0.3", 45 | "use-debounce": "^10.0.4" 46 | }, 47 | "devDependencies": { 48 | "@biomejs/biome": "2.0.0-beta.6", 49 | "@playwright/test": "^1.51.1", 50 | "@tailwindcss/postcss": "^4.0.14", 51 | "@tailwindcss/vite": "^4.0.14", 52 | "@types/node": "^22", 53 | "@types/react": "^19", 54 | "@types/react-dom": "^19", 55 | "@typescript-eslint/parser": "^8.31.1", 56 | "@vitejs/plugin-react": "^4.4.1", 57 | "@vitest/browser": "^3.1.4", 58 | "@vitest/coverage-v8": "^3.1.4", 59 | "@vitest/ui": "3.0.8", 60 | "autoprefixer": "^10.4.14", 61 | "babel-plugin-react-compiler": "19.1.0-rc.1", 62 | "eslint": "^9.26.0", 63 | "eslint-plugin-react-hooks": "^6.0.0-rc.1", 64 | "globals": "^16.0.0", 65 | "playwright": "^1.52.0", 66 | "postcss": "^8.4.31", 67 | "rollup-plugin-visualizer": "^5.14.0", 68 | "tailwindcss": "^4", 69 | "typescript": "^5", 70 | "vite": "^6", 71 | "vite-plugin-top-level-await": "^1.5.0", 72 | "vite-plugin-wasm": "^3.4.1", 73 | "vitest": "^3.1.4" 74 | }, 75 | "overrides": { 76 | "vite": "npm:rolldown-vite@latest" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | testDir: "./e2e", 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, 7 | retries: process.env.CI ? 2 : 0, 8 | workers: process.env.CI ? 1 : undefined, 9 | reporter: "html", 10 | use: { 11 | trace: "on-first-retry", 12 | baseURL: "http://localhost:3000", 13 | }, 14 | projects: [ 15 | { 16 | name: "chromium", 17 | use: { ...devices["Desktop Chrome"] }, 18 | }, 19 | ], 20 | webServer: { 21 | command: "pnpm run dev", 22 | port: 3000, 23 | reuseExistingServer: !process.env.CI, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/ephe-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/4198f7951c49c64a40f96bc41d5dfb9b986c97eb/public/ephe-large.png -------------------------------------------------------------------------------- /public/ephe-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/4198f7951c49c64a40f96bc41d5dfb9b986c97eb/public/ephe-small.png -------------------------------------------------------------------------------- /public/ephe.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/4198f7951c49c64a40f96bc41d5dfb9b986c97eb/public/favicon.ico -------------------------------------------------------------------------------- /public/wasm/dprint-markdown.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/4198f7951c49c64a40f96bc41d5dfb9b986c97eb/public/wasm/dprint-markdown.wasm -------------------------------------------------------------------------------- /src/features/editor/codemirror/codemirror-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMarkdownEditor } from "./use-markdown-editor"; 4 | 5 | export const CodeMirrorEditor = () => { 6 | const { editor } = useMarkdownEditor(); 7 | 8 | return
; 9 | }; 10 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/auto-complete.browser.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | import { EditorState } from "@codemirror/state"; 3 | import { test, expect, describe } from "vitest"; 4 | import { taskAutoComplete } from "./auto-complete"; 5 | 6 | const createViewFromText = (text: string): EditorView => { 7 | const state = EditorState.create({ 8 | doc: text, 9 | extensions: [taskAutoComplete], 10 | }); 11 | return new EditorView({ state }); 12 | }; 13 | 14 | // Helper function to properly trigger input handlers 15 | const triggerInput = (view: EditorView, text: string) => { 16 | const pos = view.state.selection.main.head; 17 | 18 | // Get all input handlers from the view 19 | const handlers = view.state.facet(EditorView.inputHandler); 20 | 21 | // Try each handler until one handles the input 22 | for (const handler of handlers) { 23 | if (handler(view, pos, pos, text, () => view.state.update({ changes: { from: pos, to: pos, insert: text } }))) { 24 | return; // Handler processed the input 25 | } 26 | } 27 | 28 | // If no handler processed it, apply the input normally 29 | view.dispatch({ 30 | changes: { from: pos, to: pos, insert: text }, 31 | selection: { anchor: pos + text.length }, 32 | }); 33 | }; 34 | 35 | // Helper function to test the auto-complete functionality 36 | const testAutoComplete = (initialText: string, cursorPos: number, inputText: string) => { 37 | const view = createViewFromText(initialText); 38 | 39 | // Set cursor position 40 | view.dispatch({ 41 | selection: { anchor: cursorPos }, 42 | }); 43 | 44 | // Get the current state before input 45 | const beforeState = view.state.doc.toString(); 46 | 47 | // Trigger input using the proper method 48 | triggerInput(view, inputText); 49 | 50 | return { 51 | before: beforeState, 52 | after: view.state.doc.toString(), 53 | cursorPosition: view.state.selection.main.anchor, 54 | }; 55 | }; 56 | 57 | describe("taskAutoComplete", () => { 58 | test("basic functionality test - manual verification", () => { 59 | // This test verifies that the extension is properly configured 60 | // The actual auto-completion behavior is tested through integration 61 | const view = createViewFromText("- ["); 62 | expect(view.state.doc.toString()).toBe("- ["); 63 | 64 | // Verify the extension is loaded 65 | const handlers = view.state.facet(EditorView.inputHandler); 66 | expect(handlers.length).toBeGreaterThan(0); 67 | }); 68 | 69 | test("auto-complete with '- [ ' pattern", () => { 70 | const result = testAutoComplete("- [", 3, " "); 71 | 72 | expect(result.before).toBe("- ["); 73 | expect(result.after).toBe("- [ ] "); 74 | expect(result.cursorPosition).toBe(6); 75 | }); 76 | 77 | test("auto-complete with '-[ ' pattern", () => { 78 | const result = testAutoComplete("-[", 2, " "); 79 | 80 | expect(result.before).toBe("-["); 81 | expect(result.after).toBe("- [ ] "); 82 | expect(result.cursorPosition).toBe(6); 83 | }); 84 | 85 | test("auto-complete with indented '- [ ' pattern", () => { 86 | const result = testAutoComplete(" - [", 5, " "); 87 | 88 | expect(result.before).toBe(" - ["); 89 | expect(result.after).toBe(" - [ ] "); 90 | expect(result.cursorPosition).toBe(8); 91 | }); 92 | 93 | test("auto-complete with indented '-[ ' pattern", () => { 94 | const result = testAutoComplete(" -[", 4, " "); 95 | 96 | expect(result.before).toBe(" -["); 97 | expect(result.after).toBe(" - [ ] "); 98 | expect(result.cursorPosition).toBe(8); 99 | }); 100 | 101 | test("document structure remains intact without auto-complete trigger", () => { 102 | const result = testAutoComplete("some text", 9, " "); 103 | 104 | expect(result.before).toBe("some text"); 105 | expect(result.after).toBe("some text "); 106 | expect(result.cursorPosition).toBe(10); 107 | }); 108 | 109 | test("typing non-space characters does not trigger auto-complete", () => { 110 | const result = testAutoComplete("- [", 3, "x"); 111 | 112 | expect(result.before).toBe("- ["); 113 | expect(result.after).toBe("- [x"); 114 | expect(result.cursorPosition).toBe(4); 115 | }); 116 | 117 | test("partial patterns do not trigger auto-complete", () => { 118 | const result = testAutoComplete("- ", 2, " "); 119 | 120 | expect(result.before).toBe("- "); 121 | expect(result.after).toBe("- "); 122 | expect(result.cursorPosition).toBe(3); 123 | }); 124 | 125 | test("single dash pattern does not trigger auto-complete", () => { 126 | const result = testAutoComplete("-", 1, " "); 127 | 128 | expect(result.before).toBe("-"); 129 | expect(result.after).toBe("- "); 130 | expect(result.cursorPosition).toBe(2); 131 | }); 132 | 133 | test("early cursor position does not trigger auto-complete", () => { 134 | const result = testAutoComplete("ab", 2, " "); 135 | 136 | expect(result.before).toBe("ab"); 137 | expect(result.after).toBe("ab "); 138 | expect(result.cursorPosition).toBe(3); 139 | }); 140 | 141 | test("multi-line document structure", () => { 142 | const result = testAutoComplete("First line\nSecond line", 22, " "); 143 | 144 | expect(result.before).toBe("First line\nSecond line"); 145 | expect(result.after).toBe("First line\nSecond line "); 146 | expect(result.cursorPosition).toBe(23); 147 | }); 148 | 149 | test("extension integration with EditorView", () => { 150 | const view = createViewFromText("test content"); 151 | 152 | // Verify the view is properly initialized 153 | expect(view.state.doc.toString()).toBe("test content"); 154 | expect(view.state.doc.length).toBe(12); 155 | 156 | // Test basic editing functionality 157 | view.dispatch({ 158 | changes: { from: 12, to: 12, insert: " added" }, 159 | }); 160 | 161 | expect(view.state.doc.toString()).toBe("test content added"); 162 | }); 163 | 164 | test("cursor positioning after manual edits", () => { 165 | const view = createViewFromText("- ["); 166 | 167 | // Manually simulate what auto-complete should do 168 | view.dispatch({ 169 | changes: { from: 0, to: 3, insert: "- [ ] " }, 170 | selection: { anchor: 6 }, 171 | }); 172 | 173 | expect(view.state.doc.toString()).toBe("- [ ] "); 174 | expect(view.state.selection.main.anchor).toBe(6); 175 | }); 176 | 177 | test("line boundary handling", () => { 178 | const view = createViewFromText("line1\n- ["); 179 | const lineInfo = view.state.doc.lineAt(9); // Position after "- [" 180 | 181 | expect(lineInfo.number).toBe(2); 182 | expect(lineInfo.from).toBe(6); 183 | expect(lineInfo.to).toBe(9); 184 | }); 185 | 186 | test("pattern matching verification for both patterns", () => { 187 | const testCases = [ 188 | { text: "- [", pos: 3, shouldMatchLong: true, shouldMatchShort: false }, 189 | { text: "-[", pos: 2, shouldMatchLong: false, shouldMatchShort: true }, 190 | { text: " - [", pos: 5, shouldMatchLong: true, shouldMatchShort: false }, 191 | { text: " -[", pos: 4, shouldMatchLong: false, shouldMatchShort: true }, 192 | { text: "- ", pos: 2, shouldMatchLong: false, shouldMatchShort: false }, 193 | { text: "text", pos: 4, shouldMatchLong: false, shouldMatchShort: false }, 194 | { text: "ab", pos: 2, shouldMatchLong: false, shouldMatchShort: false }, 195 | ]; 196 | 197 | testCases.forEach(({ text, pos, shouldMatchLong, shouldMatchShort }) => { 198 | const view = createViewFromText(text); 199 | const line = view.state.doc.lineAt(pos); 200 | const linePrefix = view.state.doc.sliceString(line.from, pos); 201 | const matchesLong = linePrefix.endsWith("- [") && pos >= 3; 202 | const matchesShort = linePrefix.endsWith("-[") && pos >= 2; 203 | 204 | expect(matchesLong).toBe(shouldMatchLong); 205 | expect(matchesShort).toBe(shouldMatchShort); 206 | }); 207 | }); 208 | 209 | test("selection replacement does not trigger auto-complete", () => { 210 | const view = createViewFromText("- [test]"); 211 | 212 | // Select "test" and replace with space 213 | view.dispatch({ 214 | changes: { from: 3, to: 7, insert: " " }, 215 | selection: { anchor: 4 }, 216 | }); 217 | 218 | // Should not auto-complete because it's a selection replacement 219 | expect(view.state.doc.toString()).toBe("- [ ]"); 220 | expect(view.state.selection.main.anchor).toBe(4); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/auto-complete.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | 4 | // Auto-complete task items: converts both '- [ ' and '-[ ' to '- [ ] '. 5 | // Initially, `-[` was used to trigger the auto-complete, but this was conflicting with the `- []()` link syntax in lists. 6 | export const taskAutoComplete: Extension = EditorView.inputHandler.of((view, from, to, text) => { 7 | // Handle only the insertion of ' ' (space) 8 | if (text !== " ") return false; 9 | 10 | // If from !== to, it means a selection is being replaced, so don't autocomplete. 11 | // Also, ensure the insertion happens at a single point. 12 | if (from !== to) return false; 13 | 14 | const doc = view.state.doc; 15 | // Cannot check prefix if at the very beginning of the document 16 | if (from < 2) return false; // Need at least 2 characters for "-[" 17 | 18 | const line = doc.lineAt(from); 19 | // Get the text from the start of the line up to the cursor position (before space is inserted) 20 | const linePrefix = doc.sliceString(line.from, from); 21 | 22 | // Check for the pattern "- [" (3 characters) right before the cursor 23 | if (linePrefix.endsWith("- [")) { 24 | const insertFrom = from - 3; // Start replacing from the "- [" 25 | 26 | // Basic safety check: ensure insertFrom is not before the line start 27 | if (insertFrom < line.from) return false; 28 | 29 | // Dispatch the transaction to replace the trigger pattern with the task item 30 | view.dispatch({ 31 | changes: { 32 | from: insertFrom, 33 | to: from, 34 | insert: "- [ ] ", 35 | }, 36 | // Place the cursor after the inserted task item "- [ ] " 37 | selection: { anchor: insertFrom + 6 }, 38 | }); 39 | // Indicate that the input event was handled 40 | return true; 41 | } 42 | 43 | // Check for the pattern "-[" (2 characters) right before the cursor 44 | if (linePrefix.endsWith("-[")) { 45 | const insertFrom = from - 2; // Start replacing from the "-[" 46 | 47 | // Basic safety check: ensure insertFrom is not before the line start 48 | if (insertFrom < line.from) return false; 49 | 50 | // Dispatch the transaction to replace the trigger pattern with the task item 51 | view.dispatch({ 52 | changes: { 53 | from: insertFrom, 54 | to: from, 55 | insert: "- [ ] ", 56 | }, 57 | // Place the cursor after the inserted task item "- [ ] " 58 | selection: { anchor: insertFrom + 6 }, 59 | }); 60 | // Indicate that the input event was handled 61 | return true; 62 | } 63 | 64 | // If neither pattern matches, let the default input handling proceed 65 | return false; 66 | }); 67 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/index.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | import type { Extension } from "@codemirror/state"; 3 | import { taskDecoration, taskHoverField, taskMouseInteraction, type TaskHandler } from "./task-close"; 4 | import { taskKeyMap } from "./keymap"; 5 | import { taskAutoComplete } from "./auto-complete"; 6 | import { generateTaskIdentifier, type TaskStorage } from "../../tasks/task-storage"; 7 | import type { TaskAutoFlushMode } from "../../../../utils/hooks/use-task-auto-flush"; 8 | 9 | export type OnTaskClosed = { 10 | taskContent: string; 11 | originalLine: string; 12 | section?: string; 13 | pos: number; // what ? 14 | view: EditorView; 15 | }; 16 | 17 | export const createDefaultTaskHandler = ( 18 | taskStorage: TaskStorage, 19 | taskAutoFlushMode: TaskAutoFlushMode, 20 | ): TaskHandler => ({ 21 | onTaskClosed: ({ taskContent, originalLine, section, pos, view }: OnTaskClosed) => { 22 | const taskIdentifier = generateTaskIdentifier(taskContent); 23 | const timestamp = new Date().toISOString(); 24 | 25 | // Auto Flush 26 | if (taskAutoFlushMode === "instant" && view) { 27 | try { 28 | const line = view.state.doc.lineAt(pos); 29 | view.dispatch({ 30 | changes: { 31 | from: line.from, 32 | to: line.to + (view.state.doc.lines > line.number ? 1 : 0), 33 | }, 34 | }); 35 | } catch (e) { 36 | console.error("[AutoFlush Debug] Error deleting line:", e); 37 | } 38 | } 39 | 40 | // Save 41 | const task = Object.freeze({ 42 | id: taskIdentifier, 43 | content: taskContent, 44 | originalLine, 45 | taskIdentifier, 46 | section, 47 | completedAt: timestamp, 48 | }); 49 | 50 | taskStorage.save(task); 51 | }, 52 | onTaskOpen: (taskContent: string) => { 53 | const taskIdentifier = generateTaskIdentifier(taskContent); 54 | taskStorage.deleteByIdentifier(taskIdentifier); 55 | }, 56 | }); 57 | 58 | export const createChecklistPlugin = (taskHandler: TaskHandler): Extension => { 59 | return [taskDecoration, taskHoverField, taskMouseInteraction(taskHandler), taskAutoComplete, taskKeyMap]; 60 | }; 61 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/keymap.ts: -------------------------------------------------------------------------------- 1 | import { indentMore, indentLess } from "@codemirror/commands"; 2 | import { indentUnit } from "@codemirror/language"; 3 | import { type KeyBinding, type EditorView, keymap } from "@codemirror/view"; 4 | import { Prec } from "@codemirror/state"; 5 | import { 6 | isTaskLine, 7 | isTaskLineEndsWithSpace, 8 | isRegularListLine, 9 | isEmptyListLine, 10 | parseTaskLine, 11 | parseEmptyListLine, 12 | parseRegularListLine, 13 | } from "./task-list-utils"; 14 | 15 | const INDENT_SPACE = " "; 16 | 17 | // Regular expression to get leading whitespace 18 | const leadingWhitespaceRegex = /^(\s*)/; 19 | 20 | // Calculate just number of spaces 21 | const getIndentLength = (lineText: string): number => { 22 | const match = lineText.match(leadingWhitespaceRegex); 23 | return match ? match[1].length : 0; 24 | }; 25 | 26 | export const taskKeyBindings: readonly KeyBinding[] = [ 27 | { 28 | key: "Enter", 29 | run: (view: EditorView): boolean => { 30 | const { state } = view; 31 | const { selection } = state; 32 | 33 | // Handle only single cursor with no selection 34 | if (!selection.main.empty || selection.ranges.length > 1) { 35 | return false; 36 | } 37 | 38 | const pos = selection.main.head; 39 | const line = state.doc.lineAt(pos); 40 | 41 | // Check if cursor is at the end of line 42 | if (pos === line.to) { 43 | // Handle task lists 44 | if (isTaskLine(line.text)) { 45 | // If it's an empty task line (ends with space), delete it 46 | if (isTaskLineEndsWithSpace(line.text)) { 47 | const from = line.from; 48 | let to = line.to; 49 | let newCursorPos = from; 50 | 51 | // Include the newline character in deletion if it exists 52 | if (line.to < state.doc.length) { 53 | to += 1; // Include newline character 54 | newCursorPos = from; 55 | } else if (line.from > 0) { 56 | newCursorPos = from; 57 | } 58 | 59 | view.dispatch({ 60 | changes: { from: from, to: to }, 61 | selection: { anchor: newCursorPos }, 62 | }); 63 | return true; 64 | } 65 | 66 | // If it's a task line with content, create a new task item 67 | const parsed = parseTaskLine(line.text); 68 | if (parsed) { 69 | const { indent, bullet } = parsed; 70 | const newTaskLine = `\n${indent}${bullet} [ ] `; 71 | 72 | view.dispatch({ 73 | changes: { from: pos, insert: newTaskLine }, 74 | selection: { anchor: pos + newTaskLine.length }, 75 | }); 76 | return true; 77 | } 78 | } 79 | 80 | // Handle regular lists (non-task lists) 81 | if (isRegularListLine(line.text)) { 82 | // If it's an empty list line, delete it 83 | if (isEmptyListLine(line.text)) { 84 | const from = line.from; 85 | let to = line.to; 86 | let newCursorPos = from; 87 | 88 | // Include the newline character in deletion if it exists 89 | if (line.to < state.doc.length) { 90 | to += 1; // Include newline character 91 | newCursorPos = from; 92 | } else if (line.from > 0) { 93 | newCursorPos = from; 94 | } 95 | 96 | view.dispatch({ 97 | changes: { from: from, to: to }, 98 | selection: { anchor: newCursorPos }, 99 | }); 100 | return true; 101 | } 102 | 103 | // If it's a list line with content, create a new list item 104 | const parsed = parseRegularListLine(line.text); 105 | if (parsed?.content && parsed.content.trim() !== "") { 106 | // Only if there's actual content 107 | const { indent, bullet } = parsed; 108 | const newListLine = `\n${indent}${bullet} `; 109 | 110 | view.dispatch({ 111 | changes: { from: pos, insert: newListLine }, 112 | selection: { anchor: pos + newListLine.length }, 113 | }); 114 | return true; 115 | } 116 | } 117 | } 118 | 119 | // Fall back to default behavior (Markdown list continuation, etc.) 120 | return false; 121 | }, 122 | }, 123 | { 124 | key: "Tab", 125 | run: (view: EditorView): boolean => { 126 | const { state } = view; 127 | if (state.readOnly || state.selection.ranges.length > 1) return false; 128 | if (!state.selection.main.empty) { 129 | return indentMore(view); 130 | } 131 | 132 | const { head } = state.selection.main; 133 | const currentLine = state.doc.lineAt(head); 134 | const currentLineText = currentLine.text; 135 | 136 | // Handle both task lines and regular list lines 137 | if (!isTaskLine(currentLineText) && !isRegularListLine(currentLineText)) { 138 | return indentMore(view); 139 | } 140 | 141 | const currentIndentLength = getIndentLength(currentLineText); 142 | const indentUnitStr = state.facet(indentUnit); 143 | const indentUnitLength = indentUnitStr.length; 144 | 145 | // Skip if indent unit is invalid 146 | if (indentUnitLength <= 0) { 147 | return false; 148 | } 149 | 150 | // Check previous line if current line is not the first 151 | if (currentLine.number > 1) { 152 | const prevLine = state.doc.line(currentLine.number - 1); 153 | const prevLineText = prevLine.text; 154 | 155 | // Apply nesting logic for both task lines and regular list lines 156 | const isCurrentTask = isTaskLine(currentLineText); 157 | const isCurrentRegularList = isRegularListLine(currentLineText); 158 | const isPrevTask = isTaskLine(prevLineText); 159 | const isPrevRegularList = isRegularListLine(prevLineText); 160 | 161 | // Allow indenting if previous line is the same type (task or regular list) 162 | if ((isCurrentTask && isPrevTask) || (isCurrentRegularList && isPrevRegularList)) { 163 | const prevIndentLength = getIndentLength(prevLineText); 164 | 165 | // Prevent nesting more than one level deeper than the previous line 166 | const maxAllowedIndent = prevIndentLength + indentUnitLength; 167 | const newIndentLength = currentIndentLength + indentUnitLength; 168 | 169 | if (newIndentLength > maxAllowedIndent) { 170 | return true; // Block the tab if it would create too deep nesting 171 | } 172 | 173 | // Allow indenting within the limit 174 | return indentMore(view); 175 | } 176 | 177 | // If previous line is not the same type, block indenting for nested items 178 | if ((isCurrentTask || isCurrentRegularList) && currentIndentLength > 0) { 179 | return true; // Block indent for nested items without suitable sibling 180 | } 181 | } 182 | 183 | // Use default indentMore for all other cases 184 | return indentMore(view); 185 | }, 186 | }, 187 | { 188 | key: "Shift-Tab", 189 | run: (view: EditorView): boolean => { 190 | const { state } = view; 191 | if (state.readOnly) return false; 192 | const { head, empty } = state.selection.main; 193 | // For range selection or single cursor with indent unit at line start 194 | const line = state.doc.lineAt(head); 195 | 196 | // Handle both task lines and regular list lines that start with indent 197 | if (empty && line.text.startsWith(INDENT_SPACE) && (isTaskLine(line.text) || isRegularListLine(line.text))) { 198 | view.dispatch({ 199 | changes: { from: line.from, to: line.from + INDENT_SPACE.length, insert: "" }, 200 | userEvent: "delete.dedent", 201 | }); 202 | return true; 203 | } 204 | 205 | // Fall back to default behavior for non-list lines or lines without indent 206 | if (empty && line.text.startsWith(INDENT_SPACE)) { 207 | view.dispatch({ 208 | changes: { from: line.from, to: line.from + INDENT_SPACE.length, insert: "" }, 209 | userEvent: "delete.dedent", 210 | }); 211 | return true; 212 | } 213 | return indentLess(view); 214 | }, 215 | }, 216 | { 217 | key: "Delete", 218 | mac: "Backspace", 219 | run: (view: EditorView): boolean => { 220 | const { state } = view; 221 | const { selection } = state; 222 | 223 | // Use default behavior for selections or multiple cursors 224 | if (!selection.main.empty || selection.ranges.length > 1) { 225 | return false; 226 | } 227 | 228 | const pos = selection.main.head; 229 | const line = state.doc.lineAt(pos); 230 | 231 | // Use default behavior if cursor is not at line end 232 | // (Allow normal character deletion when Delete is pressed in the middle of line) 233 | if (pos !== line.to) { 234 | return false; 235 | } 236 | 237 | if (isTaskLineEndsWithSpace(line.text)) { 238 | // Convert empty task line to regular list line instead of deleting the entire line 239 | const parsed = parseTaskLine(line.text); 240 | if (parsed) { 241 | const { indent, bullet } = parsed; 242 | const newListLine = `${indent}${bullet} `; 243 | 244 | view.dispatch({ 245 | changes: { from: line.from, to: line.to, insert: newListLine }, 246 | selection: { anchor: line.from + newListLine.length }, 247 | userEvent: "delete.task-to-list", 248 | }); 249 | 250 | return true; // Suppress default Delete behavior 251 | } 252 | } 253 | 254 | // Handle empty regular list lines - convert to plain text 255 | if (isEmptyListLine(line.text)) { 256 | const parsed = parseEmptyListLine(line.text); 257 | if (parsed) { 258 | const { indent } = parsed; 259 | const newPlainLine = indent; 260 | 261 | view.dispatch({ 262 | changes: { from: line.from, to: line.to, insert: newPlainLine }, 263 | selection: { anchor: line.from + newPlainLine.length }, 264 | userEvent: "delete.list-to-text", 265 | }); 266 | 267 | return true; // Suppress default Delete behavior 268 | } 269 | } 270 | 271 | // Use default Delete behavior if pattern doesn't match 272 | return false; 273 | }, 274 | }, 275 | ]; 276 | 277 | export const taskKeyMap = Prec.high(keymap.of(taskKeyBindings)); 278 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/tas-section-utils.browser.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | import { findTaskSection } from "./task-section-utils"; 3 | import { EditorState } from "@codemirror/state"; 4 | import { test, expect, describe } from "vitest"; 5 | 6 | const createViewFromText = (text: string): EditorView => { 7 | const state = EditorState.create({ 8 | doc: text, 9 | }); 10 | return new EditorView({ state }); 11 | }; 12 | 13 | describe("findTaskSection", () => { 14 | test("finds the nearest heading above the given line", () => { 15 | const text = ` 16 | # Section 1 17 | Some text here 18 | 19 | ## Section 2 20 | - [ ] Task A 21 | - [ ] Task B 22 | `; 23 | const view = createViewFromText(text); 24 | // Task B is line 7 (1-based index) 25 | const result = findTaskSection(view, 7); 26 | expect(result).toBe("## Section 2"); 27 | }); 28 | 29 | test("returns undefined when no heading exists above", () => { 30 | const view = createViewFromText(`No heading here\nJust a line`); 31 | const result = findTaskSection(view, 2); 32 | expect(result).toBeUndefined(); 33 | }); 34 | 35 | test("skips non-heading lines and finds the correct one", () => { 36 | const text = ` 37 | Random text 38 | ### Actual Section 39 | - [ ] Something 40 | `; 41 | const view = createViewFromText(text); 42 | const result = findTaskSection(view, 4); 43 | expect(result).toBe("### Actual Section"); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/task-close.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditorView, 3 | ViewPlugin, 4 | type ViewUpdate, 5 | Decoration, 6 | type DecorationSet, 7 | type PluginValue, 8 | } from "@codemirror/view"; 9 | import { StateEffect, StateField, RangeSetBuilder } from "@codemirror/state"; 10 | import { findTaskSection } from "./task-section-utils"; 11 | import type { OnTaskClosed } from "."; 12 | 13 | export interface TaskHandler { 14 | onTaskClosed: ({ taskContent, originalLine, section }: OnTaskClosed) => void; 15 | onTaskOpen: (taskContent: string) => void; 16 | } 17 | 18 | // use utils 19 | const taskItemRegex = /^(\s*[-*]\s+)\[([ xX])\]/; 20 | 21 | type TaskInfo = { 22 | from: number; // start position of the task (') 23 | to: number; // end position of the task (']' next) 24 | contentPos: number; // position of the task content ('[' next) 25 | checked: boolean; // task state 26 | line: number; // Line number containing the task 27 | key: string; // Unique identifier for the task 28 | }; 29 | 30 | // Effect to track the task being hovered over 31 | const hoverTask = StateEffect.define(); 32 | 33 | // Decoration for the pointer style applied to all tasks 34 | const taskBaseStyle = Decoration.mark({ 35 | class: "cursor-pointer", 36 | inclusive: false, 37 | }); 38 | 39 | // Decoration for the additional highlight when hovering over a task 40 | const taskHoverStyle = Decoration.mark({ 41 | class: "cm-task-hover", 42 | inclusive: false, 43 | }); 44 | 45 | interface TaskPluginValue extends PluginValue { 46 | taskHandler?: TaskHandler; 47 | } 48 | 49 | // Single global task handler instance 50 | let globalTaskHandler: TaskHandler | undefined; 51 | 52 | export const registerTaskHandler = (handler: TaskHandler | undefined): void => { 53 | globalTaskHandler = handler; 54 | }; 55 | 56 | export const getRegisteredTaskHandler = (): TaskHandler | undefined => { 57 | return globalTaskHandler; 58 | }; 59 | 60 | // Utility to generate a unique key for a task 61 | const getTaskKey = (lineNumber: number, content: string): string => { 62 | return `${lineNumber}:${content.trim()}`; 63 | }; 64 | 65 | // Performance optimization: create a set to track tasks that have already triggered events 66 | // to avoid duplicate processing 67 | const processedTaskChanges = new Set(); 68 | 69 | export const taskDecoration = ViewPlugin.fromClass( 70 | class { 71 | taskes: TaskInfo[] = []; 72 | decorations: DecorationSet; 73 | prevTaskStates: Map = new Map(); // Track previous task states by key 74 | pendingChanges = false; 75 | changeTimer: number | null = null; 76 | 77 | constructor(view: EditorView) { 78 | this.taskes = this.findAllTaskes(view); 79 | this.decorations = this.createBaseDecorations(this.taskes); 80 | 81 | // Initialize previous states 82 | for (const task of this.taskes) { 83 | this.prevTaskStates.set(task.key, task.checked); 84 | } 85 | } 86 | 87 | // Detect all tasks in the document 88 | findAllTaskes(view: EditorView): TaskInfo[] { 89 | const result: TaskInfo[] = []; 90 | const { state } = view; 91 | const { doc } = state; 92 | 93 | // Process visible lines only for performance 94 | for (let i = 1; i <= doc.lines; i++) { 95 | const line = doc.line(i); 96 | const match = line.text.match(taskItemRegex); 97 | 98 | if (match) { 99 | // Search for the entire task pattern to determine the exact position 100 | const matchIndex = match.index || 0; 101 | const prefixLength = match[1].length; 102 | 103 | // Calculate the position of '[' 104 | const taskStartPos = matchIndex + prefixLength; 105 | const from = line.from + taskStartPos; 106 | const contentPos = from + 1; // next of '[' 107 | const to = from + 3; // '[' + content + ']' = 3 chars 108 | 109 | const checkChar = match[2]; 110 | const taskContent = line.text.substring(matchIndex + prefixLength + 3).trim(); 111 | 112 | result.push({ 113 | from, 114 | to, 115 | contentPos, 116 | checked: checkChar === "x" || checkChar === "X", 117 | line: i, 118 | key: getTaskKey(i, taskContent), 119 | }); 120 | } 121 | } 122 | return result; 123 | } 124 | 125 | // Create base decorations for all tasks 126 | createBaseDecorations(taskes: TaskInfo[]): DecorationSet { 127 | const builder = new RangeSetBuilder(); 128 | for (const { from, to } of taskes) { 129 | builder.add(from, to, taskBaseStyle); 130 | } 131 | return builder.finish(); 132 | } 133 | 134 | // Process changes in a batched, debounced way 135 | processTaskChanges(update: ViewUpdate) { 136 | const changedTasks: TaskInfo[] = []; 137 | 138 | // Collect tasks that have changed state 139 | for (const task of this.taskes) { 140 | const prevState = this.prevTaskStates.get(task.key); 141 | 142 | // If we have a previous state and it changed 143 | if (prevState !== undefined && prevState !== task.checked && !processedTaskChanges.has(task.key)) { 144 | changedTasks.push(task); 145 | // Mark this task as processed to avoid duplicate events 146 | processedTaskChanges.add(task.key); 147 | 148 | // Cleanup processed tasks after a delay to prevent memory leaks 149 | setTimeout(() => { 150 | processedTaskChanges.delete(task.key); 151 | }, 1000); 152 | } 153 | 154 | // Update the previous state 155 | this.prevTaskStates.set(task.key, task.checked); 156 | } 157 | 158 | // Process changed tasks 159 | if (changedTasks.length > 0) { 160 | for (const task of changedTasks) { 161 | try { 162 | const line = update.view.state.doc.line(task.line); 163 | const match = line.text.match(taskItemRegex); 164 | 165 | if (match) { 166 | const matchIndex = match.index || 0; 167 | const prefixLength = match[1].length; 168 | const taskEndIndex = matchIndex + prefixLength + 3; // '[ ]' or '[x]' is 3 chars 169 | const taskContent = line.text.substring(taskEndIndex).trim(); 170 | const section = findTaskSection(update.view, task.line); 171 | 172 | // Use the global task handler 173 | const handler = getRegisteredTaskHandler(); 174 | 175 | // Dispatch the appropriate event based on the task state change 176 | if (task.checked && handler) { 177 | // If task is being checked, call the handler 178 | handler.onTaskClosed({ 179 | taskContent, 180 | originalLine: line.text, 181 | section, 182 | pos: task.from, 183 | view: update.view, 184 | }); 185 | } else if (!task.checked && handler) { 186 | // If task is being unchecked, call the handler 187 | handler.onTaskOpen(taskContent); 188 | } 189 | } 190 | } catch (e) { 191 | console.warn("Error processing task change:", e); 192 | } 193 | } 194 | } 195 | } 196 | 197 | // Detect tasks when the document changes 198 | update(update: ViewUpdate) { 199 | if (update.docChanged) { 200 | // Performance optimization: only re-detect tasks if the document has changed 201 | this.taskes = this.findAllTaskes(update.view); 202 | this.decorations = this.createBaseDecorations(this.taskes); 203 | 204 | // Performance optimization: Debounce task state change processing to avoid 205 | // excessive processing during rapid edits 206 | if (this.changeTimer !== null) { 207 | window.clearTimeout(this.changeTimer); 208 | } 209 | 210 | this.changeTimer = window.setTimeout(() => { 211 | this.processTaskChanges(update); 212 | this.changeTimer = null; 213 | }, 50); // 50ms debounce 214 | } 215 | } 216 | }, 217 | { 218 | decorations: (v) => v.decorations, 219 | }, 220 | ); 221 | 222 | export const taskHoverField = StateField.define({ 223 | create() { 224 | return Decoration.none; 225 | }, 226 | update(decorations, tr) { 227 | decorations = decorations.map(tr.changes); 228 | for (const e of tr.effects) { 229 | if (e.is(hoverTask)) { 230 | const hoverInfo = e.value; 231 | if (hoverInfo) { 232 | // Create a new hover decoration 233 | const builder = new RangeSetBuilder(); 234 | builder.add(hoverInfo.from, hoverInfo.to, taskHoverStyle); 235 | return builder.finish(); 236 | } 237 | // Clear hover decoration 238 | return Decoration.none; 239 | } 240 | } 241 | 242 | return decorations; 243 | }, 244 | provide: (f) => EditorView.decorations.from(f), 245 | }); 246 | 247 | export const taskMouseInteraction = (taskHandler?: TaskHandler) => { 248 | return ViewPlugin.fromClass( 249 | class implements TaskPluginValue { 250 | taskHandler: TaskHandler | undefined; 251 | 252 | constructor(readonly view: EditorView) { 253 | this.taskHandler = taskHandler; 254 | this.handleMouseMove = this.handleMouseMove.bind(this); 255 | this.handleMouseLeave = this.handleMouseLeave.bind(this); 256 | this.handleMouseDown = this.handleMouseDown.bind(this); 257 | 258 | this.view.dom.addEventListener("mousemove", this.handleMouseMove); 259 | this.view.dom.addEventListener("mouseleave", this.handleMouseLeave); 260 | this.view.dom.addEventListener("mousedown", this.handleMouseDown); 261 | } 262 | 263 | destroy() { 264 | this.view.dom.removeEventListener("mousemove", this.handleMouseMove); 265 | this.view.dom.removeEventListener("mouseleave", this.handleMouseLeave); 266 | this.view.dom.removeEventListener("mousedown", this.handleMouseDown); 267 | } 268 | 269 | getTaskAt(pos: number): TaskInfo | null { 270 | const line = this.view.state.doc.lineAt(pos); 271 | const match = line.text.match(taskItemRegex); 272 | if (!match) return null; 273 | 274 | const matchIndex = match.index || 0; 275 | const prefixLength = match[1].length; 276 | const taskStartPos = matchIndex + prefixLength; 277 | const from = line.from + taskStartPos; 278 | const contentPos = from + 1; 279 | const to = from + 3; // next of ']' 280 | 281 | if (pos >= from && pos < to) { 282 | const checkChar = match[2]; 283 | const taskContent = line.text.substring(matchIndex + prefixLength + 3).trim(); 284 | 285 | return { 286 | from, 287 | to, 288 | contentPos, 289 | checked: checkChar === "x" || checkChar === "X", 290 | line: line.number, 291 | key: getTaskKey(line.number, taskContent), 292 | }; 293 | } 294 | return null; 295 | } 296 | 297 | handleMouseMove(event: MouseEvent) { 298 | // Performance optimization: Only check on real mouse movement 299 | // Skip duplicate events at the same coordinates 300 | const pos = this.view.posAtCoords({ x: event.clientX, y: event.clientY }); 301 | if (pos === null) return; 302 | const task = this.getTaskAt(pos); 303 | 304 | this.view.dispatch({ 305 | effects: hoverTask.of(task), 306 | }); 307 | } 308 | 309 | handleMouseLeave() { 310 | this.view.dispatch({ 311 | effects: hoverTask.of(null), 312 | }); 313 | } 314 | 315 | handleMouseDown(event: MouseEvent) { 316 | // only left click 317 | if (event.button !== 0) return; 318 | 319 | const pos = this.view.posAtCoords({ x: event.clientX, y: event.clientY }); 320 | if (pos == null) return; 321 | 322 | const task = this.getTaskAt(pos); 323 | if (!task) return; 324 | 325 | event.preventDefault(); 326 | const newChar = task.checked ? " " : "x"; 327 | 328 | this.view.dispatch({ 329 | changes: { 330 | from: task.contentPos, 331 | to: task.contentPos + 1, 332 | insert: newChar, 333 | }, 334 | userEvent: "input.toggleTask", 335 | }); 336 | } 337 | }, 338 | ); 339 | }; 340 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/task-list-utils.browser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from "vitest"; 2 | import { isClosedTaskLine, isOpenTaskLine, isTaskLine, isRegularListLine, isEmptyListLine } from "./task-list-utils"; 3 | 4 | describe("isTaskLine", () => { 5 | test("identifies task list lines correctly", () => { 6 | expect(isTaskLine("- [ ] Task")).toBe(true); 7 | expect(isTaskLine("- [x] Task")).toBe(true); 8 | expect(isTaskLine("- [X] Task")).toBe(true); 9 | expect(isTaskLine(" - [ ] Indented task")).toBe(true); 10 | expect(isTaskLine("* [ ] Task")).toBe(true); 11 | expect(isTaskLine("* [x] Task")).toBe(true); 12 | expect(isTaskLine("* [X] Task")).toBe(true); 13 | 14 | expect(isTaskLine("Not a task")).toBe(false); 15 | expect(isTaskLine("- Not a task")).toBe(false); 16 | }); 17 | }); 18 | 19 | describe("isClosedTaskLine", () => { 20 | test("identifies closed tasks correctly", () => { 21 | expect(isClosedTaskLine("- [x] Task")).toBe(true); 22 | expect(isClosedTaskLine("- [X] Task")).toBe(true); 23 | expect(isClosedTaskLine(" - [x] Indented task")).toBe(true); 24 | expect(isClosedTaskLine("* [x] Task")).toBe(true); 25 | expect(isClosedTaskLine("* [X] Task")).toBe(true); 26 | 27 | expect(isClosedTaskLine("- [ ] Unchecked task")).toBe(false); 28 | expect(isClosedTaskLine("Not a task")).toBe(false); 29 | }); 30 | }); 31 | 32 | describe("isOpenTaskLine", () => { 33 | test("identifies open tasks correctly", () => { 34 | expect(isOpenTaskLine("- [ ] Task")).toBe(true); 35 | expect(isOpenTaskLine(" - [ ] Indented task")).toBe(true); 36 | expect(isOpenTaskLine("* [ ] Task")).toBe(true); 37 | 38 | expect(isOpenTaskLine("- [x] Checked task")).toBe(false); 39 | expect(isOpenTaskLine("- [X] Checked task")).toBe(false); 40 | expect(isOpenTaskLine("Not a task")).toBe(false); 41 | }); 42 | }); 43 | 44 | describe("isRegularListLine", () => { 45 | test("should detect regular list items with dash", () => { 46 | expect(isRegularListLine("- item")).toBe(true); 47 | expect(isRegularListLine(" - indented item")).toBe(true); 48 | expect(isRegularListLine("- ")).toBe(true); // Empty list item is still a list line 49 | }); 50 | 51 | test("should detect regular list items with asterisk", () => { 52 | expect(isRegularListLine("* item")).toBe(true); 53 | expect(isRegularListLine(" * indented item")).toBe(true); 54 | expect(isRegularListLine("* ")).toBe(true); 55 | }); 56 | 57 | test("should detect regular list items with plus", () => { 58 | expect(isRegularListLine("+ item")).toBe(true); 59 | expect(isRegularListLine(" + indented item")).toBe(true); 60 | expect(isRegularListLine("+ ")).toBe(true); 61 | }); 62 | 63 | test("should not detect task list items", () => { 64 | expect(isRegularListLine("- [ ] task")).toBe(false); 65 | expect(isRegularListLine("- [x] completed task")).toBe(false); 66 | expect(isRegularListLine("* [ ] task")).toBe(false); 67 | expect(isRegularListLine(" - [ ] indented task")).toBe(false); 68 | }); 69 | 70 | test("should not detect non-list content", () => { 71 | expect(isRegularListLine("regular text")).toBe(false); 72 | expect(isRegularListLine("")).toBe(false); 73 | expect(isRegularListLine(" regular text")).toBe(false); 74 | }); 75 | }); 76 | 77 | describe("isEmptyListLine", () => { 78 | test("should detect empty list items", () => { 79 | expect(isEmptyListLine("- ")).toBe(true); 80 | expect(isEmptyListLine("* ")).toBe(true); 81 | expect(isEmptyListLine("+ ")).toBe(true); 82 | expect(isEmptyListLine(" - ")).toBe(true); 83 | expect(isEmptyListLine(" * ")).toBe(true); 84 | expect(isEmptyListLine("-")).toBe(true); // Without trailing space 85 | expect(isEmptyListLine("*")).toBe(true); 86 | }); 87 | 88 | test("should not detect list items with content", () => { 89 | expect(isEmptyListLine("- item")).toBe(false); 90 | expect(isEmptyListLine("* content")).toBe(false); 91 | expect(isEmptyListLine("+ text")).toBe(false); 92 | expect(isEmptyListLine(" - content")).toBe(false); 93 | }); 94 | 95 | test("should not detect task list items", () => { 96 | expect(isEmptyListLine("- [ ]")).toBe(false); 97 | expect(isEmptyListLine("- [x]")).toBe(false); 98 | expect(isEmptyListLine("* [ ]")).toBe(false); 99 | }); 100 | 101 | test("should not detect non-list content", () => { 102 | expect(isEmptyListLine("")).toBe(false); 103 | expect(isEmptyListLine("text")).toBe(false); 104 | expect(isEmptyListLine(" text")).toBe(false); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/task-list-utils.ts: -------------------------------------------------------------------------------- 1 | // hit to `- [ ]` or `- [x]` or `- [X]` or `* [ ]` or `* [x]` or `* [X]` 2 | const TASK_LINE_REGEX = /^(\s*)[-*] \[\s*([xX ])\s*\]/; 3 | // hit to `- [ ]` or `* [ ]` 4 | const OPEN_TASK_LINE_REGEX = /^(\s*)[-*] \[\s* \s*\]/; 5 | // hit to `- [x]` or `- [X]` or `* [x]` or `* [X]` 6 | const CLOSED_TASK_LINE_REGEX = /^(\s*)[-*] \[\s*[xX]\s*\]/; 7 | // hit to `- [ ]` or `- [x]` or `- [X]` or `* [ ]` or `* [x]` or `* [X]` but ends with `\s*` 8 | const TASK_LINE_ENDS_WITH_SPACE_REGEX = /^(\s*[-*]\s+\[[ xX]\])\s*$/; 9 | 10 | // Regular list patterns (non-task lists) 11 | // hit to `- item` or `* item` or `+ item` 12 | const REGULAR_LIST_REGEX = /^(\s*)([-*+])\s+(.*)$/; 13 | // hit to `- ` or `* ` or `+ ` (empty list items) 14 | const EMPTY_LIST_REGEX = /^(\s*)([-*+])\s*$/; 15 | 16 | /** 17 | * Checks if a line contains a task list item 18 | * - [ ] or - [x] or - [X] or * [ ] or * [x] or * [X] 19 | */ 20 | export const isTaskLine = (lineContent: string): boolean => { 21 | return !!lineContent.match(TASK_LINE_REGEX); 22 | }; 23 | 24 | /** 25 | * Checks if a line contains a regular list item (non-task) 26 | * - item or * item or + item 27 | */ 28 | export const isRegularListLine = (lineContent: string): boolean => { 29 | const taskMatch = lineContent.match(TASK_LINE_REGEX); 30 | if (taskMatch) return false; // Task lines are not regular list lines 31 | 32 | return !!lineContent.match(REGULAR_LIST_REGEX); 33 | }; 34 | 35 | /** 36 | * Checks if a line is an empty regular list item 37 | * - or * or + (with optional trailing spaces) 38 | */ 39 | export const isEmptyListLine = (lineContent: string): boolean => { 40 | return !!lineContent.match(EMPTY_LIST_REGEX); 41 | }; 42 | 43 | /** 44 | * Checks if a line contains an open task 45 | * - [ ] or * [ ] 46 | */ 47 | export const isOpenTaskLine = (lineContent: string): boolean => { 48 | return !!lineContent.match(OPEN_TASK_LINE_REGEX); 49 | }; 50 | 51 | /** 52 | * Checks if a line contains a checked task 53 | * - [x] or - [X] or * [x] or * [X] 54 | */ 55 | export const isClosedTaskLine = (lineContent: string): boolean => { 56 | return !!lineContent.match(CLOSED_TASK_LINE_REGEX); 57 | }; 58 | 59 | /** 60 | * Checks if a line contains a task list item that ends with a space 61 | * - [ ] or - [x] or - [X] or * [ ] or * [x] or * [X] 62 | */ 63 | export const isTaskLineEndsWithSpace = (lineContent: string): boolean => { 64 | return !!lineContent.match(TASK_LINE_ENDS_WITH_SPACE_REGEX); 65 | }; 66 | 67 | /** 68 | * Extracts task line components (indent, bullet) from a task line 69 | * Returns null if not a task line 70 | */ 71 | export const parseTaskLine = (lineContent: string): { indent: string; bullet: string } | null => { 72 | const match = lineContent.match(TASK_LINE_REGEX); 73 | if (!match) return null; 74 | return { 75 | indent: match[1], 76 | bullet: match[0].includes("*") ? "*" : "-", 77 | }; 78 | }; 79 | 80 | /** 81 | * Extracts empty list line components (indent, bullet) from an empty list line 82 | * Returns null if not an empty list line 83 | */ 84 | export const parseEmptyListLine = (lineContent: string): { indent: string; bullet: string } | null => { 85 | const match = lineContent.match(EMPTY_LIST_REGEX); 86 | if (!match) return null; 87 | return { 88 | indent: match[1], 89 | bullet: match[2], 90 | }; 91 | }; 92 | 93 | /** 94 | * Extracts regular list line components (indent, bullet, content) from a regular list line 95 | * Returns null if not a regular list line 96 | */ 97 | export const parseRegularListLine = ( 98 | lineContent: string, 99 | ): { indent: string; bullet: string; content: string } | null => { 100 | const match = lineContent.match(REGULAR_LIST_REGEX); 101 | if (!match) return null; 102 | return { 103 | indent: match[1], 104 | bullet: match[2], 105 | content: match[3], 106 | }; 107 | }; 108 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/task-section-utils.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | 3 | const MARKDOWN_HEADING = /^#{1,6}\s+\S/; 4 | 5 | /** 6 | * Return the nearest markdown heading above `lineNumber`. 7 | * `undefined` if none exists. 8 | */ 9 | export const findTaskSection = (view: EditorView, lineNumber: number): string | undefined => { 10 | const { doc } = view.state; 11 | 12 | for (let i = lineNumber; i >= 1; i--) { 13 | const text = doc.line(i).text.trim(); 14 | if (MARKDOWN_HEADING.test(text)) return text; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/url-click.browser.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | import { EditorState } from "@codemirror/state"; 3 | import { test, expect, describe, vi } from "vitest"; 4 | import { urlClickPlugin } from "./url-click"; 5 | 6 | const createViewFromText = (text: string): EditorView => { 7 | const state = EditorState.create({ 8 | doc: text, 9 | extensions: [urlClickPlugin], 10 | }); 11 | return new EditorView({ state }); 12 | }; 13 | 14 | describe("urlClickPlugin", () => { 15 | test("creates editor with URL plugin", () => { 16 | const view = createViewFromText("Visit https://example.com for more info"); 17 | 18 | expect(view.state.doc.toString()).toBe("Visit https://example.com for more info"); 19 | expect(view).toBeDefined(); 20 | }); 21 | 22 | test("handles text with multiple URLs", () => { 23 | const text = "Check https://example.com and http://example.net"; 24 | const view = createViewFromText(text); 25 | 26 | expect(view.state.doc.toString()).toBe(text); 27 | }); 28 | 29 | test("handles text without URLs", () => { 30 | const view = createViewFromText("This is just plain text without any links"); 31 | 32 | expect(view.state.doc.toString()).toBe("This is just plain text without any links"); 33 | }); 34 | 35 | test("detects URLs with various protocols", () => { 36 | const testCases = [ 37 | "https://example.com", 38 | "http://example.com", 39 | "https://sub.example.com/path?query=value", 40 | "http://localhost:3000/api/test", 41 | ]; 42 | 43 | testCases.forEach((url) => { 44 | const view = createViewFromText(`Link: ${url}`); 45 | expect(view.state.doc.toString()).toBe(`Link: ${url}`); 46 | }); 47 | }); 48 | 49 | test("handles multiline text with URLs", () => { 50 | const text = `First line with https://example.com 51 | Second line with http://example.net 52 | Third line without URL`; 53 | 54 | const view = createViewFromText(text); 55 | expect(view.state.doc.toString()).toBe(text); 56 | }); 57 | 58 | test("URL regex pattern validation", () => { 59 | const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g; 60 | 61 | const validUrls = [ 62 | "https://example.com", 63 | "http://example.com", 64 | "https://sub.example.com/path", 65 | "http://localhost:3000", 66 | "https://example.com/path?query=value&other=test", 67 | ]; 68 | 69 | const invalidUrls = ["ftp://example.com", "example.com", "https://", "http://"]; 70 | 71 | validUrls.forEach((url) => { 72 | expect(url.match(urlRegex)).toBeTruthy(); 73 | }); 74 | 75 | invalidUrls.forEach((url) => { 76 | expect(url.match(urlRegex)).toBeFalsy(); 77 | }); 78 | }); 79 | 80 | test("plugin integration", () => { 81 | const view = createViewFromText("Visit https://example.com"); 82 | 83 | expect(view).toBeDefined(); 84 | expect(view.state).toBeDefined(); 85 | }); 86 | 87 | test("window.open mock test", () => { 88 | const mockOpen = vi.fn(); 89 | const originalOpen = window.open; 90 | window.open = mockOpen; 91 | 92 | try { 93 | window.open("https://example.com", "_blank", "noopener,noreferrer"); 94 | expect(mockOpen).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer"); 95 | } finally { 96 | window.open = originalOpen; 97 | } 98 | }); 99 | 100 | test("editor view creation with plugin", () => { 101 | const view = createViewFromText("Test content with https://example.com link"); 102 | 103 | expect(view.state.doc.toString()).toBe("Test content with https://example.com link"); 104 | expect(view.dom).toBeDefined(); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/url-click.ts: -------------------------------------------------------------------------------- 1 | import { type EditorView, Decoration, type DecorationSet, ViewPlugin, type ViewUpdate } from "@codemirror/view"; 2 | import { RangeSetBuilder } from "@codemirror/state"; 3 | 4 | const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g; 5 | const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`[\]()]+/g; 6 | 7 | const urlDecoration = Decoration.mark({ 8 | class: "cm-url", 9 | attributes: { style: "cursor: pointer;" }, 10 | }); 11 | 12 | type Range = { from: number; to: number }; 13 | 14 | const getMatchRanges = (text: string, regex: RegExp): Range[] => 15 | Array.from(text.matchAll(regex)).flatMap((m) => 16 | m.index !== undefined 17 | ? [ 18 | { 19 | from: m.index, 20 | to: m.index + m[0].length, 21 | }, 22 | ] 23 | : [], 24 | ); 25 | 26 | const overlaps = (a: Range, b: Range) => !(a.to <= b.from || a.from >= b.to); 27 | 28 | const createUrlDecorations = (view: EditorView): DecorationSet => { 29 | const builder = new RangeSetBuilder(); 30 | const doc = view.state.doc; 31 | 32 | for (let i = 1; i <= doc.lines; i++) { 33 | const { from: lineStart, text } = doc.line(i); 34 | const mdRanges = getMatchRanges(text, MARKDOWN_LINK_REGEX); 35 | 36 | for (const { from, to } of mdRanges) { 37 | builder.add(lineStart + from, lineStart + to, urlDecoration); 38 | } 39 | 40 | const urlRanges = getMatchRanges(text, URL_REGEX).filter((range) => !mdRanges.some((md) => overlaps(range, md))); 41 | 42 | for (const { from, to } of urlRanges) { 43 | builder.add(lineStart + from, lineStart + to, urlDecoration); 44 | } 45 | } 46 | 47 | return builder.finish(); 48 | }; 49 | 50 | export const urlClickPlugin = ViewPlugin.fromClass( 51 | class { 52 | decorations: DecorationSet; 53 | 54 | constructor(view: EditorView) { 55 | this.decorations = createUrlDecorations(view); 56 | } 57 | 58 | update(update: ViewUpdate) { 59 | if (update.docChanged || update.viewportChanged) { 60 | this.decorations = createUrlDecorations(update.view); 61 | } 62 | } 63 | }, 64 | { 65 | decorations: (v) => v.decorations, 66 | eventHandlers: { 67 | mousedown: (event, view) => { 68 | const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); 69 | if (pos == null) return false; 70 | 71 | const { from, text } = view.state.doc.lineAt(pos); 72 | const offset = pos - from; 73 | 74 | for (const match of text.matchAll(MARKDOWN_LINK_REGEX)) { 75 | const index = match.index; 76 | if (index !== undefined && offset >= index && offset < index + match[0].length) { 77 | window.open(match[2], "_blank", "noopener,noreferrer"); 78 | event.preventDefault(); 79 | return true; 80 | } 81 | } 82 | 83 | for (const match of text.matchAll(URL_REGEX)) { 84 | const index = match.index; 85 | if (index !== undefined && offset >= index && offset < index + match[0].length) { 86 | window.open(match[0], "_blank", "noopener,noreferrer"); 87 | event.preventDefault(); 88 | return true; 89 | } 90 | } 91 | 92 | return false; 93 | }, 94 | }, 95 | }, 96 | ); 97 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/use-editor-theme.ts: -------------------------------------------------------------------------------- 1 | import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; 2 | import { EditorView } from "@codemirror/view"; 3 | import { tags } from "@lezer/highlight"; 4 | 5 | /** 6 | * Manages the CodeMirror theme and highlight style based on dark mode and editor width. 7 | */ 8 | export const useEditorTheme = (isDarkMode: boolean, isWideMode: boolean) => { 9 | const getHighlightStyle = () => { 10 | const COLORS = isDarkMode ? EPHE_COLORS.dark : EPHE_COLORS.light; 11 | const CODE_SYNTAX_HIGHLIGHT = isDarkMode ? SYNTAX_HIGHLIGHT_STYLES.dark : SYNTAX_HIGHLIGHT_STYLES.light; 12 | 13 | const epheHighlightStyle = HighlightStyle.define([ 14 | ...CODE_SYNTAX_HIGHLIGHT, 15 | 16 | // Markdown Style 17 | { tag: tags.heading, color: COLORS.heading }, 18 | { 19 | tag: tags.heading1, 20 | color: COLORS.heading, 21 | fontSize: "1.2em", 22 | }, 23 | { 24 | tag: tags.heading2, 25 | color: COLORS.heading, 26 | fontSize: "1.2em", 27 | }, 28 | { 29 | tag: tags.heading3, 30 | color: COLORS.heading, 31 | fontSize: "1.1em", 32 | }, 33 | { tag: tags.emphasis, color: COLORS.emphasis, fontStyle: "italic" }, 34 | { tag: tags.strong, color: COLORS.emphasis }, 35 | { tag: tags.link, color: COLORS.string, textDecoration: "underline" }, 36 | { tag: tags.url, color: COLORS.string, textDecoration: "underline" }, 37 | { tag: tags.monospace, color: COLORS.constant, fontFamily: "monospace" }, 38 | ]); 39 | 40 | const theme = { 41 | "&": { 42 | height: "100%", 43 | width: "100%", 44 | background: COLORS.background, 45 | color: COLORS.foreground, 46 | }, 47 | ".cm-content": { 48 | fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace", 49 | fontSize: "16px", 50 | padding: "60px 20px", 51 | lineHeight: "1.6", 52 | maxWidth: isWideMode ? "100%" : "680px", 53 | margin: "0 auto", 54 | caretColor: COLORS.foreground, 55 | }, 56 | ".cm-cursor": { 57 | borderLeftColor: COLORS.foreground, 58 | borderLeftWidth: "2px", 59 | }, 60 | "&.cm-editor": { 61 | outline: "none", 62 | border: "none", 63 | background: "transparent", 64 | }, 65 | "&.cm-focused": { 66 | outline: "none", 67 | }, 68 | ".cm-scroller": { 69 | fontFamily: "monospace", 70 | background: "transparent", 71 | }, 72 | ".cm-gutters": { 73 | background: "transparent", 74 | border: "none", 75 | }, 76 | ".cm-activeLineGutter": { 77 | background: "transparent", 78 | }, 79 | ".cm-line": { 80 | padding: "0 4px 0 0", 81 | }, 82 | }; 83 | 84 | return { 85 | editorTheme: EditorView.theme(theme), 86 | editorHighlightStyle: syntaxHighlighting(epheHighlightStyle, { fallback: true }), 87 | }; 88 | }; 89 | 90 | return getHighlightStyle(); 91 | }; 92 | 93 | const EPHE_COLORS = { 94 | light: { 95 | background: "#FFFFFF", 96 | foreground: "#111111", 97 | comment: "#9E9E9E", 98 | keyword: "#111111", 99 | string: "#616161", 100 | number: "#555555", 101 | type: "#333333", 102 | function: "#555555", 103 | variable: "#666666", 104 | constant: "#555555", 105 | operator: "#757575", 106 | heading: "#000000", 107 | emphasis: "#000000", 108 | }, 109 | dark: { 110 | background: "#121212", 111 | foreground: "#F5F5F5", 112 | comment: "#757575", 113 | keyword: "#F5F5F5", 114 | string: "#AAAAAA", 115 | number: "#BDBDBD", 116 | type: "#E0E0E0", 117 | function: "#C0C0C0", 118 | variable: "#D0D0D0", 119 | constant: "#E0E0E0", 120 | operator: "#999999", 121 | heading: "#FFFFFF", 122 | emphasis: "#FFFFFF", 123 | }, 124 | } as const; 125 | 126 | const SYNTAX_HIGHLIGHT_STYLES = { 127 | light: [ 128 | { tag: tags.comment, color: "#6a737d", fontStyle: "italic" }, 129 | { tag: tags.keyword, color: "#d73a49" }, 130 | { tag: tags.string, color: "#032f62" }, 131 | { tag: tags.number, color: "#005cc5" }, 132 | { tag: tags.typeName, color: "#e36209", fontStyle: "italic" }, 133 | { tag: tags.function(tags.variableName), color: "#6f42c1" }, 134 | { tag: tags.definition(tags.variableName), color: "#22863a" }, 135 | { tag: tags.variableName, color: "#24292e" }, 136 | { tag: tags.constant(tags.variableName), color: "#b31d28" }, 137 | { tag: tags.operator, color: "#d73a49" }, 138 | ], 139 | dark: [ 140 | { tag: tags.comment, color: "#9ca3af", fontStyle: "italic" }, 141 | { tag: tags.keyword, color: "#f97583" }, 142 | { tag: tags.string, color: "#9ecbff" }, 143 | { tag: tags.number, color: "#79b8ff" }, 144 | { tag: tags.typeName, color: "#ffab70", fontStyle: "italic" }, 145 | { tag: tags.function(tags.variableName), color: "#b392f0" }, 146 | { tag: tags.definition(tags.variableName), color: "#85e89d" }, 147 | { tag: tags.variableName, color: "#e1e4e8" }, 148 | { tag: tags.constant(tags.variableName), color: "#f97583" }, 149 | { tag: tags.operator, color: "#f97583" }, 150 | ], 151 | } as const; 152 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/use-markdown-editor.ts: -------------------------------------------------------------------------------- 1 | import { defaultKeymap, historyKeymap, history } from "@codemirror/commands"; 2 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; 3 | import { languages } from "@codemirror/language-data"; 4 | import { Compartment, EditorState, Prec } from "@codemirror/state"; 5 | import { EditorView, keymap, placeholder } from "@codemirror/view"; 6 | import { useAtom } from "jotai"; 7 | import { useRef, useState, useEffect, useLayoutEffect } from "react"; 8 | import { showToast } from "../../../utils/components/toast"; 9 | import { useEditorWidth } from "../../../utils/hooks/use-editor-width"; 10 | import { useTheme } from "../../../utils/hooks/use-theme"; 11 | import { DprintMarkdownFormatter } from "../markdown/formatter/dprint-markdown-formatter"; 12 | import { getRandomQuote } from "../quotes"; 13 | import { taskStorage } from "../tasks/task-storage"; 14 | import { createDefaultTaskHandler, createChecklistPlugin } from "./tasklist"; 15 | import { registerTaskHandler } from "./tasklist/task-close"; 16 | import { atomWithStorage, createJSONStorage } from "jotai/utils"; 17 | import { LOCAL_STORAGE_KEYS } from "../../../utils/constants"; 18 | import { snapshotStorage } from "../../snapshots/snapshot-storage"; 19 | import { useEditorTheme } from "./use-editor-theme"; 20 | import { useCharCount } from "../../../utils/hooks/use-char-count"; 21 | import { useTaskAutoFlush } from "../../../utils/hooks/use-task-auto-flush"; 22 | import { useMobileDetector } from "../../../utils/hooks/use-mobile-detector"; 23 | import { urlClickPlugin } from "./url-click"; 24 | 25 | const storage = createJSONStorage(() => localStorage); 26 | 27 | const crossTabStorage = { 28 | ...storage, 29 | subscribe: (key: string, callback: (value: string) => void, initialValue: string): (() => void) => { 30 | if (typeof window === "undefined" || typeof window.addEventListener !== "function") { 31 | return () => {}; 32 | } 33 | const handler = (e: StorageEvent) => { 34 | if (e.storageArea === localStorage && e.key === key) { 35 | try { 36 | const newValue = e.newValue ? JSON.parse(e.newValue) : initialValue; 37 | callback(newValue); 38 | } catch { 39 | callback(initialValue); 40 | } 41 | } 42 | }; 43 | window.addEventListener("storage", handler); 44 | return () => window.removeEventListener("storage", handler); 45 | }, 46 | }; 47 | 48 | const editorAtom = atomWithStorage(LOCAL_STORAGE_KEYS.EDITOR_CONTENT, "", crossTabStorage); 49 | 50 | const useMarkdownFormatter = () => { 51 | const ref = useRef(null); 52 | useEffect(() => { 53 | let alive = true; 54 | (async () => { 55 | const fmt = await DprintMarkdownFormatter.getInstance(); 56 | if (alive) { 57 | ref.current = fmt; 58 | } 59 | })(); 60 | return () => { 61 | alive = false; 62 | ref.current = null; 63 | }; 64 | }, []); 65 | return ref; 66 | }; 67 | 68 | const useTaskHandler = () => { 69 | const { taskAutoFlushMode } = useTaskAutoFlush(); 70 | const handlerRef = useRef(createDefaultTaskHandler(taskStorage, taskAutoFlushMode)); 71 | useEffect(() => { 72 | handlerRef.current = createDefaultTaskHandler(taskStorage, taskAutoFlushMode); 73 | registerTaskHandler(handlerRef.current); 74 | return () => registerTaskHandler(undefined); 75 | }, [taskAutoFlushMode]); 76 | return handlerRef; 77 | }; 78 | 79 | export const useMarkdownEditor = () => { 80 | const editor = useRef(null); 81 | const [container, setContainer] = useState(); 82 | const [view, setView] = useState(); 83 | const [content, setContent] = useAtom(editorAtom); 84 | 85 | const formatterRef = useMarkdownFormatter(); 86 | const taskHandlerRef = useTaskHandler(); 87 | 88 | const { isDarkMode } = useTheme(); 89 | const { isWideMode } = useEditorWidth(); 90 | const { editorTheme, editorHighlightStyle } = useEditorTheme(isDarkMode, isWideMode); 91 | const { setCharCount } = useCharCount(); 92 | const { isMobile } = useMobileDetector(); 93 | 94 | const themeCompartment = useRef(new Compartment()).current; 95 | const highlightCompartment = useRef(new Compartment()).current; 96 | 97 | // Listen for content restore events 98 | useEffect(() => { 99 | const handleContentRestored = (event: CustomEvent<{ content: string }>) => { 100 | if (view && event.detail.content) { 101 | // Update the editor content 102 | view.dispatch({ 103 | changes: { from: 0, to: view.state.doc.length, insert: event.detail.content }, 104 | }); 105 | // Also update the atom value to keep them in sync 106 | setContent(event.detail.content); 107 | } 108 | }; 109 | // Add event listener with type assertion 110 | window.addEventListener("ephe:content-restored", handleContentRestored as EventListener); 111 | return () => { 112 | // Remove event listener on cleanup 113 | window.removeEventListener("ephe:content-restored", handleContentRestored as EventListener); 114 | }; 115 | }, [view, setContent]); 116 | 117 | // Listen for external content updates 118 | // - text edit emits storage event 119 | // - subscribe updates the editor content 120 | useEffect(() => { 121 | if (view && content !== view.state.doc.toString()) { 122 | view.dispatch({ 123 | changes: { from: 0, to: view.state.doc.length, insert: content }, 124 | }); 125 | } 126 | setCharCount(content.length); 127 | }, [view, content]); 128 | 129 | const onFormat = async (view: EditorView) => { 130 | if (!formatterRef.current) { 131 | showToast("Formatter not initialized yet", "error"); 132 | return false; 133 | } 134 | try { 135 | // format 136 | const { state } = view; 137 | const scrollTop = view.scrollDOM.scrollTop; 138 | const cursorPos = state.selection.main.head; 139 | const cursorLine = state.doc.lineAt(cursorPos); 140 | const cursorLineNumber = cursorLine.number; 141 | const cursorColumn = cursorPos - cursorLine.from; 142 | const currentText = state.doc.toString(); 143 | const formattedText = await formatterRef.current.formatMarkdown(currentText); 144 | if (formattedText !== currentText) { 145 | view.dispatch({ 146 | changes: { from: 0, to: state.doc.length, insert: formattedText }, 147 | }); 148 | // Restore cursor position after formatting 149 | try { 150 | const newState = view.state; 151 | const newDocLineCount = newState.doc.lines; 152 | if (cursorLineNumber <= newDocLineCount) { 153 | const newLine = newState.doc.line(cursorLineNumber); 154 | const newColumn = Math.min(cursorColumn, newLine.length); 155 | const newPos = newLine.from + newColumn; 156 | view.dispatch({ selection: { anchor: newPos, head: newPos } }); 157 | } 158 | } catch (selectionError) { 159 | view.dispatch({ selection: { anchor: 0, head: 0 } }); 160 | } 161 | view.scrollDOM.scrollTop = Math.min(scrollTop, view.scrollDOM.scrollHeight - view.scrollDOM.clientHeight); 162 | } 163 | 164 | showToast("Document formatted", "default"); 165 | return true; 166 | } catch (error) { 167 | const message = error instanceof Error ? error.message : "unknown"; 168 | showToast(`Error formatting document: ${message}`, "error"); 169 | return false; 170 | } 171 | }; 172 | 173 | const onSaveSnapshot = async (view: EditorView) => { 174 | try { 175 | const currentText = view.state.doc.toString(); 176 | 177 | // snapshot 178 | snapshotStorage.save({ 179 | content: currentText, 180 | charCount: currentText.length, 181 | }); 182 | 183 | showToast("Snapshot saved", "default"); 184 | return true; 185 | } catch (error) { 186 | const message = error instanceof Error ? error.message : "unknown"; 187 | showToast(`Error saving snapshot: ${message}`, "error"); 188 | return false; 189 | } 190 | }; 191 | 192 | useEffect(() => { 193 | if (editor.current) setContainer(editor.current); 194 | }, []); 195 | 196 | useLayoutEffect(() => { 197 | if (!view && container) { 198 | const state = EditorState.create({ 199 | doc: content, 200 | extensions: [ 201 | keymap.of(defaultKeymap), 202 | history(), 203 | keymap.of(historyKeymap), 204 | 205 | // Task key bindings with high priority BEFORE markdown extension 206 | Prec.high(createChecklistPlugin(taskHandlerRef.current)), 207 | 208 | markdown({ 209 | base: markdownLanguage, 210 | codeLanguages: languages, 211 | addKeymap: true, 212 | }), 213 | 214 | EditorView.lineWrapping, 215 | EditorView.updateListener.of((update) => { 216 | if (update.docChanged) { 217 | const updatedContent = update.state.doc.toString(); 218 | window.requestAnimationFrame(() => { 219 | setContent(updatedContent); 220 | }); 221 | } 222 | }), 223 | 224 | themeCompartment.of(editorTheme), 225 | highlightCompartment.of(editorHighlightStyle), 226 | // Only show placeholder on non-mobile devices 227 | ...(isMobile ? [] : [placeholder(getRandomQuote())]), 228 | 229 | keymap.of([ 230 | { 231 | key: "Mod-s", 232 | run: (targetView) => { 233 | onFormat(targetView); 234 | return true; 235 | }, 236 | preventDefault: true, 237 | }, 238 | { 239 | key: "Mod-Shift-s", 240 | run: (targetView) => { 241 | onSaveSnapshot(targetView); 242 | return true; 243 | }, 244 | preventDefault: true, 245 | }, 246 | ]), 247 | urlClickPlugin, 248 | ], 249 | }); 250 | const viewCurrent = new EditorView({ state, parent: container }); 251 | setView(viewCurrent); 252 | viewCurrent.focus(); // store focus 253 | } 254 | }, [ 255 | view, 256 | container, 257 | content, 258 | setContent, 259 | onFormat, 260 | onSaveSnapshot, 261 | highlightCompartment.of, 262 | themeCompartment.of, 263 | editorTheme, 264 | editorHighlightStyle, 265 | isMobile, 266 | ]); 267 | 268 | // Update theme when dark mode changes 269 | useEffect(() => { 270 | if (view) { 271 | view.dispatch({ 272 | effects: [themeCompartment.reconfigure(editorTheme), highlightCompartment.reconfigure(editorHighlightStyle)], 273 | }); 274 | } 275 | }, [view, highlightCompartment.reconfigure, themeCompartment.reconfigure, editorTheme, editorHighlightStyle]); 276 | 277 | return { 278 | editor, 279 | view, 280 | onFormat: view ? () => onFormat(view) : undefined, 281 | onSaveSnapshot: view ? () => onSaveSnapshot(view) : undefined, 282 | }; 283 | }; 284 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/use-toc.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import { EditorView } from "@codemirror/view"; 3 | 4 | export type TocItem = { 5 | level: number; 6 | text: string; 7 | from: number; 8 | }; 9 | 10 | type UseTableOfContentsProps = { 11 | editorView: EditorView; 12 | content: string; 13 | }; 14 | 15 | const HEADING_REGEX = /^(#{1,6})\s+(.+)$/; 16 | 17 | export const useTableOfContentsCodeMirror = ({ editorView, content }: UseTableOfContentsProps) => { 18 | const [tocItems, setTocItems] = useState([]); 19 | 20 | // 1. Parse table of contents items from content (calculate from position) 21 | const parseTocItems = useCallback((text: string): TocItem[] => { 22 | if (!text) return []; 23 | const lines = text.split("\n"); 24 | const items: TocItem[] = []; 25 | let currentPos = 0; 26 | 27 | for (const lineText of lines) { 28 | const match = HEADING_REGEX.exec(lineText); 29 | if (match) { 30 | items.push({ 31 | level: match[1].length, 32 | text: match[2].trim(), 33 | from: currentPos, 34 | }); 35 | } 36 | currentPos += lineText.length + 1; 37 | } 38 | return items; 39 | }, []); 40 | 41 | // 2. Effect to regenerate table of contents items when content changes 42 | useEffect(() => { 43 | const newItems = parseTocItems(content); 44 | setTocItems(newItems); 45 | }, [content, parseTocItems]); 46 | 47 | // 3. Function to focus on section when table of contents item is clicked (using CodeMirror API) 48 | const focusOnSection = useCallback( 49 | (from: number) => { 50 | if (!editorView) return; 51 | 52 | const transaction = editorView.state.update({ 53 | // Move cursor to the beginning of the heading line 54 | selection: { anchor: from }, 55 | // Scroll so the specified position is centered in the viewport 56 | effects: EditorView.scrollIntoView(from, { y: "center" }), 57 | // Specify user event type if needed 58 | // userEvent: "select.toc" 59 | }); 60 | 61 | editorView.dispatch(transaction); 62 | editorView.focus(); 63 | }, 64 | [editorView], 65 | ); 66 | 67 | return { 68 | tocItems, 69 | focusOnSection, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/features/editor/markdown/formatter/dprint-markdown-formatter.ts: -------------------------------------------------------------------------------- 1 | import { createFromBuffer, type GlobalConfiguration } from "@dprint/formatter"; 2 | import { getPath } from "@dprint/markdown"; 3 | import type { MarkdownFormatter, FormatterConfig } from "./markdown-formatter"; 4 | 5 | /** 6 | * Markdown formatter implementation using dprint's high-performance WASM 7 | */ 8 | export class DprintMarkdownFormatter implements MarkdownFormatter { 9 | private static instance: DprintMarkdownFormatter | null = null; 10 | private formatter: { 11 | formatText(params: { filePath: string; fileText: string }): string; 12 | setConfig(globalConfig: GlobalConfiguration, specificConfig?: Record): void; 13 | } | null = null; 14 | private isInitialized = false; 15 | private config: FormatterConfig; 16 | private static readonly WASM_URL = "/wasm/dprint-markdown.wasm"; 17 | 18 | /** 19 | * Private constructor to enforce singleton pattern 20 | */ 21 | private constructor(config: FormatterConfig = {}) { 22 | this.config = config; 23 | } 24 | 25 | /** 26 | * Get or create the singleton instance with provided config 27 | */ 28 | public static async getInstance(config: FormatterConfig = {}): Promise { 29 | if (!DprintMarkdownFormatter.instance) { 30 | DprintMarkdownFormatter.instance = new DprintMarkdownFormatter(config); 31 | await DprintMarkdownFormatter.instance.initialize(); 32 | } 33 | return DprintMarkdownFormatter.instance; 34 | } 35 | 36 | /** 37 | * Check if running in browser environment 38 | */ 39 | private isBrowser(): boolean { 40 | return typeof window !== "undefined" && typeof document !== "undefined"; 41 | } 42 | 43 | /** 44 | * Load WASM buffer based on environment 45 | */ 46 | private async loadWasmBuffer(): Promise { 47 | if (this.isBrowser()) { 48 | // In browser environment, fetch WASM from static assets 49 | const response = await fetch(DprintMarkdownFormatter.WASM_URL); 50 | if (!response.ok) { 51 | throw new Error(`Failed to fetch WASM: ${response.statusText}`); 52 | } 53 | const arrayBuffer = await response.arrayBuffer(); 54 | return new Uint8Array(arrayBuffer); 55 | } 56 | 57 | // In Node.js environment, load WASM directly from file 58 | // Note: This code won't be executed when bundled with Vite 59 | const fs = await import("node:fs"); 60 | const wasmPath = getPath(); 61 | const buffer = fs.readFileSync(wasmPath); 62 | return new Uint8Array(buffer); 63 | } 64 | 65 | private async initialize(): Promise { 66 | if (this.isInitialized) return; 67 | 68 | try { 69 | // Load WASM buffer based on current environment 70 | const wasmBuffer = await this.loadWasmBuffer(); 71 | 72 | this.formatter = await createFromBuffer(wasmBuffer); 73 | 74 | this.setConfig({ 75 | indentWidth: this.config.indentWidth ?? 2, 76 | lineWidth: this.config.lineWidth ?? 80, 77 | useTabs: this.config.useTabs ?? false, 78 | newLineKind: this.config.newLineKind ?? "auto", 79 | }); 80 | 81 | this.isInitialized = true; 82 | } catch (error) { 83 | console.error("Failed to initialize dprint markdown formatter:", error); 84 | throw error; 85 | } 86 | } 87 | 88 | // should check dprint markdown config if you want to add more options 89 | private setConfig(globalConfig: GlobalConfiguration, dprintMarkdownConfig: Record = {}): void { 90 | if (!this.formatter) { 91 | throw new Error("Formatter not initialized. Call initialize() first."); 92 | } 93 | this.formatter.setConfig(globalConfig, dprintMarkdownConfig); 94 | } 95 | 96 | /** 97 | * Format markdown text 98 | */ 99 | public async formatMarkdown(text: string): Promise { 100 | if (!this.isInitialized || !this.formatter) { 101 | throw new Error("Formatter not initialized properly"); 102 | } 103 | 104 | return this.formatter.formatText({ 105 | filePath: "ephe.md", 106 | fileText: text, 107 | }); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/features/editor/markdown/formatter/markdown-formatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Markdown formatter interface for dependency inversion 3 | */ 4 | export type MarkdownFormatter = { 5 | /** 6 | * Format markdown text 7 | */ 8 | formatMarkdown(text: string): Promise; 9 | }; 10 | 11 | /** 12 | * Configuration for markdown formatter 13 | */ 14 | export type FormatterConfig = { 15 | indentWidth?: number; 16 | lineWidth?: number; 17 | useTabs?: boolean; 18 | newLineKind?: "auto" | "lf" | "crlf" | "system"; 19 | [key: string]: unknown; 20 | }; 21 | -------------------------------------------------------------------------------- /src/features/editor/quotes.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "vitest"; 2 | import { getRandomQuote, WRITING_QUOTES } from "./quotes"; 3 | 4 | describe("getRandomQuote", () => { 5 | test("should return a quote from the WRITING_QUOTES array", () => { 6 | // Run multiple times to increase confidence due to randomness 7 | for (let i = 0; i < 5; i++) { 8 | const quote = getRandomQuote(); 9 | expect(WRITING_QUOTES).toContain(quote); 10 | } 11 | }); 12 | 13 | test("should return different quotes over multiple calls (probabilistic)", () => { 14 | const quotes = new Set(); 15 | // Generate multiple quotes, expecting at least some difference 16 | for (let i = 0; i < 5; i++) { 17 | quotes.add(getRandomQuote()); 18 | } 19 | expect(quotes.size).toBeGreaterThan(1); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/features/editor/quotes.ts: -------------------------------------------------------------------------------- 1 | export const WRITING_QUOTES = [ 2 | "The scariest moment is always just before you start.", 3 | "Fill your paper with the breathings of your heart.", 4 | "The pen is mightier than the sword.", 5 | "The best way to predict the future is to invent it.", 6 | "The only way to do great work is to love what you do.", 7 | "A word after a word after a word is power.", 8 | "Get things done.", 9 | "Later equals never.", 10 | "Divide and conquer.", 11 | ]; 12 | 13 | export const getRandomQuote = (): string => { 14 | return WRITING_QUOTES[Math.floor(Math.random() * WRITING_QUOTES.length)]; 15 | }; 16 | -------------------------------------------------------------------------------- /src/features/editor/table-of-contents.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type React from "react"; 4 | import { useState, useEffect, useRef, useCallback } from "react"; 5 | import { useTheme } from "../../utils/hooks/use-theme"; 6 | 7 | type TocItem = { 8 | level: number; 9 | text: string; 10 | line: number; 11 | }; 12 | 13 | type TocProps = { 14 | content: string; 15 | onItemClick: (line: number) => void; 16 | isVisible: boolean; 17 | }; 18 | 19 | const HEADING_REGEX = /^(#{1,6})\s+(.+)$/; 20 | 21 | export const TableOfContents: React.FC = ({ content, onItemClick, isVisible }) => { 22 | const [tocItems, setTocItems] = useState([]); 23 | const { isDarkMode } = useTheme(); 24 | const prevContentRef = useRef(""); 25 | const hasInitializedRef = useRef(false); 26 | 27 | const parseTocItems = useCallback((content: string): TocItem[] => { 28 | if (!content) return []; 29 | 30 | const lines = content.split("\n"); 31 | const items: TocItem[] = []; 32 | 33 | for (let i = 0; i < lines.length; i++) { 34 | const line = lines[i]; 35 | const match = HEADING_REGEX.exec(line); 36 | if (match) { 37 | items.push({ 38 | level: match[1].length, 39 | text: match[2].trim(), 40 | line: i, 41 | }); 42 | } 43 | } 44 | 45 | return items; 46 | }, []); 47 | 48 | // Always parse content on initial render and when content changes 49 | useEffect(() => { 50 | // Always parse content regardless of visibility 51 | if (!content) { 52 | setTocItems([]); 53 | return; 54 | } 55 | 56 | // Always parse on initial load or when content changes 57 | const shouldUpdate = !hasInitializedRef.current || content !== prevContentRef.current; 58 | 59 | if (shouldUpdate) { 60 | const newItems = parseTocItems(content); 61 | setTocItems(newItems); 62 | prevContentRef.current = content; 63 | hasInitializedRef.current = true; 64 | } 65 | }, [content, parseTocItems]); 66 | 67 | const shouldRender = isVisible && tocItems.length > 0 && !!content; 68 | if (!shouldRender) { 69 | return null; 70 | } 71 | 72 | return ( 73 |
74 |
    75 | {tocItems.map((item) => ( 76 |
  • onItemClick(item.line)} 84 | onKeyDown={() => {}} 85 | > 86 | {item.text} 87 |
  • 88 | ))} 89 |
90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/features/editor/tasks/task-storage.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE_KEYS } from "../../../utils/constants"; 2 | import { 3 | createBrowserLocalStorage, 4 | createStorage, 5 | type DateFilter, 6 | defaultStorageProvider, 7 | filterItemsByDate, 8 | groupItemsByDate, 9 | type StorageProvider, 10 | } from "../../../utils/storage"; 11 | 12 | export interface TaskStorage { 13 | getAll: () => CompletedTask[]; 14 | getById: (id: string) => CompletedTask | null; 15 | save: (task: CompletedTask) => void; 16 | deleteById: (id: string) => void; 17 | deleteByIdentifier: (taskIdentifier: string) => void; 18 | deleteAll: () => void; 19 | getByDate: (filter?: DateFilter) => Record; 20 | } 21 | /** 22 | * Generate a unique identifier for a task based on its content 23 | */ 24 | export const generateTaskIdentifier = (taskContent: string): string => { 25 | let hash = 0; 26 | for (let i = 0; i < taskContent.length; i++) { 27 | const char = taskContent.charCodeAt(i); 28 | hash = (hash << 5) - hash + char; 29 | hash = hash & hash; // Convert to 32bit integer 30 | } 31 | return `task-${Math.abs(hash)}`; 32 | }; 33 | 34 | // Task Storage factory function 35 | const createTaskStorage = (storage: StorageProvider = createBrowserLocalStorage()): TaskStorage => { 36 | const baseStorage = createStorage(storage, LOCAL_STORAGE_KEYS.COMPLETED_TASKS); 37 | 38 | // Task specific operations 39 | const save = (task: CompletedTask): void => { 40 | baseStorage.save(task); 41 | }; 42 | 43 | const deleteItem = (id: string): void => { 44 | baseStorage.deleteById(id); 45 | }; 46 | 47 | const deleteByIdentifier = (taskIdentifier: string): void => { 48 | try { 49 | const tasks = baseStorage.getAll(); 50 | const updatedTasks = tasks.filter((task) => task.taskIdentifier !== taskIdentifier); 51 | storage.setItem(LOCAL_STORAGE_KEYS.COMPLETED_TASKS, JSON.stringify(updatedTasks)); 52 | } catch (error) { 53 | console.error("Error deleting task by identifier:", error); 54 | } 55 | }; 56 | 57 | const purgeAll = (): void => { 58 | baseStorage.deleteAll(); 59 | }; 60 | 61 | const getByDate = (filter?: DateFilter): Record => { 62 | const tasks = baseStorage.getAll(); 63 | const filteredTasks = filterItemsByDate(tasks, filter); 64 | return groupItemsByDate(filteredTasks); 65 | }; 66 | 67 | return { 68 | ...baseStorage, 69 | save, 70 | deleteById: deleteItem, 71 | deleteByIdentifier, 72 | deleteAll: purgeAll, 73 | getByDate, 74 | }; 75 | }; 76 | 77 | export const taskStorage = createTaskStorage(defaultStorageProvider); 78 | 79 | export type CompletedTask = { 80 | id: string; 81 | content: string; 82 | completedAt: string; // ISO string 83 | originalLine: string; 84 | taskIdentifier: string; // Unique identifier for the task 85 | section: string | undefined; // Section name the task belongs to 86 | }; 87 | -------------------------------------------------------------------------------- /src/features/history/history-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogPanel, 4 | Tab, 5 | TabGroup, 6 | TabList, 7 | TabPanel, 8 | TabPanels, 9 | Transition, 10 | TransitionChild, 11 | } from "@headlessui/react"; 12 | import { Fragment, useEffect, useState } from "react"; 13 | import type { Snapshot } from "../snapshots/snapshot-storage"; 14 | import { useHistoryData } from "./use-history-data"; 15 | 16 | type HistoryModalProps = { 17 | isOpen: boolean; 18 | onClose: () => void; 19 | initialTabIndex?: number; 20 | }; 21 | 22 | const formatDate = (dateString: string) => { 23 | const date = new Date(dateString); 24 | return new Intl.DateTimeFormat("en-US", { 25 | year: "numeric", 26 | month: "short", 27 | day: "numeric", 28 | hour: "2-digit", 29 | minute: "2-digit", 30 | }).format(date); 31 | }; 32 | 33 | const BUTTON_STYLES = { 34 | primary: 35 | "rounded border border-transparent bg-neutral-100 px-4 py-2 text-sm transition-colors hover:bg-neutral-200 focus-visible:ring-offset-2 dark:bg-neutral-700/50 dark:hover:bg-neutral-600", 36 | danger: 37 | "rounded bg-red-100 px-3 py-1.5 text-red-700 text-sm hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50", 38 | close: 39 | "rounded border border-transparent bg-neutral-100 px-4 py-2 text-sm transition-colors hover:bg-neutral-200 focus-visible:ring-offset-2 dark:bg-neutral-700 dark:hover:bg-neutral-600", 40 | } as const; 41 | 42 | export const HistoryModal = ({ isOpen, onClose, initialTabIndex = 0 }: HistoryModalProps) => { 43 | const [selectedTabIndex, setSelectedTabIndex] = useState(initialTabIndex); 44 | const [selectedSnapshot, setSelectedSnapshot] = useState(null); 45 | const { 46 | snapshots, 47 | tasks, 48 | isLoading, 49 | handleRestoreSnapshot, 50 | handleDeleteSnapshot, 51 | handleDeleteAllSnapshots, 52 | refresh, 53 | } = useHistoryData(); 54 | 55 | useEffect(() => { 56 | setSelectedTabIndex(initialTabIndex); 57 | }, [initialTabIndex]); 58 | 59 | useEffect(() => { 60 | if (isOpen) { 61 | refresh(); 62 | } 63 | }, [isOpen, refresh]); 64 | 65 | useEffect(() => { 66 | if (isOpen && snapshots.length > 0 && !selectedSnapshot) { 67 | setSelectedSnapshot(snapshots[0]); 68 | } 69 | }, [isOpen, snapshots, selectedSnapshot]); 70 | 71 | const handleSnapshotClick = (snapshot: Snapshot) => { 72 | setSelectedSnapshot(snapshot); 73 | }; 74 | 75 | const handleKeyDown = (e: React.KeyboardEvent, snapshot: Snapshot) => { 76 | if (e.key === "Enter" || e.key === " ") { 77 | handleSnapshotClick(snapshot); 78 | } 79 | }; 80 | 81 | const handleRestore = () => { 82 | if (selectedSnapshot) { 83 | handleRestoreSnapshot(selectedSnapshot); 84 | onClose(); 85 | } 86 | }; 87 | 88 | const handleDelete = () => { 89 | if (selectedSnapshot && confirm("Are you sure you want to delete this snapshot?")) { 90 | handleDeleteSnapshot(selectedSnapshot.id); 91 | 92 | if (snapshots.length > 1) { 93 | const newSelectedIndex = snapshots.findIndex((s) => s.id === selectedSnapshot.id); 94 | const nextIndex = newSelectedIndex === 0 ? 1 : newSelectedIndex - 1; 95 | setSelectedSnapshot(snapshots[nextIndex >= 0 ? nextIndex : 0]); 96 | } else { 97 | setSelectedSnapshot(null); 98 | } 99 | } 100 | }; 101 | 102 | const handleDeleteAll = () => { 103 | if ( 104 | snapshots.length > 0 && 105 | confirm("Are you sure you want to delete all snapshots? This action cannot be undone.") 106 | ) { 107 | handleDeleteAllSnapshots(); 108 | setSelectedSnapshot(null); 109 | } 110 | }; 111 | 112 | return ( 113 | 114 | 115 | 124 |
125 | 126 | 127 |
128 |
129 | 138 | 139 |
140 | 141 | 142 | 144 | `rounded-md px-2 py-1 transition-colors ${selected ? "" : "text-neutral-300 dark:text-neutral-500"}` 145 | } 146 | > 147 | Tasks 148 | 149 |
/
150 | 152 | `rounded-md px-2 py-1 transition-colors ${selected ? "" : "text-neutral-300 dark:text-neutral-500"}` 153 | } 154 | > 155 | Snapshots 156 | 157 |
158 | 159 | 160 |
161 | {isLoading ? ( 162 |
163 |
164 | Loading tasks... 165 |
166 |
167 | ) : tasks.length > 0 ? ( 168 |
169 | {tasks.map((task) => ( 170 |
171 |
172 | [x] 173 | {task.content} 174 | {task.section && ( 175 | 176 | {task.section} 177 | 178 | )} 179 |
180 | 181 | Closed at {formatDate(task.completedAt)} 182 | 183 |
184 | ))} 185 |
186 | ) : ( 187 |
188 |
189 | No tasks found. 190 |

You can open tasks by `- [ ]`, and can close them by `- [x]`.

191 |
192 |
193 | )} 194 |
195 |
196 | 197 | {snapshots.length > 0 && ( 198 |
199 |

Snapshots

200 | 203 |
204 | )} 205 |
206 |
207 | {snapshots.length === 0 ? ( 208 |
209 |
210 | No snapshots found. 211 |
212 | You can save a snapshot by{" "} 213 | 214 | Cmd + Shift + s 215 | {" "} 216 | on the editor. 217 |
218 |
219 | ) : isLoading ? ( 220 |
221 |
222 | Loading snapshots... 223 |
224 |
225 | ) : selectedSnapshot ? ( 226 |
227 |
228 |

{formatDate(selectedSnapshot.timestamp)}

229 |
230 | 233 | 236 |
237 |
238 |
239 | {selectedSnapshot.content.split("\n").map((line, i) => ( 240 |
244 | {line} 245 |
246 | ))} 247 |
248 |
249 | ) : ( 250 |
251 |
252 | No snapshot selected 253 |
254 |
255 | )} 256 |
257 | {snapshots.length > 0 && ( 258 |
259 | {isLoading ? ( 260 |
261 | Loading... 262 |
263 | ) : ( 264 |
265 | {snapshots.map((snapshot) => ( 266 | 282 | ))} 283 |
284 | )} 285 |
286 | )} 287 |
288 |
289 |
290 |
291 |
292 | 293 |
294 | 297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 | ); 305 | }; 306 | -------------------------------------------------------------------------------- /src/features/history/use-history-data.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { type Snapshot, snapshotStorage } from "../snapshots/snapshot-storage"; 3 | import { type CompletedTask, taskStorage } from "../editor/tasks/task-storage"; 4 | import { LOCAL_STORAGE_KEYS } from "../../utils/constants"; 5 | import { showToast } from "../../utils/components/toast"; 6 | 7 | // Type definition for grouped items 8 | export type DateGroupedItems = { 9 | today: T[]; 10 | yesterday: T[]; 11 | older: { date: string; items: T[] }[]; 12 | }; 13 | 14 | // History data type 15 | type HistoryData = { 16 | snapshots: Snapshot[]; 17 | tasks: CompletedTask[]; 18 | groupedSnapshots: DateGroupedItems; 19 | groupedTasks: DateGroupedItems; 20 | isLoading: boolean; 21 | handleRestoreSnapshot: (snapshot: Snapshot) => void; 22 | handleDeleteSnapshot: (id: string) => void; 23 | handleDeleteAllSnapshots: () => void; 24 | handleDeleteTask: (id: string) => void; 25 | refresh: () => void; 26 | }; 27 | 28 | // Cache for date strings to avoid recreating them repeatedly 29 | const dateStringCache = new Map(); 30 | 31 | // Helper to get date string for grouping with caching 32 | const getDateString = (date: Date): string => { 33 | const time = date.getTime(); 34 | const cacheKey = `date_${time}`; 35 | 36 | if (dateStringCache.has(cacheKey)) { 37 | return dateStringCache.get(cacheKey)!; 38 | } 39 | 40 | const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD 41 | dateStringCache.set(cacheKey, dateStr); 42 | 43 | // Cleanup cache if it gets too large 44 | if (dateStringCache.size > 100) { 45 | // Get the oldest keys and remove them 46 | const keys = Array.from(dateStringCache.keys()).slice(0, 50); 47 | keys.forEach((key) => dateStringCache.delete(key)); 48 | } 49 | 50 | return dateStr; 51 | }; 52 | 53 | // Function to group items by date 54 | const groupItemsByDate = (items: T[]): DateGroupedItems => { 55 | const now = new Date(); 56 | const today = getDateString(now); 57 | 58 | const yesterday = new Date(now); 59 | yesterday.setDate(yesterday.getDate() - 1); 60 | const yesterdayStr = getDateString(yesterday); 61 | 62 | const result: DateGroupedItems = { 63 | today: [], 64 | yesterday: [], 65 | older: [], 66 | }; 67 | 68 | // Temporary storage for older dates 69 | const olderDates: Record = {}; 70 | 71 | items.forEach((item) => { 72 | // Get the date string from item (handle both snapshot and task) 73 | const dateStr = item.timestamp 74 | ? getDateString(new Date(item.timestamp)) 75 | : item.completedAt 76 | ? getDateString(new Date(item.completedAt)) 77 | : ""; 78 | 79 | if (dateStr === today) { 80 | result.today.push(item); 81 | } else if (dateStr === yesterdayStr) { 82 | result.yesterday.push(item); 83 | } else if (dateStr) { 84 | // Group by date for older items 85 | if (!olderDates[dateStr]) { 86 | olderDates[dateStr] = []; 87 | } 88 | olderDates[dateStr].push(item); 89 | } 90 | }); 91 | 92 | // Convert older dates to array and sort by date (newest first) 93 | result.older = Object.entries(olderDates) 94 | .map(([date, items]) => ({ date, items })) 95 | .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); 96 | 97 | return result; 98 | }; 99 | 100 | export const useHistoryData = (): HistoryData => { 101 | const [snapshots, setSnapshots] = useState([]); 102 | const [tasks, setTasks] = useState([]); 103 | const [isLoading, setIsLoading] = useState(true); 104 | 105 | const groupedSnapshots = groupItemsByDate(snapshots); 106 | const groupedTasks = groupItemsByDate(tasks); 107 | 108 | // Load data from storage with optimizations 109 | const loadData = () => { 110 | setIsLoading(true); 111 | let loadingComplete = false; 112 | 113 | // Create a timeout to ensure we don't show loading state for too long 114 | const loadingTimeout = setTimeout(() => { 115 | if (!loadingComplete) { 116 | setIsLoading(false); 117 | } 118 | }, 500); 119 | 120 | // Performance optimization: Use promise.all to load data in parallel 121 | Promise.all([ 122 | // Load snapshots with caching 123 | new Promise((resolve) => { 124 | try { 125 | const allSnapshots = snapshotStorage 126 | .getAll() 127 | .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); 128 | setSnapshots(allSnapshots); 129 | } catch (snapshotError) { 130 | console.error("Error loading snapshots:", snapshotError); 131 | setSnapshots([]); 132 | } 133 | resolve(); 134 | }), 135 | 136 | // Load tasks with caching 137 | new Promise((resolve) => { 138 | try { 139 | const allTasks = taskStorage 140 | .getAll() 141 | .sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()); 142 | setTasks(allTasks); 143 | } catch (taskError) { 144 | console.error("Error loading tasks:", taskError); 145 | setTasks([]); 146 | } 147 | resolve(); 148 | }), 149 | ]).then(() => { 150 | loadingComplete = true; 151 | clearTimeout(loadingTimeout); 152 | setIsLoading(false); 153 | }); 154 | }; 155 | 156 | // Initialize data 157 | useEffect(() => { 158 | loadData(); 159 | 160 | // Listen for storage changes 161 | const handleStorageChange = (e: StorageEvent) => { 162 | if (e.key?.includes("snapshot") || e.key?.includes("task")) { 163 | loadData(); 164 | } 165 | }; 166 | 167 | window.addEventListener("storage", handleStorageChange); 168 | return () => { 169 | window.removeEventListener("storage", handleStorageChange); 170 | }; 171 | }, []); 172 | 173 | // Handle restore snapshot 174 | const handleRestoreSnapshot = (snapshot: Snapshot) => { 175 | try { 176 | localStorage.setItem(LOCAL_STORAGE_KEYS.EDITOR_CONTENT, snapshot.content); 177 | // Dispatch a custom event instead of reloading 178 | window.dispatchEvent( 179 | new CustomEvent("ephe:content-restored", { 180 | detail: { content: snapshot.content }, 181 | }), 182 | ); 183 | showToast("Snapshot restored to editor", "success"); 184 | } catch (error) { 185 | console.error("Error restoring snapshot:", error); 186 | showToast("Failed to restore snapshot", "error"); 187 | } 188 | }; 189 | 190 | // Handle delete snapshot 191 | const handleDeleteSnapshot = (id: string) => { 192 | try { 193 | snapshotStorage.deleteById(id); 194 | const updatedSnapshots = snapshots.filter((snapshot) => snapshot.id !== id); 195 | setSnapshots(updatedSnapshots); 196 | showToast("Snapshot deleted", "success"); 197 | } catch (error) { 198 | console.error("Error deleting snapshot:", error); 199 | showToast("Failed to delete snapshot", "error"); 200 | } 201 | }; 202 | 203 | // Handle delete task 204 | const handleDeleteTask = (id: string) => { 205 | try { 206 | taskStorage.deleteById(id); 207 | const updatedTasks = tasks.filter((task) => task.id !== id); 208 | setTasks(updatedTasks); 209 | showToast("Task deleted", "success"); 210 | } catch (error) { 211 | console.error("Error deleting task:", error); 212 | showToast("Failed to delete task", "error"); 213 | } 214 | }; 215 | 216 | // Handle delete all snapshots 217 | const handleDeleteAllSnapshots = () => { 218 | try { 219 | snapshotStorage.deleteAll(); 220 | setSnapshots([]); 221 | showToast("All snapshots deleted", "success"); 222 | } catch (error) { 223 | console.error("Error deleting all snapshots:", error); 224 | showToast("Failed to delete all snapshots", "error"); 225 | } 226 | }; 227 | 228 | return { 229 | snapshots, 230 | tasks, 231 | groupedSnapshots, 232 | groupedTasks, 233 | isLoading, 234 | handleRestoreSnapshot, 235 | handleDeleteSnapshot, 236 | handleDeleteAllSnapshots, 237 | handleDeleteTask, 238 | refresh: loadData, 239 | }; 240 | }; 241 | -------------------------------------------------------------------------------- /src/features/integration/github/github-api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { generateIssuesTaskList, type GitHubIssue } from "./github-api"; 3 | 4 | describe("GitHub API", () => { 5 | it("generateIssuesTaskList", () => { 6 | const issues = [ 7 | { 8 | id: 1, 9 | title: "Issue 1", 10 | html_url: "https://github.com/unvalley/test/issues/1", 11 | state: "open", 12 | repository_url: "https://github.com/unvalley/test", 13 | }, 14 | { 15 | id: 2, 16 | title: "Issue 2", 17 | html_url: "https://github.com/unvalley/test/issues/2", 18 | state: "open", 19 | repository_url: "https://github.com/unvalley/test", 20 | }, 21 | ]; 22 | const taskList = generateIssuesTaskList(issues); 23 | 24 | expect(taskList).toBeDefined(); 25 | expect(taskList).toContain("- [ ] github.com/unvalley/test/issues/1"); 26 | expect(taskList).toContain("- [ ] github.com/unvalley/test/issues/2"); 27 | }); 28 | 29 | const baseIssue: Omit = { 30 | title: "Test Issue", 31 | state: "open", 32 | repository_url: "https://api.github.com/repos/owner/repo", 33 | repository_name: "owner/repo", 34 | }; 35 | 36 | it("should return 'No issues assigned.' when the issues array is empty", () => { 37 | const issues: GitHubIssue[] = []; 38 | const taskList = generateIssuesTaskList(issues); 39 | expect(taskList).toBe("No issues assigned."); 40 | }); 41 | 42 | it("should generate a task list for a single issue", () => { 43 | const issues: GitHubIssue[] = [ 44 | { 45 | ...baseIssue, 46 | id: 1, 47 | html_url: "https://github.com/owner/repo/issues/1", 48 | }, 49 | ]; 50 | const taskList = generateIssuesTaskList(issues); 51 | expect(taskList).toBe("- [ ] github.com/owner/repo/issues/1"); 52 | }); 53 | 54 | it("should generate a task list for multiple issues, removing https:// and joining with newline", () => { 55 | const issues: GitHubIssue[] = [ 56 | { 57 | ...baseIssue, 58 | id: 1, 59 | html_url: "https://github.com/owner/repo/issues/1", 60 | }, 61 | { 62 | ...baseIssue, 63 | id: 2, 64 | html_url: "https://github.com/another/repo/issues/2", 65 | }, 66 | ]; 67 | const taskList = generateIssuesTaskList(issues); 68 | const expectedOutput = ["- [ ] github.com/owner/repo/issues/1", "- [ ] github.com/another/repo/issues/2"].join( 69 | "\n", 70 | ); 71 | expect(taskList).toBe(expectedOutput); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/features/integration/github/github-api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub API service for fetching issues and other GitHub data 3 | */ 4 | 5 | export type GitHubIssue = { 6 | id: number; 7 | title: string; 8 | html_url: string; 9 | state: string; 10 | repository_url: string; 11 | repository_name?: string; 12 | }; 13 | 14 | /** 15 | * Fetch issues assigned to a specific GitHub user 16 | * Currently only supports public repositories 17 | * @param github_user_id The GitHub user ID to fetch issues for 18 | * @returns Promise with the list of issues assigned to the user 19 | */ 20 | export const fetchAssignedIssues = async (github_user_id: string): Promise => { 21 | try { 22 | const response = await fetch(`https://api.github.com/search/issues?q=assignee:${github_user_id}+state:open`); 23 | 24 | if (!response.ok) { 25 | throw new Error(`GitHub API error: ${response.status}`); 26 | } 27 | 28 | const data = await response.json(); 29 | 30 | // Process the results to extract repository name from repository_url 31 | const issues: GitHubIssue[] = data.items.map((item: GitHubIssue) => { 32 | // Extract repository name from repository_url 33 | // Format: https://api.github.com/repos/owner/repo 34 | const repoUrl = item.repository_url; 35 | const repoName = repoUrl.split("/repos/")[1]; 36 | 37 | return { 38 | ...item, 39 | repository_name: repoName, 40 | }; 41 | }); 42 | 43 | return issues; 44 | } catch (error) { 45 | console.error("Error fetching GitHub issues:", error); 46 | return []; 47 | } 48 | }; 49 | 50 | /** 51 | * Generates markdown task list items from GitHub issues 52 | * @param issues List of GitHub issues 53 | * @returns Markdown string with task list items 54 | */ 55 | export const generateIssuesTaskList = (issues: GitHubIssue[]): string => { 56 | if (issues.length === 0) { 57 | return "No issues assigned."; 58 | } 59 | 60 | return issues 61 | .map((issue) => { 62 | // Remove https:// from the URL for cleaner display 63 | const displayUrl = issue.html_url.replace(/^https:\/\//, ""); 64 | // Just display the URL without Markdown link formatting 65 | return `- [ ] ${displayUrl}`; 66 | }) 67 | .join("\n"); 68 | }; 69 | 70 | /** 71 | * Fetches GitHub issues for a user and returns them as a markdown task list 72 | * @param username GitHub username to fetch issues for 73 | * @returns Promise with markdown text containing the task list 74 | */ 75 | export const fetchGitHubIssuesTaskList = async (github_user_id: string): Promise => { 76 | try { 77 | const issues = await fetchAssignedIssues(github_user_id); 78 | return generateIssuesTaskList(issues); 79 | } catch (error) { 80 | console.error("Error fetching GitHub issues:", error); 81 | return "Error fetching GitHub issues."; 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/features/menu/command-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Command } from "cmdk"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import { useTheme } from "../../utils/hooks/use-theme"; 6 | import type { MarkdownFormatter } from "../editor/markdown/formatter/markdown-formatter"; 7 | import { showToast } from "../../utils/components/toast"; 8 | import type { PaperMode } from "../../utils/hooks/use-paper-mode"; 9 | import { COLOR_THEME, type ColorTheme } from "../../utils/theme-initializer"; 10 | import type { EditorWidth } from "../../utils/hooks/use-editor-width"; 11 | import { 12 | ComputerDesktopIcon, 13 | DocumentIcon, 14 | LinkIcon, 15 | MagnifyingGlassIcon, 16 | MoonIcon, 17 | NewspaperIcon, 18 | SunIcon, 19 | ViewColumnsIcon, 20 | } from "@heroicons/react/24/outline"; 21 | 22 | type CommandMenuProps = { 23 | open: boolean; 24 | onClose?: () => void; 25 | editorContent?: string; 26 | // editorRef?: React.RefObject; 27 | markdownFormatterRef?: React.RefObject; 28 | paperMode?: PaperMode; 29 | cyclePaperMode?: () => PaperMode; 30 | editorWidth?: EditorWidth; 31 | toggleEditorWidth?: () => void; 32 | previewMode?: boolean; 33 | togglePreviewMode?: () => void; 34 | }; 35 | 36 | type CommandItem = { 37 | id: string; 38 | name: string; 39 | icon?: React.ReactNode; 40 | shortcut?: string; 41 | perform: () => void; 42 | keywords?: string; 43 | }; 44 | 45 | export function CommandMenu({ 46 | open, 47 | onClose = () => {}, 48 | editorContent = "", 49 | // markdownFormatterRef, 50 | paperMode, 51 | cyclePaperMode, 52 | editorWidth, 53 | toggleEditorWidth, 54 | }: CommandMenuProps) { 55 | const { theme, setTheme, nextTheme } = useTheme(); 56 | const [inputValue, setInputValue] = useState(""); 57 | const inputRef = useRef(null); 58 | const listRef = useRef(null); 59 | 60 | useEffect(() => { 61 | if (open) { 62 | const timer = setTimeout(() => { 63 | inputRef.current?.focus(); 64 | setInputValue(""); 65 | }, 50); 66 | return () => clearTimeout(timer); 67 | } 68 | }, [open]); 69 | 70 | const cycleThemeCallback = () => { 71 | let nextTheme: ColorTheme; 72 | if (theme === COLOR_THEME.LIGHT) { 73 | nextTheme = COLOR_THEME.DARK; 74 | } else if (theme === COLOR_THEME.DARK) { 75 | nextTheme = COLOR_THEME.SYSTEM; 76 | } else { 77 | nextTheme = COLOR_THEME.LIGHT; 78 | } 79 | setTheme(nextTheme); 80 | onClose(); 81 | }; 82 | 83 | const getNextThemeText = () => { 84 | if (theme === COLOR_THEME.LIGHT) return "dark"; 85 | if (theme === COLOR_THEME.DARK) return "system"; 86 | return "light"; 87 | }; 88 | 89 | const cyclePaperModeCallback = () => { 90 | cyclePaperMode?.(); 91 | onClose(); 92 | }; 93 | 94 | const toggleEditorWidthCallback = () => { 95 | toggleEditorWidth?.(); 96 | onClose(); 97 | }; 98 | 99 | const handleExportMarkdownCallback = () => { 100 | if (!editorContent) { 101 | showToast("No content to export", "error"); 102 | onClose(); 103 | return; 104 | } 105 | try { 106 | const blob = new Blob([editorContent], { type: "text/markdown" }); 107 | const url = URL.createObjectURL(blob); 108 | const a = document.createElement("a"); 109 | const date = new Date().toISOString().split("T")[0]; 110 | a.href = url; 111 | a.download = `ephe_${date}.md`; 112 | document.body.appendChild(a); 113 | a.click(); 114 | document.body.removeChild(a); 115 | URL.revokeObjectURL(url); 116 | showToast("Markdown exported", "success"); 117 | } catch (error) { 118 | console.error("Export failed:", error); 119 | showToast("Failed to export markdown", "error"); 120 | } finally { 121 | onClose(); 122 | } 123 | }; 124 | 125 | // const handleFormatDocumentCallback = useCallback(async () => { 126 | // if (!editorRef?.current || !markdownFormatterRef?.current) { 127 | // showToast("Editor or markdown formatter not available", "error"); 128 | // handleClose(); 129 | // return; 130 | // } 131 | // try { 132 | // const editor = editorRef.current; 133 | // const selection = editor.getSelection(); 134 | // const scrollTop = editor.getScrollTop(); 135 | // const content = editor.getValue(); 136 | // const formattedContent = await markdownFormatterRef.current.formatMarkdown(content); // formatMarkdownはPromiseを返すと仮定 137 | 138 | // editor.setValue(formattedContent); 139 | 140 | // // カーソル位置とスクロール位置を復元 141 | // if (selection) { 142 | // editor.setSelection(selection); 143 | // } 144 | // // setValue後のレンダリングを待ってからスクロール位置を復元 145 | // setTimeout(() => editor.setScrollTop(scrollTop), 0); 146 | 147 | // showToast("Document formatted successfully", "default"); 148 | // } catch (error) { 149 | // const message = error instanceof Error ? error.message : "unknown"; 150 | // showToast(`Error formatting document: ${message}`, "error"); 151 | // console.error("Formatting error:", error); 152 | // } finally { 153 | // handleClose(); 154 | // } 155 | // }, [markdownFormatterRef, handleClose]); 156 | 157 | // const handleInsertGitHubIssuesCallback = useCallback(async () => { 158 | // if (!editorRef?.current) { 159 | // showToast("Editor not available", "error"); 160 | // handleClose(); 161 | // return; 162 | // } 163 | // try { 164 | // const github_user_id = prompt("Enter GitHub User ID:"); 165 | // if (!github_user_id) { 166 | // handleClose(); // キャンセルまたは空入力時は閉じる 167 | // return; 168 | // } 169 | // const issuesTaskList = await fetchGitHubIssuesTaskList(github_user_id); // fetchGitHubIssuesTaskListはPromiseを返すと仮定 170 | // const editor = editorRef.current; 171 | // const selection = editor.getSelection(); 172 | // const position = editor.getPosition(); 173 | 174 | // let range: monaco.IRange; 175 | // if (selection && !selection.isEmpty()) { 176 | // range = selection; 177 | // } else if (position) { 178 | // // 選択範囲がない場合は現在のカーソル位置に挿入 179 | // range = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column); 180 | // } else { 181 | // // カーソル位置もない場合(エディタが空など)は先頭に挿入 182 | // range = new monaco.Range(1, 1, 1, 1); 183 | // } 184 | 185 | // editor.executeEdits("insert-github-issues", [{ range, text: issuesTaskList, forceMoveMarkers: true }]); 186 | 187 | // showToast(`Inserted GitHub issues for ${github_user_id}`, "success"); 188 | // } catch (error) { 189 | // console.error("Error inserting GitHub issues:", error); 190 | // showToast("Failed to insert GitHub issues", "error"); 191 | // } finally { 192 | // handleClose(); 193 | // } 194 | // }, [editorRef, handleClose]); 195 | 196 | const goToGitHubRepo = () => { 197 | window.open("https://github.com/unvalley/ephe", "_blank"); 198 | onClose(); 199 | }; 200 | 201 | const commandsList = (): CommandItem[] => { 202 | const list: CommandItem[] = [ 203 | { 204 | id: "theme-toggle", 205 | name: `Switch to ${getNextThemeText()} mode`, 206 | icon: 207 | nextTheme === COLOR_THEME.LIGHT ? ( 208 | 209 | ) : nextTheme === COLOR_THEME.DARK ? ( 210 | 211 | ) : ( 212 | 213 | ), 214 | // shortcut: "⌘T", // Mac以外も考慮するなら修飾キーの表示を工夫する必要あり 215 | perform: cycleThemeCallback, 216 | keywords: "theme toggle switch mode light dark system color appearance", 217 | }, 218 | ]; 219 | 220 | if (cyclePaperMode) { 221 | list.push({ 222 | id: "paper-mode", 223 | name: "Cycle paper mode", 224 | icon: , 225 | perform: cyclePaperModeCallback, 226 | keywords: "paper mode cycle switch document style layout background", 227 | }); 228 | } 229 | if (toggleEditorWidth) { 230 | list.push({ 231 | id: "editor-width", 232 | name: "Toggle editor width", 233 | icon: , 234 | perform: toggleEditorWidthCallback, 235 | keywords: "editor width toggle resize narrow wide full layout column", 236 | }); 237 | } 238 | if (editorContent) { 239 | list.push({ 240 | id: "export-markdown", 241 | name: "Export markdown", 242 | icon: , 243 | // shortcut: "⌘S", 244 | perform: handleExportMarkdownCallback, 245 | keywords: "export markdown save download file md text document", 246 | }); 247 | } 248 | // if (editorRef?.current && markdownFormatterRef?.current) { 249 | // list.push({ 250 | // id: "format-document", 251 | // name: "Format document", 252 | // icon: , 253 | // shortcut: "⌘F", // ブラウザの検索と競合する可能性あり 254 | // perform: handleFormatDocumentCallback, 255 | // keywords: "format document prettify code style arrange beautify markdown lint tidy", 256 | // }); 257 | // } 258 | // if (editorRef?.current) { 259 | // // エディタが存在する場合のみ表示 260 | // list.push({ 261 | // id: "insert-github-issues", 262 | // name: "Insert GitHub Issues (Public Repos)", 263 | // icon: , 264 | // shortcut: "⌘G", // ショートカットは要検討 265 | // perform: handleInsertGitHubIssuesCallback, 266 | // keywords: "github issues insert fetch task todo list import integrate", 267 | // }); 268 | // } 269 | list.push({ 270 | id: "github-repo", 271 | name: "Go to Ephe GitHub Repo", 272 | icon: , 273 | perform: goToGitHubRepo, 274 | keywords: "github ephe repository project code source link open website source-code", 275 | }); 276 | 277 | return list; 278 | }; 279 | 280 | return ( 281 | <> 282 | {open && ( 283 |
{ 286 | e.preventDefault(); 287 | e.stopPropagation(); 288 | onClose(); 289 | }} 290 | onKeyDown={(e) => { 291 | if (e.key === "Escape") { 292 | e.preventDefault(); 293 | onClose(); 294 | } 295 | }} 296 | aria-hidden="true" 297 | /> 298 | )} 299 | 300 | { 307 | if (e.key === "Escape") { 308 | e.preventDefault(); 309 | onClose(); 310 | } 311 | }} 312 | > 313 |
314 |
315 | 316 |
317 | 324 |
325 | 326 | 330 | 331 | No results found. 332 | 333 | 334 | 338 | {commandsList() 339 | .filter((cmd) => ["theme-toggle", "paper-mode", "editor-width"].includes(cmd.id)) 340 | .map((command) => ( 341 | 348 |
349 | {/* アイコン表示エリア */} 350 |
351 | {command.icon} 352 |
353 | {/* コマンド名と状態表示 */} 354 | 355 | {" "} 356 | {command.name} 357 | {command.id === "paper-mode" && paperMode && ( 358 | ({paperMode}) 359 | )} 360 | {command.id === "editor-width" && editorWidth && ( 361 | ({editorWidth}) 362 | )} 363 | 364 |
365 | {command.shortcut && ( 366 | 367 | {command.shortcut} 368 | 369 | )} 370 |
371 | ))} 372 |
373 | 374 | 378 | {commandsList() 379 | .filter((cmd) => ["export-markdown", "format-document", "insert-github-issues"].includes(cmd.id)) 380 | .map((command) => ( 381 | 387 |
388 |
389 | {command.icon} 390 |
391 | {command.name} 392 |
393 | {command.shortcut && ( 394 | 395 | {command.shortcut} 396 | 397 | )} 398 |
399 | ))} 400 |
401 | 402 | 406 | {commandsList() 407 | .filter((cmd) => ["github-repo", "history"].includes(cmd.id)) 408 | .map((command) => ( 409 | 415 |
416 |
417 | {command.icon} 418 |
419 | {command.name} 420 |
421 | {command.shortcut && ( 422 | 423 | {command.shortcut} 424 | 425 | )} 426 |
427 | ))} 428 |
429 |
430 | 431 |
432 |
433 | 434 | ⌘ 435 | 436 | 437 | k 438 | 439 | to close 440 |
441 |
442 |
443 | 444 | ); 445 | } 446 | -------------------------------------------------------------------------------- /src/features/menu/system-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "../../utils/hooks/use-theme"; 4 | import { usePaperMode } from "../../utils/hooks/use-paper-mode"; 5 | import { useEditorWidth } from "../../utils/hooks/use-editor-width"; 6 | import { useCharCount } from "../../utils/hooks/use-char-count"; 7 | import { useState, useEffect, useRef } from "react"; 8 | import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; 9 | import { COLOR_THEME } from "../../utils/theme-initializer"; 10 | import { 11 | CheckCircleIcon, 12 | DocumentIcon, 13 | HashtagIcon, 14 | ViewColumnsIcon, 15 | SunIcon, 16 | MoonIcon, 17 | ComputerDesktopIcon, 18 | BoltIcon, 19 | } from "@heroicons/react/24/outline"; 20 | import { taskStorage } from "../editor/tasks/task-storage"; 21 | import { HistoryModal } from "../history/history-modal"; 22 | import { snapshotStorage } from "../snapshots/snapshot-storage"; 23 | import { useTaskAutoFlush } from "../../utils/hooks/use-task-auto-flush"; 24 | 25 | // Today completed tasks count 26 | const useTodayCompletedTasks = (menuOpen: boolean) => { 27 | const [todayCompletedTasks, setTodayCompletedTasks] = useState(0); 28 | useEffect(() => { 29 | const loadTodayTasks = () => { 30 | const today = new Date(); 31 | const tasksByDate = taskStorage.getByDate({ 32 | year: today.getFullYear(), 33 | month: today.getMonth() + 1, // getMonth is 0-indexed 34 | day: today.getDate(), 35 | }); 36 | // Count tasks completed today 37 | const todayDateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart( 38 | 2, 39 | "0", 40 | )}-${String(today.getDate()).padStart(2, "0")}`; 41 | const todayTasks = tasksByDate[todayDateStr] || []; 42 | setTodayCompletedTasks(todayTasks.length); 43 | }; 44 | loadTodayTasks(); 45 | }, [menuOpen]); 46 | return { todayCompletedTasks }; 47 | }; 48 | 49 | // All snapshots count 50 | const useSnapshotCount = (menuOpen: boolean) => { 51 | const [snapshotCount, setSnapshotCount] = useState(0); 52 | useEffect(() => { 53 | const loadSnapshots = () => { 54 | const snapshots = snapshotStorage.getAll(); 55 | setSnapshotCount(snapshots.length); 56 | }; 57 | loadSnapshots(); 58 | }, [menuOpen]); 59 | return { snapshotCount }; 60 | }; 61 | 62 | // const tocVisibilityAtom = atomWithStorage(LOCAL_STORAGE_KEYS.TOC_MODE, false); 63 | 64 | const useHistoryModal = () => { 65 | const [historyModalOpen, setHistoryModalOpen] = useState(false); 66 | const [modalTabIndex, setModalTabIndex] = useState(0); 67 | return { historyModalOpen, modalTabIndex, setHistoryModalOpen, setModalTabIndex }; 68 | }; 69 | 70 | export const SystemMenu = () => { 71 | const menuRef = useRef(null); 72 | const [menuOpen, setMenuOpen] = useState(false); 73 | const { historyModalOpen, modalTabIndex, setHistoryModalOpen, setModalTabIndex } = useHistoryModal(); 74 | 75 | const { theme, setTheme } = useTheme(); 76 | const { paperMode, cyclePaperMode } = usePaperMode(); 77 | 78 | const { editorWidth, setNormalWidth, setWideWidth } = useEditorWidth(); 79 | const { charCount } = useCharCount(); 80 | const { todayCompletedTasks } = useTodayCompletedTasks(menuOpen); 81 | const { snapshotCount } = useSnapshotCount(menuOpen); 82 | const { taskAutoFlushMode, setTaskAutoFlushMode } = useTaskAutoFlush(); 83 | 84 | const openTaskSnapshotModal = (tabIndex: number) => { 85 | setModalTabIndex(tabIndex); 86 | setHistoryModalOpen(true); 87 | setMenuOpen(false); 88 | }; 89 | 90 | useEffect(() => { 91 | const handleClickOutside = (event: MouseEvent) => { 92 | if (menuRef.current && !menuRef.current.contains(event.target as Node) && menuOpen) { 93 | setMenuOpen(false); 94 | } 95 | }; 96 | document.addEventListener("mousedown", handleClickOutside); 97 | return () => { 98 | document.removeEventListener("mousedown", handleClickOutside); 99 | }; 100 | }, [menuOpen]); 101 | 102 | return ( 103 | <> 104 | 105 | {({ open }) => ( 106 | <> 107 | setMenuOpen(!menuOpen)} 110 | > 111 | System 112 | 113 | 114 | {(open || menuOpen) && ( 115 | 120 | {/* Document Stats Section */} 121 |
122 |
Document Stats
123 | 124 |
125 | 126 | 127 | 128 | {charCount > 0 ? `${charCount.toLocaleString()} chars` : "No content"} 129 |
130 |
131 | 132 | 133 | 149 | 150 | 151 | 152 | 162 | 163 |
164 | 165 |
166 |
Appearence
167 | 168 | 193 | 194 | 195 | 196 | 223 | 224 | 225 | 237 | 238 |
239 | 240 |
241 |
Task
242 | 243 | 255 | 256 |
257 |
258 | )} 259 | 260 | )} 261 |
262 | 263 | setHistoryModalOpen(false)} 266 | initialTabIndex={modalTabIndex} 267 | /> 268 | 269 | ); 270 | }; 271 | -------------------------------------------------------------------------------- /src/features/snapshots/snapshot-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Snapshot management utilities 3 | */ 4 | 5 | import { snapshotStorage } from "./snapshot-storage"; 6 | 7 | /** 8 | * Create an automatic snapshot of the current editor content 9 | */ 10 | export const createAutoSnapshot = ({ content }: { content: string; title: string; description?: string }): void => { 11 | // Limit the number of snapshots (keep only the latest 10) 12 | const snapshots = snapshotStorage.getAll(); 13 | 14 | // If there are more than 10, delete the oldest ones 15 | if (snapshots.length >= 10) { 16 | // Sort by date to find the oldest ones 17 | const sortedSnapshots = [...snapshots].sort( 18 | (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), 19 | ); 20 | 21 | // Calculate the number of snapshots to delete 22 | const toDelete = sortedSnapshots.slice(0, snapshots.length - 9); 23 | 24 | // Delete the oldest snapshots 25 | for (const snapshot of toDelete) { 26 | snapshotStorage.deleteById(snapshot.id); 27 | } 28 | } 29 | 30 | snapshotStorage.save({ 31 | content, 32 | charCount: content.length, 33 | }); 34 | }; 35 | 36 | /** 37 | * Compare two snapshots and return the differences 38 | */ 39 | export const compareSnapshots = ( 40 | snapshotId1: string, 41 | snapshotId2: string, 42 | ): { additions: string[]; deletions: string[] } | null => { 43 | const snapshot1 = snapshotStorage.getById(snapshotId1); 44 | const snapshot2 = snapshotStorage.getById(snapshotId2); 45 | 46 | if (!snapshot1 || !snapshot2) return null; 47 | 48 | const lines1 = snapshot1.content.split("\n"); 49 | const lines2 = snapshot2.content.split("\n"); 50 | 51 | // Simple line-by-line diff 52 | const additions = lines2.filter((line) => !lines1.includes(line)); 53 | const deletions = lines1.filter((line) => !lines2.includes(line)); 54 | 55 | return { additions, deletions }; 56 | }; 57 | -------------------------------------------------------------------------------- /src/features/snapshots/snapshot-storage.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE_KEYS } from "../../utils/constants"; 2 | import { 3 | type StorageProvider, 4 | createBrowserLocalStorage, 5 | createStorage, 6 | type DateFilter, 7 | filterItemsByDate, 8 | groupItemsByDate, 9 | defaultStorageProvider, 10 | } from "../../utils/storage"; 11 | 12 | interface SnapshotStorage { 13 | getAll: () => Snapshot[]; 14 | getById: (id: string) => Snapshot | null; 15 | save: (snapshot: Omit) => void; 16 | deleteById: (id: string) => void; 17 | deleteAll: () => void; 18 | getByDate: (filter?: DateFilter) => Record; 19 | } 20 | 21 | // Snapshot Storage factory function 22 | const createSnapshotStorage = (storage: StorageProvider = createBrowserLocalStorage()): SnapshotStorage => { 23 | const baseStorage = createStorage(storage, LOCAL_STORAGE_KEYS.SNAPSHOTS); 24 | 25 | const save = (snapshot: Omit): void => { 26 | const now = new Date(); 27 | const id = `snapshot-${now.getTime()}-${Math.random().toString(36).substring(2, 9)}`; 28 | 29 | const newSnapshot: Snapshot = { 30 | ...snapshot, 31 | id, 32 | timestamp: now.toISOString(), 33 | }; 34 | 35 | baseStorage.save(newSnapshot); 36 | }; 37 | 38 | const getByDate = (filter?: DateFilter): Record => { 39 | const snapshots = baseStorage.getAll(); 40 | const filteredSnapshots = filterItemsByDate(snapshots, filter); 41 | return groupItemsByDate(filteredSnapshots); 42 | }; 43 | 44 | return { 45 | ...baseStorage, 46 | save, 47 | getByDate, 48 | }; 49 | }; 50 | 51 | export const snapshotStorage = createSnapshotStorage(defaultStorageProvider); 52 | 53 | export type Snapshot = { 54 | id: string; 55 | timestamp: string; 56 | content: string; 57 | charCount: number; 58 | }; 59 | -------------------------------------------------------------------------------- /src/features/time-display/hours-display.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect, useRef } from "react"; 4 | import { useTheme } from "../../utils/hooks/use-theme"; 5 | 6 | export const HoursDisplay = () => { 7 | const [showTooltip, setShowTooltip] = useState(false); 8 | const [hoveredHour, setHoveredHour] = useState(null); 9 | const tooltipRef = useRef(null); 10 | const { isDarkMode } = useTheme(); 11 | 12 | const now = new Date(); 13 | const currentHour = now.getHours(); 14 | 15 | // Format current time based on user's locale 16 | // const formattedTime = now.toLocaleTimeString(undefined, { 17 | // hour: "2-digit", 18 | // minute: "2-digit", 19 | // }); 20 | 21 | const formattedToday = now.toLocaleDateString(undefined, { 22 | weekday: "short", 23 | month: "short", 24 | day: "numeric", 25 | }); 26 | 27 | // Calculate hours remaining in the day 28 | const hoursRemaining = 24 - currentHour - 1; 29 | const minutesRemaining = 60 - now.getMinutes(); 30 | 31 | // Generate array of all hours in the day 32 | const hoursArray = Array.from({ length: 24 }, (_, i) => { 33 | return { 34 | hour: i, 35 | current: i === currentHour, 36 | past: i < currentHour, 37 | future: i > currentHour, 38 | }; 39 | }); 40 | 41 | // Close tooltip when clicking outside 42 | useEffect(() => { 43 | const handleClickOutside = (event: MouseEvent) => { 44 | if (tooltipRef.current && !tooltipRef.current.contains(event.target as Node)) { 45 | setShowTooltip(false); 46 | } 47 | }; 48 | 49 | document.addEventListener("mousedown", handleClickOutside); 50 | return () => { 51 | document.removeEventListener("mousedown", handleClickOutside); 52 | }; 53 | }, []); 54 | 55 | // Format hour for display 56 | const formatHour = (hour: number): string => { 57 | return `${hour.toString().padStart(2, "0")}:00`; 58 | }; 59 | 60 | return ( 61 |
62 | 70 | 71 | {showTooltip && ( 72 | // biome-ignore lint/a11y/noStaticElementInteractions: 73 |
setShowTooltip(false)} 83 | > 84 |
85 | {hoursRemaining > 0 ? `${hoursRemaining}h ${minutesRemaining}m left` : "End of day"} 86 |
87 |
99 | {hoursArray.map((hour) => ( 100 | // biome-ignore lint/a11y/noStaticElementInteractions: 101 |
setHoveredHour(formatHour(hour.hour))} 113 | onMouseLeave={() => setHoveredHour(null)} 114 | > 115 |
122 | {formatHour(hour.hour)} 123 |
124 |
125 | ))} 126 |
127 |
128 | )} 129 |
130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /src/globals.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Noto+Sans+JP&display=swap"); 2 | @import "tailwindcss"; 3 | @config "../tailwind.config.js"; 4 | 5 | @layer base { 6 | button, 7 | [role="button"] { 8 | /* I think using pointer is better for accessibility */ 9 | cursor: pointer; 10 | } 11 | } 12 | 13 | body { 14 | font-family: "Inter", "Noto Sans JP", sans-serif; 15 | } 16 | 17 | /* Dark mode transitions */ 18 | body.dark { 19 | color-scheme: dark; 20 | } 21 | 22 | /* Make the task part clickable */ 23 | span.task-task { 24 | cursor: pointer; 25 | user-select: none; 26 | display: inline-block; 27 | margin-right: 0.25rem; 28 | font-weight: normal; 29 | } 30 | 31 | /* Styling for completed tasks - using opacity instead of strikethrough */ 32 | .task-completed-line { 33 | opacity: 0.5; 34 | } 35 | 36 | /* TOC styles */ 37 | .toc-container { 38 | position: sticky; 39 | top: 1rem; 40 | max-height: calc(100vh - 8rem); 41 | width: 100%; 42 | font-size: 0.875rem; 43 | transition: all 0.3s ease; 44 | background-color: transparent; 45 | border-radius: 0.375rem; 46 | padding: 0.5rem; 47 | } 48 | 49 | .dark .toc-container { 50 | background-color: transparent; 51 | border-color: transparent; 52 | } 53 | 54 | .toc-wrapper { 55 | position: fixed; 56 | right: 15rem; 57 | top: 10rem; 58 | width: 15rem; 59 | transition: all 0.3s ease-in-out; 60 | } 61 | 62 | .toc-wrapper.visible { 63 | opacity: 1; 64 | transform: translateX(0); 65 | } 66 | 67 | .toc-wrapper.hidden { 68 | opacity: 0; 69 | transform: translateX(100%); 70 | pointer-events: none; 71 | } 72 | 73 | @media (max-width: 1280px) { 74 | .toc-wrapper { 75 | position: fixed; 76 | right: 2rem; 77 | top: 4rem; 78 | max-width: 16rem; 79 | } 80 | } 81 | 82 | @media (max-width: 768px) { 83 | .toc-wrapper { 84 | right: 0; 85 | top: 0; 86 | height: 100%; 87 | width: 16rem; 88 | } 89 | 90 | .toc-container { 91 | height: 100%; 92 | border-radius: 0; 93 | margin-left: 0; 94 | padding-top: 2rem; 95 | } 96 | } 97 | 98 | /* Paper Mode: Graph Paper (Light Mode) */ 99 | .bg-graph-paper { 100 | background-image: linear-gradient(rgba(210, 210, 210, 0.3) 1px, transparent 1px), 101 | linear-gradient(to right, rgba(210, 210, 210, 0.3) 1px, transparent 1px); 102 | background-size: 12px 12px; 103 | background-color: #fff; 104 | background-position: -14px 14px; 105 | } 106 | 107 | /* Paper Mode: Graph Paper (Dark Mode) */ 108 | .dark .bg-graph-paper { 109 | background-image: linear-gradient(rgba(80, 80, 80, 0.3) 1px, transparent 1px), 110 | linear-gradient(to right, rgba(80, 80, 80, 0.3) 1px, transparent 1px); 111 | background-size: 12px 12px; 112 | background-color: #0d1117; 113 | background-position: -14px 14px; 114 | } 115 | 116 | /* Paper Mode: Normal (Light Mode) */ 117 | .bg-normal-paper { 118 | background-color: #fff; 119 | } 120 | 121 | /* Paper Mode: Normal (Dark Mode) */ 122 | .dark .bg-normal-paper { 123 | background-color: #0d1117; 124 | } 125 | 126 | /* Paper Mode: Dots (Light Mode) */ 127 | .bg-dots-paper { 128 | background-image: radial-gradient(rgba(210, 210, 210, 0.5) 1px, transparent 1px); 129 | background-size: 24px 24px; 130 | background-color: #fff; 131 | } 132 | 133 | /* Paper Mode: Dots (Dark Mode) */ 134 | .dark .bg-dots-paper { 135 | background-image: radial-gradient(rgba(80, 80, 80, 0.5) 1px, transparent 1px); 136 | background-size: 16px 16px; 137 | background-color: #0d1117; 138 | } 139 | 140 | .cm-task-hover { 141 | cursor: pointer; 142 | background-color: rgba(128, 128, 128, 0.1); 143 | } 144 | 145 | /* Dark mode hover style */ 146 | .dark .cm-task-hover { 147 | background-color: rgba(255, 255, 255, 0.1); 148 | } 149 | 150 | /* History sidebar styles */ 151 | .history-sidebar { 152 | width: 300px; 153 | border-left: 1px solid rgb(229, 231, 235); 154 | overflow: hidden; 155 | } 156 | 157 | .dark .history-sidebar { 158 | border-color: rgb(55, 65, 81); 159 | } 160 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./utils/theme-initializer"; 2 | import "./globals.css"; 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 6 | import { LandingPage } from "./page/landing-page"; 7 | import { ToastContainer } from "./utils/components/toast"; 8 | import { NotFound } from "./page/404-page"; 9 | import { EditorPage } from "./page/editor-page"; 10 | 11 | const root = document.getElementById("root"); 12 | if (!root) { 13 | throw new Error("Root element not found"); 14 | } 15 | 16 | ReactDOM.createRoot(root).render( 17 | 18 | 19 | 20 | } /> 21 | } /> 22 | } /> 23 | 24 | 25 | 26 | , 27 | ); 28 | -------------------------------------------------------------------------------- /src/page/404-page.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | 3 | export const NotFound: FC = () => { 4 | return ( 5 |
6 |
7 |

404

8 |

Page not found

9 | 13 | Back to Editor 14 | 15 |
16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/page/editor-page.tsx: -------------------------------------------------------------------------------- 1 | import "../globals.css"; 2 | import { usePaperMode } from "../utils/hooks/use-paper-mode"; 3 | import { Footer, FooterButton } from "../utils/components/footer"; 4 | import { CommandMenu } from "../features/menu/command-menu"; 5 | import { CodeMirrorEditor } from "../features/editor/codemirror/codemirror-editor"; 6 | import { SystemMenu } from "../features/menu/system-menu"; 7 | import { HoursDisplay } from "../features/time-display/hours-display"; 8 | import { Link } from "react-router-dom"; 9 | import { EPHE_VERSION } from "../utils/constants"; 10 | import { useCommandK } from "../utils/hooks/use-command-k"; 11 | 12 | export const EditorPage = () => { 13 | const { paperModeClass } = usePaperMode(); 14 | const { isCommandMenuOpen, toggleCommandMenu } = useCommandK(); 15 | 16 | return ( 17 |
18 |
19 |
20 | 21 |
22 |
23 | 24 |
} 27 | rightContent={ 28 | <> 29 | 30 | 31 | Ephe v{EPHE_VERSION} 32 | 33 | 34 | } 35 | /> 36 | 37 | {isCommandMenuOpen && } 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/page/landing-page.tsx: -------------------------------------------------------------------------------- 1 | export const LandingPage = () => { 2 | return ( 3 |
4 |
5 |
6 |

7 | Ephe is : 8 |

9 |
    10 |
  1. A markdown paper to organize your daily todos and thoughts.
  2. 11 |
  3. 12 | 18 | OSS 19 | 20 | , and free. 21 |
  4. 22 |
23 | 24 |

25 | No installs. No sign-up. No noise. 26 |
27 | You get one page, write what matters today. 28 |

29 |
30 | 31 |
32 |

Why :

33 |
    34 |
  • - Most note and todo apps are overloaded.
  • 35 |
  • - I believe, just one page is enough for organizing.
  • 36 |
37 |
38 |

39 | Quickly capture todos, thoughts. We have a{" "} 40 | 46 | guide 47 | {" "} 48 | for you. 49 |

50 |
51 | 52 | 60 |
61 | 62 | 74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/scripts/copy-wasm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Script to copy WASM file from @dprint/markdown package to public directory 3 | */ 4 | import { getPath } from "@dprint/markdown"; 5 | import fs from "node:fs"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | 9 | // Get current directory 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const rootDir = path.resolve(__dirname, "../.."); 13 | 14 | // Source and target paths 15 | const sourceWasmPath = getPath(); 16 | const targetDir = path.join(rootDir, "public", "wasm"); 17 | const targetPath = path.join(targetDir, "dprint-markdown.wasm"); 18 | 19 | // Create target directory if it doesn't exist 20 | if (!fs.existsSync(targetDir)) { 21 | fs.mkdirSync(targetDir, { recursive: true }); 22 | } 23 | 24 | // Copy WASM file 25 | fs.copyFileSync(sourceWasmPath, targetPath); 26 | console.info(`✅ Copied WASM from ${sourceWasmPath} to ${targetPath}`); 27 | -------------------------------------------------------------------------------- /src/utils/README.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | Functions, components, hooks, constants, types, and other utilities that are used throughout the app. 4 | Carefully consider if it should be in `/utils/**` or `/src/features/**`. 5 | 6 | ## Why `/utils` ? 7 | 8 | For example, if `src/components` or `src/hooks` exists, you might put it there even though it is a component specialized for a certain feature. Use `/utils/**` as a recognition to prevent this. 9 | 10 | If multiple features use the same utility, you should use `/utils/**`. -------------------------------------------------------------------------------- /src/utils/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, type ErrorInfo, type ReactNode } from "react"; 2 | 3 | type Props = { 4 | children: ReactNode; 5 | fallback?: ReactNode; 6 | }; 7 | 8 | type State = { 9 | hasError: boolean; 10 | error?: Error; 11 | }; 12 | 13 | export class ErrorBoundary extends Component { 14 | constructor(props: Props) { 15 | super(props); 16 | this.state = { hasError: false }; 17 | } 18 | 19 | static getDerivedStateFromError(error: Error): State { 20 | return { hasError: true, error }; 21 | } 22 | 23 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 24 | console.error("Error caught by ErrorBoundary:", error, errorInfo); 25 | } 26 | 27 | render(): ReactNode { 28 | if (this.state.hasError) { 29 | return ( 30 | this.props.fallback || ( 31 |
32 |

Something went wrong.

33 |
34 | Error details 35 |
{this.state.error?.toString()}
36 |
37 | 40 |
41 | ) 42 | ); 43 | } 44 | 45 | return this.props.children; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import { type ButtonProps, Button } from "@headlessui/react"; 2 | import { useUserActivity } from "../hooks/use-user-activity"; 3 | 4 | type FooterProps = { 5 | leftContent: React.ReactNode; 6 | rightContent: React.ReactNode; 7 | autoHide?: boolean; 8 | }; 9 | 10 | export const Footer = ({ leftContent, rightContent, autoHide = false }: FooterProps) => { 11 | const { isHidden } = useUserActivity({ 12 | showDelay: 1500, 13 | }); 14 | 15 | const shouldHide = autoHide && isHidden; 16 | 17 | return ( 18 |
23 |
24 |
{leftContent}
25 |
{rightContent}
26 |
27 |
28 | ); 29 | }; 30 | 31 | export const FooterButton = ({ children, ...props }: ButtonProps) => { 32 | return ( 33 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/utils/components/loading.tsx: -------------------------------------------------------------------------------- 1 | type LoadingProps = { 2 | className?: string; 3 | }; 4 | 5 | export const Loading = ({ className }: LoadingProps) => { 6 | return ( 7 |
8 |
9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/components/toast.tsx: -------------------------------------------------------------------------------- 1 | import { toast as sonnerToast, Toaster as SonnerToaster } from "sonner"; 2 | 3 | type ToastType = "success" | "error" | "info" | "default"; 4 | 5 | export const ToastContainer = () => { 6 | return ; 7 | }; 8 | 9 | export const showToast = (message: string, type: ToastType = "default") => { 10 | switch (type) { 11 | case "success": 12 | sonnerToast.success(message); 13 | break; 14 | case "error": 15 | sonnerToast.error(message); 16 | break; 17 | case "info": 18 | sonnerToast.info(message); 19 | break; 20 | default: 21 | sonnerToast(message); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_STORAGE_KEYS = { 2 | EDITOR_CONTENT: "ephe:editor-content", 3 | COMPLETED_TASKS: "ephe:completed-tasks", 4 | SNAPSHOTS: "ephe:snapshots", 5 | THEME: "ephe:theme", 6 | EDITOR_WIDTH: "ephe:editor-width", 7 | PAPER_MODE: "ephe:paper-mode", 8 | PREVIEW_MODE: "ephe:preview-mode", 9 | TOC_MODE: "ephe:toc-mode", 10 | TASK_AUTO_FLUSH_MODE: "ephe:task-auto-flush-mode", 11 | } as const; 12 | 13 | export const EPHE_VERSION = "0.0.1"; 14 | -------------------------------------------------------------------------------- /src/utils/hooks/use-char-count.ts: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { atom } from "jotai"; 3 | import { LOCAL_STORAGE_KEYS } from "../constants"; 4 | import { useEffect } from "react"; 5 | 6 | export const charCountAtom = atom(0); 7 | 8 | export const useCharCount = () => { 9 | const [charCount, setCharCount] = useAtom(charCountAtom); 10 | 11 | // Initialize character count from editor content if not already set 12 | useEffect(() => { 13 | if (charCount === 0) { 14 | const editorContent = localStorage.getItem(LOCAL_STORAGE_KEYS.EDITOR_CONTENT); 15 | const DOUBLE_QUOTE_SIZE = 2; 16 | if (editorContent == null) { 17 | setCharCount(0); 18 | } else { 19 | setCharCount(editorContent.length - DOUBLE_QUOTE_SIZE); 20 | } 21 | } 22 | }, [charCount, setCharCount]); 23 | 24 | return { charCount, setCharCount }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/hooks/use-command-k.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export const useCommandK = () => { 4 | const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); 5 | 6 | const toggleCommandMenu = () => { 7 | setIsCommandMenuOpen((prev) => !prev); 8 | }; 9 | 10 | useEffect(() => { 11 | const handleKeyDown = (event: KeyboardEvent) => { 12 | if (event.key === "k" && (event.ctrlKey || event.metaKey)) { 13 | event.preventDefault(); 14 | toggleCommandMenu(); 15 | } 16 | }; 17 | document.addEventListener("keydown", handleKeyDown); 18 | return () => { 19 | document.removeEventListener("keydown", handleKeyDown); 20 | }; 21 | }, [toggleCommandMenu]); 22 | 23 | return { isCommandMenuOpen, toggleCommandMenu }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/hooks/use-debounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export const useDebounce = (value: T, delay: number): T => { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | // Set a timeout to update the debounced value after the specified delay 8 | const timer = setTimeout(() => { 9 | setDebouncedValue(value); 10 | }, delay); 11 | 12 | // Clean up the timeout if the value changes before the delay has passed 13 | return () => { 14 | clearTimeout(timer); 15 | }; 16 | }, [value, delay]); 17 | 18 | return debouncedValue; 19 | }; 20 | 21 | // biome-ignore lint/suspicious/noExplicitAny: accept any function 22 | export const useDebouncedCallback = any>( 23 | fn: T, 24 | delay: number, 25 | ): ((...args: Parameters) => void) => { 26 | const [timeoutId, setTimeoutId] = useState(null); 27 | 28 | // Return a memoized version of the callback that only changes if fn or delay change 29 | return (...args: Parameters) => { 30 | // Clear the previous timeout 31 | if (timeoutId) { 32 | clearTimeout(timeoutId); 33 | } 34 | 35 | // Set a new timeout 36 | const id = setTimeout(() => { 37 | fn(...args); 38 | }, delay); 39 | 40 | setTimeoutId(id); 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/hooks/use-editor-width.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE_KEYS } from "../constants"; 2 | import { useAtom } from "jotai"; 3 | import { atomWithStorage } from "jotai/utils"; 4 | import type { ValueOf } from "../types"; 5 | 6 | const EDITOR_WITH = { 7 | NORMAL: "normal", 8 | WIDE: "wide", 9 | } as const; 10 | 11 | export type EditorWidth = ValueOf; 12 | 13 | const editorWidthAtom = atomWithStorage(LOCAL_STORAGE_KEYS.EDITOR_WIDTH, "normal"); 14 | 15 | export const useEditorWidth = () => { 16 | const [editorWidth, setEditorWidth] = useAtom(editorWidthAtom); 17 | 18 | const toggleEditorWidth = () => { 19 | setEditorWidth((prev) => (prev === EDITOR_WITH.NORMAL ? EDITOR_WITH.WIDE : EDITOR_WITH.NORMAL)); 20 | }; 21 | 22 | const setNormalWidth = () => { 23 | setEditorWidth(EDITOR_WITH.NORMAL); 24 | }; 25 | 26 | const setWideWidth = () => { 27 | setEditorWidth(EDITOR_WITH.WIDE); 28 | }; 29 | return { 30 | editorWidth, 31 | toggleEditorWidth, 32 | isWideMode: editorWidth === EDITOR_WITH.WIDE, 33 | setNormalWidth, 34 | setWideWidth, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/utils/hooks/use-mobile-detector.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useMobileDetector = () => { 4 | const [isMobile, setIsMobile] = useState(false); 5 | 6 | useEffect(() => { 7 | if (typeof window === "undefined") { 8 | return; 9 | } 10 | const checkMobile = () => { 11 | setIsMobile(window.innerWidth < 768); 12 | }; 13 | checkMobile(); 14 | window.addEventListener("resize", checkMobile); 15 | return () => { 16 | window.removeEventListener("resize", checkMobile); 17 | }; 18 | }, []); 19 | 20 | return { isMobile }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/hooks/use-paper-mode.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE_KEYS } from "../constants"; 2 | import { atomWithStorage } from "jotai/utils"; 3 | import { useAtom } from "jotai"; 4 | 5 | export type PaperMode = "normal" | "graph" | "dots"; 6 | 7 | const PAPER_MODE_CLASSES = { 8 | normal: "bg-normal-paper", 9 | graph: "bg-graph-paper", 10 | dots: "bg-dots-paper", 11 | } as const; 12 | 13 | const paperModeAtom = atomWithStorage(LOCAL_STORAGE_KEYS.PAPER_MODE, "normal"); 14 | 15 | export const usePaperMode = () => { 16 | const [paperMode, setPaperMode] = useAtom(paperModeAtom); 17 | 18 | const cyclePaperMode = () => { 19 | const modes = ["normal", "graph", "dots"] as const; 20 | const currentIndex = modes.indexOf(paperMode); 21 | const nextIndex = (currentIndex + 1) % modes.length; 22 | setPaperMode(modes[nextIndex]); 23 | return modes[nextIndex]; 24 | }; 25 | 26 | const toggleNormalMode = () => { 27 | setPaperMode((prev) => (prev === "normal" ? "normal" : "normal")); 28 | }; 29 | 30 | const toggleGraphMode = () => { 31 | setPaperMode((prev) => (prev === "graph" ? "normal" : "graph")); 32 | }; 33 | 34 | const toggleDotsMode = () => { 35 | setPaperMode((prev) => (prev === "dots" ? "normal" : "dots")); 36 | }; 37 | 38 | return { 39 | paperMode, 40 | paperModeClass: PAPER_MODE_CLASSES[paperMode], 41 | cyclePaperMode, 42 | toggleNormalMode, 43 | toggleGraphMode, 44 | toggleDotsMode, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/utils/hooks/use-task-auto-flush.ts: -------------------------------------------------------------------------------- 1 | import { atomWithStorage } from "jotai/utils"; 2 | import { LOCAL_STORAGE_KEYS } from "../constants"; 3 | import { useAtom } from "jotai"; 4 | 5 | export type TaskAutoFlushMode = "off" | "instant"; 6 | 7 | export const taskAutoFlushAtom = atomWithStorage(LOCAL_STORAGE_KEYS.TASK_AUTO_FLUSH_MODE, "off"); 8 | 9 | export const useTaskAutoFlush = () => { 10 | const [taskAutoFlushMode, setTaskAutoFlushMode] = useAtom(taskAutoFlushAtom); 11 | return { taskAutoFlushMode, setTaskAutoFlushMode }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/hooks/use-theme.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAtom } from "jotai"; 4 | import { atomWithStorage } from "jotai/utils"; 5 | import { useEffect } from "react"; 6 | import { LOCAL_STORAGE_KEYS } from "../constants"; 7 | import { COLOR_THEME, type ColorTheme, applyTheme } from "../theme-initializer"; 8 | 9 | const themeAtom = atomWithStorage(LOCAL_STORAGE_KEYS.THEME, COLOR_THEME.SYSTEM); 10 | 11 | export const useTheme = () => { 12 | const [theme, setTheme] = useAtom(themeAtom); 13 | 14 | useEffect(() => { 15 | applyTheme(theme); 16 | }, [theme]); 17 | 18 | // Listen for system theme changes 19 | useEffect(() => { 20 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 21 | const handleThemeChange = () => { 22 | if (theme === COLOR_THEME.SYSTEM) { 23 | applyTheme(theme); 24 | } 25 | }; 26 | mediaQuery.addEventListener("change", handleThemeChange); 27 | return () => { 28 | mediaQuery.removeEventListener("change", handleThemeChange); 29 | }; 30 | }, [theme]); 31 | 32 | const nextTheme = theme === COLOR_THEME.LIGHT ? COLOR_THEME.DARK : COLOR_THEME.LIGHT; 33 | const isDarkMode = 34 | theme === COLOR_THEME.DARK || 35 | (theme === COLOR_THEME.SYSTEM && window.matchMedia("(prefers-color-scheme: dark)").matches); 36 | 37 | return { theme, nextTheme, setTheme, isDarkMode }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/hooks/use-user-activity.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | 3 | type UseUserActivityOptions = { 4 | showDelay?: number; 5 | }; 6 | 7 | export const useUserActivity = (options: UseUserActivityOptions = {}) => { 8 | const { showDelay = 1000 } = options; 9 | const [isTyping, setIsTyping] = useState(false); 10 | const [isScrolling, setIsScrolling] = useState(false); 11 | const typingTimeoutRef = useRef(undefined); 12 | const scrollTimeoutRef = useRef(undefined); 13 | 14 | const handleTypingStart = () => { 15 | setIsTyping(true); 16 | if (typingTimeoutRef.current) { 17 | window.clearTimeout(typingTimeoutRef.current); 18 | } 19 | }; 20 | 21 | const handleTypingEnd = () => { 22 | if (typingTimeoutRef.current) { 23 | window.clearTimeout(typingTimeoutRef.current); 24 | } 25 | 26 | typingTimeoutRef.current = window.setTimeout(() => { 27 | setIsTyping(false); 28 | }, showDelay); 29 | }; 30 | 31 | const handleScrolling = () => { 32 | setIsScrolling(true); 33 | 34 | if (scrollTimeoutRef.current) { 35 | window.clearTimeout(scrollTimeoutRef.current); 36 | } 37 | 38 | scrollTimeoutRef.current = window.setTimeout(() => { 39 | setIsScrolling(false); 40 | }, showDelay); 41 | }; 42 | 43 | useEffect(() => { 44 | const handleKeyDown = () => handleTypingStart(); 45 | const handleKeyUp = () => handleTypingEnd(); 46 | const handleScroll = () => handleScrolling(); 47 | 48 | document.addEventListener("keydown", handleKeyDown); 49 | document.addEventListener("keyup", handleKeyUp); 50 | document.addEventListener("scroll", handleScroll, true); 51 | 52 | return () => { 53 | document.removeEventListener("keydown", handleKeyDown); 54 | document.removeEventListener("keyup", handleKeyUp); 55 | document.removeEventListener("scroll", handleScroll, true); 56 | 57 | if (typingTimeoutRef.current) { 58 | window.clearTimeout(typingTimeoutRef.current); 59 | } 60 | if (scrollTimeoutRef.current) { 61 | window.clearTimeout(scrollTimeoutRef.current); 62 | } 63 | }; 64 | }, []); 65 | 66 | const isActive = isTyping || isScrolling; 67 | 68 | return { 69 | isActive, 70 | isHidden: isActive, 71 | isTyping, 72 | isScrolling, 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /src/utils/storage/index.ts: -------------------------------------------------------------------------------- 1 | export interface StorageProvider { 2 | getItem: (key: string) => string | null; 3 | setItem: (key: string, value: string) => void; 4 | } 5 | 6 | // Define a DateFilter type to be reused 7 | export type DateFilter = { 8 | year?: number; 9 | month?: number; 10 | day?: number; 11 | }; 12 | 13 | // Base Storage interface using function properties 14 | type Storage = { 15 | getAll: () => T[]; 16 | getById: (id: string) => T | null; 17 | save: (item: T) => void; 18 | deleteById: (id: string) => void; 19 | deleteAll: () => void; 20 | }; 21 | 22 | // Create browser local storage provider 23 | export const createBrowserLocalStorage = (): StorageProvider => ({ 24 | getItem: (key: string): string | null => localStorage.getItem(key), 25 | setItem: (key: string, value: string): void => localStorage.setItem(key, value), 26 | }); 27 | 28 | // Pure utility functions for date handling 29 | export const formatDateKey = (date: Date): string => 30 | `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; 31 | 32 | export const filterItemsByDate = ( 33 | items: T[], 34 | filter?: DateFilter, 35 | ): T[] => { 36 | if (!filter) return items; 37 | 38 | return items.filter((item) => { 39 | const dateStr = 40 | "timestamp" in item && item.timestamp 41 | ? item.timestamp 42 | : "completedAt" in item && item.completedAt 43 | ? item.completedAt 44 | : ""; 45 | 46 | if (!dateStr) return false; 47 | 48 | const date = new Date(dateStr); 49 | const itemYear = date.getFullYear(); 50 | const itemMonth = date.getMonth() + 1; // JavaScript months are 0-indexed 51 | const itemDay = date.getDate(); 52 | 53 | // Apply filters 54 | if (filter.year && itemYear !== filter.year) return false; 55 | if (filter.month && itemMonth !== filter.month) return false; 56 | if (filter.day && itemDay !== filter.day) return false; 57 | 58 | return true; 59 | }); 60 | }; 61 | 62 | export const groupItemsByDate = ( 63 | items: T[], 64 | ): Record => { 65 | const itemsByDate: Record = {}; 66 | 67 | for (const item of items) { 68 | const dateStr = 69 | "timestamp" in item && item.timestamp 70 | ? item.timestamp 71 | : "completedAt" in item && item.completedAt 72 | ? item.completedAt 73 | : ""; 74 | 75 | if (!dateStr) continue; 76 | 77 | const date = new Date(dateStr); 78 | const localDateStr = formatDateKey(date); 79 | 80 | if (!itemsByDate[localDateStr]) { 81 | itemsByDate[localDateStr] = []; 82 | } 83 | itemsByDate[localDateStr].push(item); 84 | } 85 | 86 | return itemsByDate; 87 | }; 88 | 89 | // Generic storage factory function 90 | export const createStorage = (storage: StorageProvider, storageKey: string): Storage => { 91 | const getAll = (): T[] => { 92 | try { 93 | const itemsJson = storage.getItem(storageKey); 94 | return itemsJson ? JSON.parse(itemsJson) : []; 95 | } catch (error) { 96 | console.error(`Error retrieving items from ${storageKey}:`, error); 97 | return []; 98 | } 99 | }; 100 | 101 | const getById = (id: string): T | null => { 102 | const items = getAll(); 103 | return items.find((item) => item.id === id) || null; 104 | }; 105 | 106 | const save = (item: T): void => { 107 | try { 108 | const existingItems = getAll(); 109 | storage.setItem(storageKey, JSON.stringify([item, ...existingItems])); 110 | } catch (error) { 111 | console.error(`Error saving item to ${storageKey}:`, error); 112 | } 113 | }; 114 | 115 | const deleteById = (id: string): void => { 116 | try { 117 | const items = getAll(); 118 | const updatedItems = items.filter((item) => item.id !== id); 119 | storage.setItem(storageKey, JSON.stringify(updatedItems)); 120 | } catch (error) { 121 | console.error(`Error deleting item from ${storageKey}:`, error); 122 | } 123 | }; 124 | 125 | const deleteAll = (): void => { 126 | try { 127 | storage.setItem(storageKey, JSON.stringify([])); 128 | } catch (error) { 129 | console.error(`Error purging items from ${storageKey}:`, error); 130 | } 131 | }; 132 | 133 | return { 134 | getAll, 135 | getById, 136 | save, 137 | deleteById: deleteById, 138 | deleteAll: deleteAll, 139 | }; 140 | }; 141 | 142 | // Create singleton instances for the app to use 143 | export const defaultStorageProvider = createBrowserLocalStorage(); 144 | -------------------------------------------------------------------------------- /src/utils/theme-initializer.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE_KEYS } from "./constants"; 2 | 3 | export const COLOR_THEME = { 4 | LIGHT: "light", 5 | DARK: "dark", 6 | SYSTEM: "system", 7 | } as const; 8 | 9 | export type ColorTheme = (typeof COLOR_THEME)[keyof typeof COLOR_THEME]; 10 | 11 | // Apply theme to document based on current settings 12 | export const applyTheme = (theme: ColorTheme): void => { 13 | const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 14 | 15 | if (theme === COLOR_THEME.SYSTEM && prefersDark) { 16 | document.documentElement.classList.add("dark"); 17 | } else if (theme === COLOR_THEME.DARK) { 18 | document.documentElement.classList.add("dark"); 19 | } else { 20 | document.documentElement.classList.remove("dark"); 21 | } 22 | }; 23 | 24 | // This script runs immediately to set theme before CSS loads 25 | const initializeTheme = (): void => { 26 | const storedTheme = localStorage.getItem(LOCAL_STORAGE_KEYS.THEME); 27 | const theme = storedTheme ? (JSON.parse(storedTheme) as ColorTheme) : COLOR_THEME.SYSTEM; 28 | applyTheme(theme); 29 | }; 30 | 31 | // Initialize on script load 32 | initializeTheme(); 33 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type ValueOf = T[keyof T]; 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: "class", 4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: { 7 | colors: { 8 | primary: { 9 | DEFAULT: "#333333", 10 | 50: "#FFFFFF", 11 | 100: "#F5F5F5", 12 | 200: "#E0E0E0", 13 | 300: "#CCCCCC", 14 | 400: "#999999", 15 | 500: "#666666", 16 | 600: "#333333", 17 | 700: "#1F1F1F", 18 | 800: "#111111", 19 | 900: "#000000", 20 | }, 21 | blue: { 22 | DEFAULT: "#00A3FF", 23 | 50: "#E6F6FF", 24 | 100: "#CCE9FF", 25 | 200: "#99D3FF", 26 | 300: "#66BDFF", 27 | 400: "#33AEFF", 28 | 500: "#00A3FF", 29 | 600: "#0082CC", 30 | 700: "#006199", 31 | 800: "#004166", 32 | 900: "#002033", 33 | }, 34 | green: { 35 | DEFAULT: "#4CAF50", 36 | 50: "#ECFAED", 37 | 100: "#D0F5D2", 38 | 200: "#A1E7A4", 39 | 300: "#73D977", 40 | 400: "#59C95D", 41 | 500: "#4CAF50", 42 | 600: "#3E8F41", 43 | 700: "#2F6F32", 44 | 800: "#204F22", 45 | 900: "#102F13", 46 | }, 47 | purple: { 48 | DEFAULT: "#9C27B0", 49 | 50: "#F5E6F9", 50 | 100: "#EBCCF2", 51 | 200: "#D699E6", 52 | 300: "#C266D9", 53 | 400: "#AE33CC", 54 | 500: "#9C27B0", 55 | 600: "#7D1F8D", 56 | 700: "#5E176A", 57 | 800: "#3F1047", 58 | 900: "#1F0823", 59 | }, 60 | }, 61 | backgroundColor: (theme) => ({ 62 | ...theme("colors"), 63 | }), 64 | textColor: (theme) => ({ 65 | ...theme("colors"), 66 | }), 67 | borderColor: (theme) => ({ 68 | ...theme("colors"), 69 | }), 70 | button: { 71 | primary: { 72 | backgroundColor: "#333333", 73 | textColor: "#FFFFFF", 74 | hoverBackgroundColor: "#111111", 75 | }, 76 | secondary: { 77 | backgroundColor: "#666666", 78 | textColor: "#FFFFFF", 79 | hoverBackgroundColor: "#999999", 80 | }, 81 | tertiary: { 82 | backgroundColor: "#FFFFFF", 83 | textColor: "#333333", 84 | borderColor: "#333333", 85 | hoverBackgroundColor: "#F5F5F5", 86 | }, 87 | }, 88 | }, 89 | fontFamily: { 90 | // "space-mono": ["var(--font-space-mono)"], 91 | }, 92 | gridTemplateColumns: { 93 | 24: "repeat(24, minmax(0, 1fr))", 94 | }, 95 | animation: { 96 | "fade-in": "fadeIn 0.3s ease-out", 97 | }, 98 | keyframes: { 99 | fadeIn: { 100 | "0%": { opacity: "0", transform: "translate(-50%, 10px) scale(0.95)" }, 101 | "100%": { opacity: "1", transform: "translate(-50%, 0) scale(1)" }, 102 | }, 103 | }, 104 | }, 105 | plugins: [ 106 | // should remove this in v4 107 | require("@tailwindcss/typography"), 108 | ({ addComponents, theme }) => { 109 | const buttons = { 110 | ".btn-primary": { 111 | backgroundColor: theme("colors.primary.600"), 112 | color: theme("colors.neutral.50"), 113 | "&:hover": { 114 | backgroundColor: theme("colors.primary.700"), 115 | }, 116 | "&:focus": { 117 | boxShadow: `0 0 0 3px ${theme("colors.primary.100")}`, 118 | }, 119 | }, 120 | ".btn-secondary": { 121 | backgroundColor: theme("colors.primary.500"), 122 | color: theme("colors.neutral.50"), 123 | "&:hover": { 124 | backgroundColor: theme("colors.primary.400"), 125 | }, 126 | "&:focus": { 127 | boxShadow: `0 0 0 3px ${theme("colors.primary.100")}`, 128 | }, 129 | }, 130 | ".btn-outline": { 131 | backgroundColor: "transparent", 132 | color: theme("colors.primary.600"), 133 | borderWidth: "1px", 134 | borderColor: theme("colors.primary.600"), 135 | "&:hover": { 136 | backgroundColor: theme("colors.primary.50"), 137 | }, 138 | "&:focus": { 139 | boxShadow: `0 0 0 3px ${theme("colors.primary.100")}`, 140 | }, 141 | }, 142 | }; 143 | addComponents(buttons); 144 | }, 145 | ], 146 | }; 147 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | "moduleResolution": "bundler", 8 | "allowImportingTsExtensions": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "noEmit": true, 12 | "jsx": "react-jsx", 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "types": ["vitest/importMeta"] 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import wasm from "vite-plugin-wasm"; 4 | import topLevelAwait from "vite-plugin-top-level-await"; 5 | import tailwindcss from "@tailwindcss/vite"; 6 | import { visualizer } from "rollup-plugin-visualizer"; 7 | 8 | const ReactCompilerConfig = {}; 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig({ 12 | plugins: [ 13 | react({ 14 | babel: { 15 | plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]], 16 | }, 17 | }), 18 | wasm(), 19 | topLevelAwait(), 20 | tailwindcss(), 21 | visualizer({ 22 | filename: "bundle-size.html", 23 | open: true, 24 | template: "treemap", 25 | }), 26 | ], 27 | server: { 28 | port: 3000, 29 | }, 30 | build: { 31 | outDir: "dist", 32 | sourcemap: true, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | includeSource: ["src/**/*.{js,ts}"], 9 | coverage: { 10 | provider: "v8", 11 | }, 12 | workspace: [ 13 | { 14 | test: { 15 | name: "node", 16 | include: ["src/**/*.test.ts"], 17 | exclude: ["src/**/*.browser.test.ts"], 18 | environment: "node", 19 | }, 20 | }, 21 | { 22 | test: { 23 | name: "browser", 24 | include: ["src/**/*.browser.test.ts"], 25 | browser: { 26 | enabled: true, 27 | provider: "playwright", 28 | instances: [{ browser: "chromium" }], 29 | }, 30 | }, 31 | }, 32 | ], 33 | }, 34 | }); 35 | --------------------------------------------------------------------------------