├── .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 |
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 |
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 |
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 |
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 |
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 | - A markdown paper to organize your daily todos and thoughts.
11 | -
12 |
18 | OSS
19 |
20 | , and free.
21 |
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 |
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 |
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 |
--------------------------------------------------------------------------------