├── src ├── utils │ ├── types.ts │ ├── components │ │ ├── loading.tsx │ │ ├── toast.tsx │ │ ├── tooltip.tsx │ │ ├── error-boundary.tsx │ │ └── footer.tsx │ ├── README.md │ ├── hooks │ │ ├── use-task-auto-flush.ts │ │ ├── use-mobile-detector.ts │ │ ├── use-char-count.ts │ │ ├── use-editor-mode.ts │ │ ├── use-editor-width.ts │ │ ├── use-command-k.ts │ │ ├── use-paper-mode.ts │ │ ├── use-word-count.test.ts │ │ ├── use-theme.ts │ │ ├── use-font.ts │ │ ├── use-word-count.ts │ │ └── use-user-activity.ts │ ├── platform.ts │ ├── constants.ts │ ├── atoms │ │ ├── editor.ts │ │ └── multi-document.ts │ ├── theme-initializer.ts │ ├── platform.test.ts │ └── storage │ │ └── index.ts ├── features │ ├── editor │ │ ├── editor-ref.ts │ │ ├── markdown │ │ │ └── formatter │ │ │ │ ├── markdown-formatter.ts │ │ │ │ └── dprint-markdown-formatter.ts │ │ ├── codemirror │ │ │ ├── tasklist │ │ │ │ ├── task-section-utils.ts │ │ │ │ ├── auto-complete.ts │ │ │ │ ├── tas-section-utils.browser.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── task-list-utils.ts │ │ │ │ ├── task-list-utils.browser.test.ts │ │ │ │ ├── auto-complete.browser.test.ts │ │ │ │ └── task-reorder.ts │ │ │ ├── codemirror-editor.tsx │ │ │ ├── use-cursor-position.ts │ │ │ ├── use-toc.ts │ │ │ ├── url-click.ts │ │ │ ├── url-click.browser.test.ts │ │ │ ├── use-editor-theme.ts │ │ │ └── use-markdown-editor.ts │ │ ├── quotes.ts │ │ ├── quotes.test.ts │ │ ├── multi │ │ │ ├── multi-context.tsx │ │ │ ├── document-navigation.tsx │ │ │ ├── multi-editor.tsx │ │ │ └── dock-menu.tsx │ │ ├── tasks │ │ │ └── task-storage.ts │ │ └── table-of-contents.tsx │ ├── snapshots │ │ └── snapshot-storage.ts │ ├── integration │ │ └── github │ │ │ ├── github-api.test.ts │ │ │ └── github-api.ts │ ├── time-display │ │ └── hours-display.tsx │ └── history │ │ └── use-history-data.ts ├── page │ ├── 404-page.tsx │ ├── landing-page.tsx │ └── editor-page.tsx ├── main.tsx ├── scripts │ └── copy-wasm.ts └── globals.css ├── public ├── ephe-192.png ├── ephe-512.png ├── favicon.ico ├── ephe-large.png ├── ephe-small.png ├── wasm │ └── dprint-markdown.wasm └── ephe.svg ├── postcss.config.mjs ├── .cursor └── rules │ ├── agent-requested │ ├── lint-format.mdc │ ├── state-management.mdc │ └── editor-features.mdc │ ├── auto-attached │ ├── typescript.mdc │ ├── testing.mdc │ └── code-organization.mdc │ └── always │ ├── naming-conventions.mdc │ ├── documentation.mdc │ ├── general.mdc │ └── project-structure.mdc ├── tsconfig.node.json ├── .github ├── copilot-instructions.md ├── FUNDING.yml ├── guide.md └── workflows │ ├── claude.yml │ └── ci.yml ├── README.md ├── tsconfig.json ├── playwright.config.ts ├── .gitignore ├── eslint.config.js ├── vitest.config.ts ├── LICENSE ├── biome.jsonc ├── index.html ├── package.json ├── vite.config.ts ├── CLAUDE.md ├── tailwind.config.js └── e2e └── editor.spec.ts /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type ValueOf = T[keyof T]; 2 | -------------------------------------------------------------------------------- /public/ephe-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/HEAD/public/ephe-192.png -------------------------------------------------------------------------------- /public/ephe-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/HEAD/public/ephe-512.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/ephe-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/HEAD/public/ephe-large.png -------------------------------------------------------------------------------- /public/ephe-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/HEAD/public/ephe-small.png -------------------------------------------------------------------------------- /public/wasm/dprint-markdown.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unvalley/ephe/HEAD/public/wasm/dprint-markdown.wasm -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/components/loading.tsx: -------------------------------------------------------------------------------- 1 | type LoadingProps = { 2 | className?: string; 3 | }; 4 | 5 | export const Loading = ({ className }: LoadingProps) => { 6 | return ( 7 |
8 |
9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/features/editor/editor-ref.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | 3 | export type BaseEditorRef = { 4 | view: EditorView | null; 5 | getCurrentContent: () => string; 6 | setContent: (content: string) => void; 7 | }; 8 | 9 | export type SingleEditorRef = BaseEditorRef; 10 | 11 | export type MultiEditorRef = BaseEditorRef & { 12 | navigateToDocument: (index: number) => void; 13 | }; 14 | -------------------------------------------------------------------------------- /.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. -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Ephe

3 | 4 | ephe-readme 5 | 6 |

7 | Ephe is an ephemeral markdown paper 8 | to organize your daily todos and thoughts. 9 |

10 |
11 | 12 | Traditional todo apps can be overwhelming. 13 | Ephe is designed to organize your tasks with plain Markdown. 14 | Ephe gives you just one clean page to focus your day. 15 | 16 | See the guide for details. 17 | 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/platform.ts: -------------------------------------------------------------------------------- 1 | export const getPlatform = () => { 2 | if (typeof navigator === "undefined") return "linux"; 3 | const ua = navigator.userAgent.toLowerCase(); 4 | if (ua.includes("mac")) return "mac"; 5 | if (ua.includes("win")) return "windows"; 6 | return "linux"; 7 | }; 8 | 9 | export const isMac = () => getPlatform() === "mac"; 10 | export const isWindows = () => getPlatform() === "windows"; 11 | export const isLinux = () => getPlatform() === "linux"; 12 | 13 | export const getModifierKeyName = () => { 14 | return isMac() ? "Cmd" : "Ctrl"; 15 | }; 16 | 17 | export const isLinkActivationModifier = (e: MouseEvent): boolean => (isMac() ? e.metaKey : e.ctrlKey); 18 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/page/404-page.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | 3 | export const NotFound: FC = () => { 4 | return ( 5 |
6 |
7 |

404

8 |

Page not found

9 | 13 | Back to Editor 14 | 15 |
16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/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 | FONT_FAMILY: "ephe:font-family", 12 | CURSOR_POSITION: "ephe:cursor-position", 13 | DOCUMENTS: "ephe:documents", 14 | ACTIVE_DOCUMENT_INDEX: "ephe:active-document-index", 15 | EDITOR_MODE: "ephe:editor-mode", 16 | } as const satisfies Record; 17 | 18 | export const EPHE_VERSION = "0.0.1"; 19 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /src/utils/components/toast.tsx: -------------------------------------------------------------------------------- 1 | import { toast as sonnerToast, Toaster as SonnerToaster, type ExternalToast } 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", options?: ExternalToast) => { 10 | switch (type) { 11 | case "success": 12 | sonnerToast.success(message, options); 13 | break; 14 | case "error": 15 | sonnerToast.error(message, options); 16 | break; 17 | case "info": 18 | sonnerToast.info(message, options); 19 | break; 20 | default: 21 | sonnerToast(message, options); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /src/features/editor/multi/multi-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, type ReactNode } from "react"; 2 | 3 | type MultiDocumentContextValue = { 4 | navigateToDocument: (index: number) => void; 5 | }; 6 | 7 | const MultiDocumentContext = createContext(null); 8 | 9 | export const MultiDocumentProvider = ({ 10 | children, 11 | navigateToDocument, 12 | }: { 13 | children: ReactNode; 14 | navigateToDocument: (index: number) => void; 15 | }) => { 16 | return {children}; 17 | }; 18 | 19 | export const useMultiDocument = () => { 20 | const context = useContext(MultiDocumentContext); 21 | if (!context) { 22 | throw new Error("useMultiDocument must be used within MultiDocumentProvider"); 23 | } 24 | return context; 25 | }; 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/utils/components/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | type TooltipProps = { 4 | children: ReactNode; 5 | isVisible: boolean; 6 | position?: "top" | "bottom" | "left" | "right"; 7 | className?: string; 8 | }; 9 | 10 | export const Tooltip = ({ children, isVisible, position = "bottom", className = "" }: TooltipProps) => { 11 | const positionClasses = { 12 | top: "bottom-full mb-2", 13 | bottom: "top-full mt-2", 14 | left: "right-full mr-2", 15 | right: "left-full ml-2", 16 | }; 17 | 18 | return ( 19 |
24 | {children} 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /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/utils/hooks/use-editor-mode.ts: -------------------------------------------------------------------------------- 1 | import { LOCAL_STORAGE_KEYS } from "../constants"; 2 | import { atomWithStorage } from "jotai/utils"; 3 | import { useAtom } from "jotai"; 4 | 5 | export type EditorMode = "single" | "multi"; 6 | 7 | const editorModeAtom = atomWithStorage(LOCAL_STORAGE_KEYS.EDITOR_MODE, "single"); 8 | 9 | export const useEditorMode = () => { 10 | const [editorMode, setEditorMode] = useAtom(editorModeAtom); 11 | 12 | const toggleEditorMode = () => { 13 | setEditorMode((prev) => (prev === "single" ? "multi" : "single")); 14 | }; 15 | 16 | const setSingleMode = () => { 17 | setEditorMode("single"); 18 | }; 19 | 20 | const setMultiMode = () => { 21 | setEditorMode("multi"); 22 | }; 23 | 24 | return { 25 | editorMode, 26 | toggleEditorMode, 27 | setSingleMode, 28 | setMultiMode, 29 | isSingleMode: editorMode === "single", 30 | isMultiMode: editorMode === "multi", 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /public/ephe.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/utils/atoms/editor.ts: -------------------------------------------------------------------------------- 1 | import { atomWithStorage, createJSONStorage } from "jotai/utils"; 2 | import { LOCAL_STORAGE_KEYS } from "../constants"; 3 | 4 | const storage = createJSONStorage(() => localStorage); 5 | 6 | const crossTabStorage = { 7 | ...storage, 8 | subscribe: (key: string, callback: (value: string) => void, initialValue: string): (() => void) => { 9 | if (typeof window === "undefined" || typeof window.addEventListener !== "function") { 10 | return () => {}; 11 | } 12 | const handler = (e: StorageEvent) => { 13 | if (e.storageArea === localStorage && e.key === key) { 14 | try { 15 | const newValue = e.newValue ? JSON.parse(e.newValue) : initialValue; 16 | callback(newValue); 17 | } catch { 18 | callback(initialValue); 19 | } 20 | } 21 | }; 22 | window.addEventListener("storage", handler); 23 | return () => window.removeEventListener("storage", handler); 24 | }, 25 | }; 26 | 27 | export const editorContentAtom = atomWithStorage(LOCAL_STORAGE_KEYS.EDITOR_CONTENT, "", crossTabStorage); 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/auto-complete.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from "@codemirror/state"; 2 | import { EditorView } from "@codemirror/view"; 3 | 4 | export const taskAutoComplete: Extension = EditorView.inputHandler.of((view, from, to, text) => { 5 | const isValidInput = text === " " && from === to && from >= 2; 6 | if (!isValidInput) return false; 7 | 8 | const doc = view.state.doc; 9 | const line = doc.lineAt(from); 10 | const linePrefix = doc.sliceString(line.from, from); 11 | 12 | const patterns = [ 13 | { suffix: "- [", offset: 3 }, 14 | { suffix: "-[", offset: 2 }, 15 | ]; 16 | 17 | const matchedPattern = patterns.find(({ suffix }) => linePrefix.endsWith(suffix)); 18 | if (!matchedPattern) return false; 19 | 20 | const insertFrom = from - matchedPattern.offset; 21 | if (insertFrom < line.from) return false; 22 | 23 | const replacement = "- [ ] "; 24 | 25 | view.dispatch({ 26 | changes: { 27 | from: insertFrom, 28 | to: from, 29 | insert: replacement, 30 | }, 31 | selection: { anchor: insertFrom + replacement.length }, 32 | }); 33 | 34 | return true; 35 | }); 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | - Cmd + / moves tasks and lists up or down 34 | 35 | 36 | I hope this one page helps you start your day with focus. 37 | -------------------------------------------------------------------------------- /src/utils/hooks/use-command-k.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | 3 | export const useCommandK = (isModalOpen?: boolean) => { 4 | const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); 5 | 6 | const toggleCommandMenu = useCallback(() => { 7 | // Don't open command menu if a modal is open 8 | if (isModalOpen) return; 9 | setIsCommandMenuOpen((prev) => !prev); 10 | }, [isModalOpen]); 11 | 12 | const closeCommandMenu = useCallback(() => { 13 | setIsCommandMenuOpen(false); 14 | }, []); 15 | 16 | useEffect(() => { 17 | const handleKeyDown = (event: KeyboardEvent) => { 18 | if (event.key === "k" && (event.ctrlKey || event.metaKey)) { 19 | event.preventDefault(); 20 | toggleCommandMenu(); 21 | } 22 | }; 23 | document.addEventListener("keydown", handleKeyDown); 24 | return () => { 25 | document.removeEventListener("keydown", handleKeyDown); 26 | }; 27 | }, [toggleCommandMenu]); 28 | 29 | // Close command menu if modal opens 30 | useEffect(() => { 31 | if (isModalOpen && isCommandMenuOpen) { 32 | closeCommandMenu(); 33 | } 34 | }, [isModalOpen, isCommandMenuOpen, closeCommandMenu]); 35 | 36 | return { isCommandMenuOpen, toggleCommandMenu, closeCommandMenu }; 37 | }; 38 | -------------------------------------------------------------------------------- /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 | "css": { 52 | "parser": { 53 | "tailwindDirectives": true 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ephe - Ephemeral Markdown Paper 8 | 9 | 10 | 11 | 12 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/codemirror-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMarkdownEditor } from "./use-markdown-editor"; 4 | import { useImperativeHandle } from "react"; 5 | import type { SingleEditorRef } from "../editor-ref"; 6 | 7 | type CodeMirrorEditorProps = { 8 | initialContent?: string; 9 | documentId?: string; 10 | onChange?: (content: string) => void; 11 | ref?: React.Ref; 12 | }; 13 | 14 | export const CodeMirrorEditor = ({ initialContent, documentId, onChange, ref }: CodeMirrorEditorProps) => { 15 | const { editor, view: viewRef } = useMarkdownEditor(initialContent, documentId, onChange); 16 | 17 | useImperativeHandle( 18 | ref, 19 | () => ({ 20 | get view() { 21 | return viewRef.current; 22 | }, 23 | getCurrentContent: () => { 24 | return viewRef.current?.state.doc.toString() ?? ""; 25 | }, 26 | setContent: (content: string) => { 27 | const view = viewRef.current; 28 | if (view) { 29 | view.dispatch({ 30 | changes: { 31 | from: 0, 32 | to: view.state.doc.length, 33 | insert: content, 34 | }, 35 | }); 36 | } 37 | }, 38 | }), 39 | [], 40 | ); 41 | 42 | return
; 43 | }; 44 | -------------------------------------------------------------------------------- /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/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/utils/hooks/use-word-count.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { countWords } from "./use-word-count"; 3 | 4 | describe("countWords", () => { 5 | it("counts English words correctly", () => { 6 | expect(countWords("Hello world")).toBe(2); 7 | expect(countWords("This is a test")).toBe(4); 8 | expect(countWords(" Multiple spaces between words ")).toBe(4); 9 | }); 10 | 11 | it("counts Japanese words correctly", () => { 12 | expect(countWords("こんにちは")).toBe(1); // "こんにちは" as one word 13 | expect(countWords("カタカナ")).toBe(1); // "カタカナ" as one word 14 | expect(countWords("日本語")).toBe(1); // "日本語" as one word 15 | expect(countWords("私は学生です")).toBe(4); // 私/は/学生/です 16 | }); 17 | 18 | it("counts Korean words correctly", () => { 19 | expect(countWords("안녕하세요")).toBe(1); // "안녕하세요" as one word 20 | expect(countWords("한국어")).toBe(1); // "한국어" as one word 21 | }); 22 | 23 | it("counts mixed language text correctly", () => { 24 | expect(countWords("Hello 世界")).toBe(2); // 1 English word + 1 Japanese word 25 | expect(countWords("This is 日本語 text")).toBe(4); // 3 English words + 1 Japanese word 26 | }); 27 | 28 | it("handles punctuation correctly", () => { 29 | expect(countWords("Hello, world!")).toBe(2); 30 | expect(countWords("これは、テストです。")).toBe(4); // これ/は/テスト/です 31 | }); 32 | 33 | it("handles empty and whitespace strings", () => { 34 | expect(countWords("")).toBe(0); 35 | expect(countWords(" ")).toBe(0); 36 | expect(countWords("\n\t")).toBe(0); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /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 | centerContent?: React.ReactNode; 7 | rightContent: React.ReactNode; 8 | autoHide?: boolean; 9 | }; 10 | 11 | export const Footer = ({ leftContent, rightContent, centerContent, autoHide = false }: FooterProps) => { 12 | const { isHidden } = useUserActivity({ 13 | showDelay: 800, 14 | }); 15 | 16 | const shouldHide = autoHide && isHidden; 17 | 18 | return ( 19 |
24 |
25 |
{leftContent}
26 | {centerContent ? ( 27 |
{centerContent}
28 | ) : null} 29 |
{rightContent}
30 |
31 |
32 | ); 33 | }; 34 | 35 | export const FooterButton = ({ children, ...props }: ButtonProps) => { 36 | return ( 37 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/utils/hooks/use-theme.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAtom } from "jotai"; 4 | import { atomWithStorage } from "jotai/utils"; 5 | import { useEffect, useSyncExternalStore } 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 | const getMediaQuery = () => window.matchMedia("(prefers-color-scheme: dark)"); 12 | const getSystemPrefersDark = () => getMediaQuery().matches; 13 | 14 | const subscribeToSystemTheme = (callback: () => void) => { 15 | const mediaQuery = getMediaQuery(); 16 | mediaQuery.addEventListener("change", callback); 17 | return () => mediaQuery.removeEventListener("change", callback); 18 | }; 19 | 20 | export const useTheme = () => { 21 | const [theme, setTheme] = useAtom(themeAtom); 22 | const systemPrefersDark = useSyncExternalStore(subscribeToSystemTheme, getSystemPrefersDark, () => false); 23 | 24 | const isDarkMode = theme === COLOR_THEME.DARK || (theme === COLOR_THEME.SYSTEM && systemPrefersDark); 25 | 26 | useEffect(() => { 27 | applyTheme(theme); 28 | }, [theme, systemPrefersDark]); 29 | 30 | // Light -> Dark -> System 31 | const nextTheme = 32 | theme === COLOR_THEME.SYSTEM 33 | ? COLOR_THEME.LIGHT 34 | : theme === COLOR_THEME.LIGHT 35 | ? COLOR_THEME.DARK 36 | : COLOR_THEME.SYSTEM; 37 | 38 | const cycleTheme = () => { 39 | setTheme(nextTheme); 40 | }; 41 | 42 | return { theme, nextTheme, setTheme, isDarkMode, cycleTheme }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils/hooks/use-font.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAtom } from "jotai"; 4 | import { atomWithStorage } from "jotai/utils"; 5 | import { LOCAL_STORAGE_KEYS } from "../constants"; 6 | 7 | export const FONT_FAMILIES = { 8 | IA_WRITER_MONO: { 9 | value: "'iA Writer Mono', 'Menlo', 'Monaco', 'Courier New', monospace", 10 | displayValue: "iA Writer Mono", 11 | }, 12 | MONOSPACE: { 13 | value: "'monospace', 'Menlo', 'Monaco', 'Courier New'", 14 | displayValue: "Monospace", 15 | }, 16 | IBM_PLEX_MONO: { 17 | value: "'IBM Plex Mono', 'Menlo', 'Monaco', 'Courier New', monospace", 18 | displayValue: "IBM Plex Mono", 19 | }, 20 | MYNERVE: { 21 | value: "'Mynerve', cursive", 22 | displayValue: "Mynerve", 23 | }, 24 | } as const; 25 | 26 | export type FontFamily = keyof typeof FONT_FAMILIES; 27 | export const FONT_FAMILY_OPTIONS = Object.keys(FONT_FAMILIES) as (keyof typeof FONT_FAMILIES)[]; 28 | 29 | const fontFamilyAtom = atomWithStorage(LOCAL_STORAGE_KEYS.FONT_FAMILY, "IA_WRITER_MONO"); 30 | 31 | export const useFontFamily = () => { 32 | const [fontFamily, setFontFamily] = useAtom(fontFamilyAtom); 33 | 34 | const getFontValue = (family: FontFamily) => FONT_FAMILIES[family].value; 35 | const getFontDisplayValue = (family: FontFamily) => FONT_FAMILIES[family].displayValue; 36 | const currentFontValue = getFontValue(fontFamily); 37 | const currentFontDisplayValue = getFontDisplayValue(fontFamily); 38 | 39 | return { 40 | fontFamily, 41 | setFontFamily, 42 | currentFontValue, 43 | currentFontDisplayValue, 44 | getFontValue, 45 | getFontDisplayValue, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/use-cursor-position.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | import { atomWithStorage } from "jotai/utils"; 3 | import { useAtom } from "jotai"; 4 | import { useLayoutEffect } from "react"; 5 | import { LOCAL_STORAGE_KEYS } from "../../../utils/constants"; 6 | 7 | const INITIAL_CURSOR_POSITION = { from: 0, to: 0 }; 8 | const cursorAtom = atomWithStorage<{ from: number; to: number }>( 9 | LOCAL_STORAGE_KEYS.CURSOR_POSITION, 10 | INITIAL_CURSOR_POSITION, 11 | ); 12 | 13 | export const useCursorPosition = (view?: EditorView) => { 14 | const [cursorPosition, setCursorPosition] = useAtom(cursorAtom); 15 | 16 | useLayoutEffect(() => { 17 | if (typeof window === "undefined" || !view) return; 18 | 19 | if (cursorPosition.from || cursorPosition.to) { 20 | const len = view.state.doc.length; 21 | view.dispatch({ 22 | selection: { 23 | anchor: Math.min(cursorPosition.from, len), 24 | head: Math.min(cursorPosition.to, len), 25 | }, 26 | scrollIntoView: true, 27 | }); 28 | } 29 | 30 | const save = () => { 31 | const { from, to } = view.state.selection.main; 32 | setCursorPosition((prev) => (prev.from === from && prev.to === to ? prev : { from, to })); 33 | }; 34 | 35 | // only save cursor position when the page is hidden 36 | window.addEventListener("pagehide", save); 37 | return () => window.removeEventListener("pagehide", save); 38 | }, [view, cursorPosition, setCursorPosition]); 39 | 40 | const resetCursorPosition = () => setCursorPosition(INITIAL_CURSOR_POSITION); 41 | 42 | return { cursorPosition, resetCursorPosition }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils/hooks/use-word-count.ts: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from "jotai"; 2 | import { atom } from "jotai"; 3 | import { editorContentAtom } from "../atoms/editor"; 4 | 5 | // Derive word count from editor content 6 | export const wordCountAtom = atom((get) => { 7 | const content = get(editorContentAtom); 8 | return countWords(content); 9 | }); 10 | 11 | // Hook to use word count 12 | export const useWordCount = () => { 13 | const wordCount = useAtomValue(wordCountAtom); 14 | return { wordCount }; 15 | }; 16 | 17 | export const countWords = (text: string): number => { 18 | if (!text || text.trim().length === 0) { 19 | return 0; 20 | } 21 | 22 | // Use Intl.Segmenter if available 23 | // Note: It may over-segment Japanese text, but it's still better than nothing 24 | if (typeof Intl !== "undefined" && "Segmenter" in Intl) { 25 | try { 26 | // Detect if text contains Japanese characters 27 | const hasJapanese = /[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF]/.test(text); 28 | 29 | // Use appropriate locale: 'ja' for Japanese content, 'en' for others 30 | const locale = hasJapanese ? "ja" : "en"; 31 | const segmenter = new Intl.Segmenter(locale, { granularity: "word" }); 32 | const segments = segmenter.segment(text); 33 | 34 | let wordCount = 0; 35 | for (const segment of segments) { 36 | if (segment.isWordLike) { 37 | wordCount++; 38 | } 39 | } 40 | return wordCount; 41 | } catch (_e) { 42 | // Fall through to regex approach 43 | } 44 | } 45 | 46 | // Fallback: Count English words and Japanese character groups 47 | const englishWords = text.match(/\b[a-zA-Z0-9]+\b/g) || []; 48 | const japaneseGroups = text.match(/[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF]+/g) || []; 49 | 50 | return englishWords.length + japaneseGroups.length; 51 | }; 52 | -------------------------------------------------------------------------------- /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 | type 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/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/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/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 = 800 } = 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/atoms/multi-document.ts: -------------------------------------------------------------------------------- 1 | import { atomWithStorage } from "jotai/utils"; 2 | import { LOCAL_STORAGE_KEYS } from "../constants"; 3 | 4 | // Create cross-tab storage adapter for multi-document support 5 | const createCrossTabStorage = () => { 6 | return { 7 | getItem: (key: string, initialValue: T): T => { 8 | if (typeof window === "undefined") { 9 | return initialValue; 10 | } 11 | const item = localStorage.getItem(key); 12 | if (item === null) { 13 | return initialValue; 14 | } 15 | try { 16 | return JSON.parse(item); 17 | } catch { 18 | return initialValue; 19 | } 20 | }, 21 | setItem: (key: string, value: T): void => { 22 | if (typeof window === "undefined") { 23 | return; 24 | } 25 | localStorage.setItem(key, JSON.stringify(value)); 26 | }, 27 | removeItem: (key: string): void => { 28 | if (typeof window === "undefined") { 29 | return; 30 | } 31 | localStorage.removeItem(key); 32 | }, 33 | subscribe: (key: string, callback: (value: T) => void, initialValue: T): (() => void) => { 34 | if (typeof window === "undefined" || typeof window.addEventListener !== "function") { 35 | return () => {}; 36 | } 37 | const handler = (e: StorageEvent) => { 38 | if (e.storageArea === localStorage && e.key === key) { 39 | try { 40 | const newValue = e.newValue ? JSON.parse(e.newValue) : initialValue; 41 | callback(newValue); 42 | } catch { 43 | callback(initialValue); 44 | } 45 | } 46 | }; 47 | window.addEventListener("storage", handler); 48 | return () => window.removeEventListener("storage", handler); 49 | }, 50 | }; 51 | }; 52 | 53 | // Document interface for multi-document support 54 | export type Document = { 55 | id: string; 56 | content: string; 57 | lastModified: number; 58 | }; 59 | 60 | // Default documents initialization 61 | export const DEFAULT_DOCUMENTS: Document[] = Array.from({ length: 5 }, (_, i) => ({ 62 | id: `doc-${i}`, 63 | content: "", 64 | lastModified: Date.now(), 65 | })); 66 | 67 | export const documentsAtom = atomWithStorage( 68 | LOCAL_STORAGE_KEYS.DOCUMENTS, 69 | DEFAULT_DOCUMENTS, 70 | createCrossTabStorage(), 71 | ); 72 | 73 | export const activeDocumentIndexAtom = atomWithStorage(LOCAL_STORAGE_KEYS.ACTIVE_DOCUMENT_INDEX, 0); 74 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | github.actor == 'unvalley' && ( 17 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 19 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 20 | (github.event_name == 'issues' && ( 21 | contains(github.event.issue.body, '@claude') || 22 | contains(github.event.issue.title, '@claude') 23 | )) 24 | ) 25 | runs-on: ubuntu-latest 26 | permissions: 27 | contents: read 28 | pull-requests: read 29 | issues: read 30 | id-token: write 31 | actions: read # Required for Claude to read CI results on PRs 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 1 37 | 38 | - name: Run Claude Code 39 | id: claude 40 | uses: anthropics/claude-code-action@beta 41 | with: 42 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 43 | 44 | # This is an optional setting that allows Claude to read CI results on PRs 45 | additional_permissions: | 46 | actions: read 47 | 48 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 49 | model: "claude-opus-4-20250514" 50 | 51 | # Optional: Customize the trigger phrase (default: @claude) 52 | # trigger_phrase: "/claude" 53 | 54 | # Optional: Trigger when specific user is assigned to an issue 55 | assignee_trigger: "claude-bot" 56 | 57 | # Optional: Allow Claude to run specific commands 58 | allowed_tools: "Bash(pnpm:*),Bash(node:*)" 59 | 60 | # Optional: Add custom instructions for Claude to customize its behavior for your project 61 | custom_instructions: | 62 | Follow our coding standards 63 | Ensure all new code has tests 64 | Do not use `any` and type cast, use proper types instead 65 | 66 | # Optional: Custom environment variables for Claude 67 | # claude_env: | 68 | # NODE_ENV: test 69 | 70 | -------------------------------------------------------------------------------- /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 --unsafe", 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.10.0", 27 | "@codemirror/lang-markdown": "^6.5.0", 28 | "@codemirror/language": "^6.11.3", 29 | "@codemirror/language-data": "^6.5.2", 30 | "@codemirror/state": "^6.5.2", 31 | "@codemirror/view": "^6.39.3", 32 | "@dprint/formatter": "^0.4.1", 33 | "@dprint/markdown": "^0.18.0", 34 | "@headlessui/react": "^2.2.9", 35 | "@lezer/highlight": "^1.2.3", 36 | "@phosphor-icons/react": "^2.1.10", 37 | "cmdk": "^1.1.1", 38 | "codemirror": "^6.0.2", 39 | "jotai": "^2.16.0", 40 | "motion": "^12.23.26", 41 | "react": "^19.2.3", 42 | "react-dom": "^19.2.3", 43 | "react-router-dom": "^7.10.1", 44 | "sonner": "^2.0.7", 45 | "use-debounce": "^10.0.6" 46 | }, 47 | "devDependencies": { 48 | "@biomejs/biome": "latest", 49 | "@playwright/test": "^1.57.0", 50 | "@tailwindcss/postcss": "^4.1.18", 51 | "@tailwindcss/typography": "^0.5.19", 52 | "@tailwindcss/vite": "^4.1.18", 53 | "@types/node": "^22.19.2", 54 | "@types/react": "^19.2.7", 55 | "@types/react-dom": "^19.2.3", 56 | "@typescript-eslint/parser": "^8.49.0", 57 | "@vitejs/plugin-react": "^4.7.0", 58 | "@vitest/browser": "^3.2.4", 59 | "@vitest/coverage-v8": "^3.2.4", 60 | "@vitest/ui": "3.0.8", 61 | "autoprefixer": "^10.4.22", 62 | "babel-plugin-react-compiler": "19.1.0-rc.1", 63 | "eslint": "^9.39.1", 64 | "eslint-plugin-react-hooks": "6.0.0-rc1", 65 | "globals": "^16.5.0", 66 | "postcss": "^8.5.6", 67 | "rollup-plugin-visualizer": "^5.14.0", 68 | "tailwindcss": "^4.1.18", 69 | "typescript": "^5.9.3", 70 | "vite": "^7.2.7", 71 | "vite-plugin-pwa": "^1.2.0", 72 | "vite-plugin-top-level-await": "^1.6.0", 73 | "vite-plugin-wasm": "^3.5.0", 74 | "vitest": "^3.2.4" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /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/page/landing-page.tsx: -------------------------------------------------------------------------------- 1 | export const LandingPage = () => { 2 | return ( 3 |
4 |
5 |
6 |

7 | Ephe is : 8 |

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

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

29 |
30 | 31 |
32 |

Why :

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

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

50 |
51 | 52 | 60 |
61 | 62 | 74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /src/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 type 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/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 | -------------------------------------------------------------------------------- /.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: true 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: true 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: true 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 -------------------------------------------------------------------------------- /src/utils/platform.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, vi, beforeEach, afterAll } from "vitest"; 2 | import { getPlatform, isMac, isWindows, isLinux, getModifierKeyName } from "./platform"; 3 | 4 | describe("platform utilities", () => { 5 | const originalUserAgent = navigator.userAgent; 6 | 7 | beforeEach(() => { 8 | vi.restoreAllMocks(); 9 | }); 10 | 11 | describe("getPlatform", () => { 12 | test("detects macOS", () => { 13 | Object.defineProperty(navigator, "userAgent", { 14 | value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", 15 | configurable: true, 16 | }); 17 | expect(getPlatform()).toBe("mac"); 18 | }); 19 | 20 | test("detects Windows", () => { 21 | Object.defineProperty(navigator, "userAgent", { 22 | value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", 23 | configurable: true, 24 | }); 25 | expect(getPlatform()).toBe("windows"); 26 | }); 27 | 28 | test("defaults to Linux for other platforms", () => { 29 | Object.defineProperty(navigator, "userAgent", { 30 | value: "Mozilla/5.0 (X11; Linux x86_64)", 31 | configurable: true, 32 | }); 33 | expect(getPlatform()).toBe("linux"); 34 | }); 35 | }); 36 | 37 | describe("platform checks", () => { 38 | test("isMac returns true for macOS", () => { 39 | Object.defineProperty(navigator, "userAgent", { 40 | value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", 41 | configurable: true, 42 | }); 43 | expect(isMac()).toBe(true); 44 | expect(isWindows()).toBe(false); 45 | expect(isLinux()).toBe(false); 46 | }); 47 | 48 | test("isWindows returns true for Windows", () => { 49 | Object.defineProperty(navigator, "userAgent", { 50 | value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", 51 | configurable: true, 52 | }); 53 | expect(isMac()).toBe(false); 54 | expect(isWindows()).toBe(true); 55 | expect(isLinux()).toBe(false); 56 | }); 57 | 58 | test("isLinux returns true for Linux", () => { 59 | Object.defineProperty(navigator, "userAgent", { 60 | value: "Mozilla/5.0 (X11; Linux x86_64)", 61 | configurable: true, 62 | }); 63 | expect(isMac()).toBe(false); 64 | expect(isWindows()).toBe(false); 65 | expect(isLinux()).toBe(true); 66 | }); 67 | }); 68 | 69 | describe("modifier keys", () => { 70 | test("returns metaKey for macOS", () => { 71 | Object.defineProperty(navigator, "userAgent", { 72 | value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", 73 | configurable: true, 74 | }); 75 | expect(getModifierKeyName()).toBe("Cmd"); 76 | }); 77 | 78 | test("returns ctrlKey for Windows", () => { 79 | Object.defineProperty(navigator, "userAgent", { 80 | value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", 81 | configurable: true, 82 | }); 83 | expect(getModifierKeyName()).toBe("Ctrl"); 84 | }); 85 | 86 | test("returns ctrlKey for Linux", () => { 87 | Object.defineProperty(navigator, "userAgent", { 88 | value: "Mozilla/5.0 (X11; Linux x86_64)", 89 | configurable: true, 90 | }); 91 | expect(getModifierKeyName()).toBe("Ctrl"); 92 | }); 93 | }); 94 | 95 | // Restore original userAgent 96 | afterAll(() => { 97 | Object.defineProperty(navigator, "userAgent", { 98 | value: originalUserAgent, 99 | configurable: true, 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /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 | import { VitePWA } from "vite-plugin-pwa"; 8 | 9 | const ReactCompilerConfig = {}; 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig({ 13 | plugins: [ 14 | react({ 15 | babel: { 16 | plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]], 17 | }, 18 | }), 19 | wasm(), 20 | topLevelAwait(), 21 | tailwindcss(), 22 | VitePWA({ 23 | registerType: "autoUpdate", 24 | injectRegister: "inline", 25 | includeAssets: ["ephe.svg", "ephe-192.png", "ephe-512.png"], 26 | manifest: { 27 | name: "Ephe - Ephemeral Markdown Paper", 28 | short_name: "Ephe", 29 | description: "Ephe is an ephemeral markdown paper. Organize your day with ease.", 30 | theme_color: "#ffffff", 31 | background_color: "#ffffff", 32 | display: "standalone", 33 | orientation: "any", 34 | scope: "/", 35 | start_url: "/", 36 | icons: [ 37 | { 38 | src: "ephe-192.png", 39 | sizes: "192x192", 40 | type: "image/png", 41 | purpose: "any maskable", 42 | }, 43 | { 44 | src: "ephe-512.png", 45 | sizes: "512x512", 46 | type: "image/png", 47 | purpose: "any maskable", 48 | }, 49 | { 50 | src: "ephe.svg", 51 | sizes: "any", 52 | type: "image/svg+xml", 53 | purpose: "any", 54 | }, 55 | ], 56 | }, 57 | workbox: { 58 | globPatterns: ["**/*.{js,css,html,svg,png,wasm}"], 59 | maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB 60 | navigateFallback: "index.html", 61 | navigateFallbackDenylist: [/^\/_/, /\/[^/?]+\.[^/]+$/], 62 | runtimeCaching: [ 63 | { 64 | urlPattern: ({ request }) => request.mode === "navigate", 65 | handler: "NetworkFirst", 66 | options: { 67 | cacheName: "pages", 68 | networkTimeoutSeconds: 3, 69 | }, 70 | }, 71 | { 72 | urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, 73 | handler: "StaleWhileRevalidate", 74 | options: { 75 | cacheName: "google-fonts-stylesheets", 76 | expiration: { 77 | maxEntries: 10, 78 | maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days 79 | }, 80 | cacheableResponse: { 81 | statuses: [0, 200], 82 | }, 83 | }, 84 | }, 85 | { 86 | urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, 87 | handler: "CacheFirst", 88 | options: { 89 | cacheName: "google-fonts-webfonts", 90 | expiration: { 91 | maxEntries: 10, 92 | maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days 93 | }, 94 | cacheableResponse: { 95 | statuses: [0, 200], 96 | }, 97 | }, 98 | }, 99 | ], 100 | }, 101 | }), 102 | visualizer({ 103 | filename: "bundle-size.html", 104 | open: true, 105 | template: "treemap", 106 | }), 107 | ], 108 | server: { 109 | port: 3000, 110 | }, 111 | build: { 112 | outDir: "dist", 113 | sourcemap: true, 114 | }, 115 | }); 116 | -------------------------------------------------------------------------------- /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/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.slice()); 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/multi/document-navigation.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { activeDocumentIndexAtom, documentsAtom } from "../../../utils/atoms/multi-document"; 3 | import { useState, useEffect } from "react"; 4 | import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react"; 5 | import { useMultiDocument } from "./multi-context"; 6 | import { Tooltip } from "../../../utils/components/tooltip"; 7 | 8 | type NavigationCardProps = { 9 | direction: "left" | "right"; 10 | isVisible: boolean; 11 | onClick: () => void; 12 | documentIndex: number; 13 | documentPreview?: string; 14 | }; 15 | 16 | const NavigationCard = ({ direction, isVisible, onClick }: NavigationCardProps) => { 17 | return ( 18 |
27 | 48 |
49 | ); 50 | }; 51 | 52 | export const DocumentNavigation = () => { 53 | const [activeIndex] = useAtom(activeDocumentIndexAtom); 54 | const [documents] = useAtom(documentsAtom); 55 | const [showLeftCard, setShowLeftCard] = useState(false); 56 | const [showRightCard, setShowRightCard] = useState(false); 57 | 58 | const canGoLeft = activeIndex > 0; 59 | const canGoRight = activeIndex < documents.length - 1; 60 | 61 | const { navigateToDocument } = useMultiDocument(); 62 | 63 | useEffect(() => { 64 | const handleMouseMove = (e: MouseEvent) => { 65 | const { clientX, clientY } = e; 66 | const yThreshold = 80; // Vertical threshold for showing cards 67 | const xThreshold = 100; // Horizontal threshold for showing cards 68 | const inVerticalRange = clientY > yThreshold && clientY < window.innerHeight - yThreshold; 69 | 70 | setShowLeftCard(canGoLeft && clientX < xThreshold && inVerticalRange); 71 | setShowRightCard(canGoRight && clientX > window.innerWidth - xThreshold && inVerticalRange); 72 | }; 73 | 74 | window.addEventListener("mousemove", handleMouseMove); 75 | return () => window.removeEventListener("mousemove", handleMouseMove); 76 | }, [canGoLeft, canGoRight]); 77 | 78 | return ( 79 | <> 80 | {canGoLeft && ( 81 | navigateToDocument(activeIndex - 1)} 85 | documentIndex={activeIndex - 1} 86 | documentPreview={documents[activeIndex - 1]?.content} 87 | /> 88 | )} 89 | {canGoRight && ( 90 | navigateToDocument(activeIndex + 1)} 94 | documentIndex={activeIndex + 1} 95 | documentPreview={documents[activeIndex + 1]?.content} 96 | /> 97 | )} 98 | 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/url-click.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type EditorView, 3 | Decoration, 4 | type DecorationSet, 5 | ViewPlugin, 6 | type ViewUpdate, 7 | hoverTooltip, 8 | } from "@codemirror/view"; 9 | import { RangeSetBuilder } from "@codemirror/state"; 10 | import { getModifierKeyName, isLinkActivationModifier } from "../../../utils/platform"; 11 | 12 | const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g; 13 | const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`[\]()]+/g; 14 | 15 | const urlDecoration = Decoration.mark({ 16 | attributes: { 17 | style: "text-decoration: underline; text-underline-offset: 2px;", 18 | }, 19 | }); 20 | 21 | type Range = { from: number; to: number }; 22 | 23 | const getMatchRanges = (text: string, regex: RegExp): Range[] => 24 | Array.from(text.matchAll(regex)).flatMap((m) => 25 | m.index !== undefined 26 | ? [ 27 | { 28 | from: m.index, 29 | to: m.index + m[0].length, 30 | }, 31 | ] 32 | : [], 33 | ); 34 | 35 | const overlaps = (a: Range, b: Range) => !(a.to <= b.from || a.from >= b.to); 36 | 37 | const createUrlDecorations = (view: EditorView): DecorationSet => { 38 | const builder = new RangeSetBuilder(); 39 | const doc = view.state.doc; 40 | 41 | for (let i = 1; i <= doc.lines; i++) { 42 | const { from: lineStart, text } = doc.line(i); 43 | const mdRanges = getMatchRanges(text, MARKDOWN_LINK_REGEX); 44 | 45 | for (const { from, to } of mdRanges) { 46 | builder.add(lineStart + from, lineStart + to, urlDecoration); 47 | } 48 | 49 | const urlRanges = getMatchRanges(text, URL_REGEX).filter((range) => !mdRanges.some((md) => overlaps(range, md))); 50 | 51 | for (const { from, to } of urlRanges) { 52 | builder.add(lineStart + from, lineStart + to, urlDecoration); 53 | } 54 | } 55 | 56 | return builder.finish(); 57 | }; 58 | 59 | const findUrlAtPos = (view: EditorView, pos: number): { url: string; from: number; to: number } | null => { 60 | const { from: lineFrom, text } = view.state.doc.lineAt(pos); 61 | const offset = pos - lineFrom; 62 | 63 | for (const match of text.matchAll(MARKDOWN_LINK_REGEX)) { 64 | const index = match.index; 65 | if (index !== undefined && offset >= index && offset < index + match[0].length) { 66 | return { 67 | url: match[2], 68 | from: lineFrom + index, 69 | to: lineFrom + index + match[0].length, 70 | }; 71 | } 72 | } 73 | 74 | for (const match of text.matchAll(URL_REGEX)) { 75 | const index = match.index; 76 | if (index !== undefined && offset >= index && offset < index + match[0].length) { 77 | return { 78 | url: match[0], 79 | from: lineFrom + index, 80 | to: lineFrom + index + match[0].length, 81 | }; 82 | } 83 | } 84 | 85 | return null; 86 | }; 87 | 88 | // Create tooltip DOM node once 89 | const tooltipDom = document.createElement("div"); 90 | tooltipDom.textContent = `${getModifierKeyName()}+Click to open link`; 91 | 92 | export const urlHoverTooltip = hoverTooltip((view, pos) => { 93 | const urlInfo = findUrlAtPos(view, pos); 94 | if (!urlInfo) return null; 95 | 96 | return { 97 | pos: urlInfo.from, 98 | end: urlInfo.to, 99 | above: true, 100 | create: () => ({ dom: tooltipDom }), 101 | }; 102 | }); 103 | 104 | export const urlClickPlugin = ViewPlugin.fromClass( 105 | class { 106 | decorations: DecorationSet; 107 | 108 | constructor(view: EditorView) { 109 | this.decorations = createUrlDecorations(view); 110 | } 111 | 112 | update(update: ViewUpdate) { 113 | if (update.docChanged || update.viewportChanged) { 114 | this.decorations = createUrlDecorations(update.view); 115 | } 116 | } 117 | }, 118 | { 119 | decorations: (v) => v.decorations, 120 | eventHandlers: { 121 | mousedown: (event, view) => { 122 | if (!isLinkActivationModifier(event)) return false; 123 | 124 | const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); 125 | if (pos == null) return false; 126 | 127 | const urlInfo = findUrlAtPos(view, pos); 128 | if (!urlInfo) return false; 129 | 130 | window.open(urlInfo.url, "_blank", "noopener,noreferrer")?.focus(); 131 | event.preventDefault(); 132 | return true; 133 | }, 134 | }, 135 | }, 136 | ); 137 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Essential Commands 6 | 7 | ### Development 8 | ```bash 9 | pnpm dev # Start dev server on port 3000 10 | pnpm preview # Preview production build 11 | ``` 12 | 13 | ### Testing 14 | ```bash 15 | pnpm test # Run unit tests 16 | pnpm test:e2e # Run end-to-end tests with Playwright 17 | pnpm test:unit:coverage # Run tests with coverage report 18 | ``` 19 | 20 | ### Code Quality 21 | ```bash 22 | pnpm lint # Run both Biome and ESLint 23 | pnpm lint:biome:write # Auto-fix with Biome 24 | pnpm lint:eslint:fix # Auto-fix with ESLint 25 | pnpm format # Format code with Biome 26 | ``` 27 | 28 | ### Build 29 | ```bash 30 | pnpm build # Production build (TypeScript check + Vite build) 31 | ``` 32 | 33 | ## Architecture Overview 34 | 35 | ### Feature-Based Organization 36 | The codebase follows a feature-based structure under `src/features/`: 37 | - **editor/**: Core CodeMirror 6 integration with custom extensions for task lists, URL clicking, and markdown formatting 38 | - **snapshots/**: Persistent storage system for saving work snapshots 39 | - **history/**: UI for viewing and restoring past snapshots 40 | - **integration/**: External service integrations (GitHub API) 41 | 42 | ### Key Technical Decisions 43 | 44 | 1. **Editor Core**: CodeMirror 6 with custom extensions for: 45 | - Task list auto-completion (`- [ ]` syntax) 46 | - Keyboard shortcuts (Cmd+Enter to toggle tasks) 47 | - URL click handling 48 | - Theme synchronization 49 | 50 | 2. **State Management**: Jotai atoms for global state, with localStorage persistence for: 51 | - Editor content 52 | - Theme preferences 53 | - Paper mode settings 54 | - Editor width 55 | 56 | 3. **Markdown Formatting**: dprint WASM module loaded asynchronously for Cmd+S formatting 57 | 58 | 4. **Testing Strategy**: 59 | - `.test.ts` files run in Node environment 60 | - `.browser.test.ts` files run in Playwright for DOM-dependent tests 61 | - E2E tests verify critical user workflows 62 | 63 | ### TypeScript 64 | 65 | - Use type alias instead of interface 66 | 67 | ### Development Guidelines 68 | 69 | 1. **Performance Focus**: Minimize re-renders, we use React 19 and React Compiler 70 | 2. **Functional Design**: Prefer immutable data structures and pure functions (inspired by Rich Hickey's principles) 71 | 3. **Side Effects Isolation**: Keep side effects in custom hooks or effect handlers 72 | 4. **Keyboard-First**: All features should be accessible via keyboard shortcuts 73 | 74 | ### Philosophy & Design Principles 75 | 76 | This codebase embraces principles from Rich Hickey and John Ousterhout: 77 | 78 | #### Rich Hickey's Functional Programming Principles: 79 | - **Simplicity over ease**: Choose simple solutions that compose well 80 | - **Data orientation**: Treat data as immutable values, not objects with behavior 81 | - **Pure functions**: Maximize referential transparency and minimize side effects 82 | - **Explicit over implicit**: Make data flow and transformations visible 83 | - **Accretion**: Grow software by adding capabilities, not by modifying existing code 84 | 85 | #### John Ousterhout's Software Design Principles: 86 | - **Deep modules**: Create powerful interfaces that hide complexity (see CodeMirror extensions) 87 | - **Strategic programming**: Invest time upfront in good design to reduce future complexity 88 | - **Information hiding**: Minimize dependencies between modules and expose minimal interfaces 89 | - **Exception aggregation**: Handle errors at the highest appropriate level 90 | - **Define errors out of existence**: Design APIs that make errors impossible or unlikely 91 | - **Pull complexity downward**: Better to have complex implementation than complex interface 92 | - **Together or apart**: Related functionality should be together; unrelated should be separate 93 | - **Comments for why, not what**: Focus on design decisions and non-obvious behavior 94 | 95 | ### Important Patterns 96 | 97 | 1. **Custom Hooks**: Extensive use of hooks for reusable logic (see `src/utils/hooks/`) 98 | 2. **Storage Abstraction**: All localStorage access through `src/utils/storage/` 99 | 3. **Error Boundaries**: Wrap feature components to prevent cascade failures 100 | 4. **Theme System**: Coordinated theme updates across CodeMirror and Tailwind 101 | -------------------------------------------------------------------------------- /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 | mynerve: ["Mynerve", "cursive"], 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 | -------------------------------------------------------------------------------- /src/features/editor/multi/multi-editor.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { activeDocumentIndexAtom, documentsAtom } from "../../../utils/atoms/multi-document"; 3 | import { CodeMirrorEditor } from "../codemirror/codemirror-editor"; 4 | import { useRef, useEffect, useImperativeHandle, useCallback } from "react"; 5 | import { DocumentNavigation } from "./document-navigation"; 6 | import { MultiDocumentProvider } from "./multi-context"; 7 | import type { SingleEditorRef, MultiEditorRef } from "../editor-ref"; 8 | import { motion, AnimatePresence } from "motion/react"; 9 | 10 | type MultiDocumentEditorProps = { 11 | ref?: React.Ref; 12 | }; 13 | 14 | export const MultiDocumentEditor = ({ ref }: MultiDocumentEditorProps) => { 15 | const [activeIndex, setActiveIndex] = useAtom(activeDocumentIndexAtom); 16 | const [documents, setDocuments] = useAtom(documentsAtom); 17 | const editorRef = useRef(null); 18 | 19 | // Save document content immediately when user types 20 | const saveDocument = useCallback( 21 | (content: string) => { 22 | setDocuments((prev) => { 23 | const updated = [...prev]; 24 | updated[activeIndex] = { 25 | ...updated[activeIndex], 26 | content, 27 | lastModified: Date.now(), 28 | }; 29 | return updated; 30 | }); 31 | }, 32 | [activeIndex, setDocuments], 33 | ); 34 | 35 | const navigateToDocument = useCallback( 36 | (newIndex: number) => { 37 | if (newIndex < 0 || newIndex >= documents.length || newIndex === activeIndex) return; 38 | 39 | // Save current document content before switching 40 | const currentEditor = editorRef.current; 41 | if (currentEditor?.view) { 42 | const currentContent = currentEditor.view.state.doc.toString(); 43 | setDocuments((prev) => { 44 | const updated = [...prev]; 45 | updated[activeIndex] = { 46 | ...updated[activeIndex], 47 | content: currentContent, 48 | lastModified: Date.now(), 49 | }; 50 | return updated; 51 | }); 52 | } 53 | 54 | setActiveIndex(newIndex); 55 | }, 56 | [activeIndex, documents.length, setDocuments], 57 | ); 58 | 59 | useImperativeHandle( 60 | ref, 61 | () => ({ 62 | get view() { 63 | return editorRef.current?.view || null; 64 | }, 65 | getCurrentContent: () => { 66 | return editorRef.current?.getCurrentContent() ?? documents[activeIndex]?.content ?? ""; 67 | }, 68 | setContent: (content: string) => { 69 | // Update document in state 70 | setDocuments((prev) => { 71 | const updated = [...prev]; 72 | updated[activeIndex] = { 73 | ...updated[activeIndex], 74 | content, 75 | lastModified: Date.now(), 76 | }; 77 | return updated; 78 | }); 79 | 80 | // Update editor view 81 | editorRef.current?.setContent(content); 82 | }, 83 | navigateToDocument, 84 | }), 85 | [navigateToDocument, documents, activeIndex, setDocuments], 86 | ); 87 | 88 | // Reset scroll position immediately when document changes 89 | useEffect(() => { 90 | const view = editorRef.current?.view; 91 | if (view) { 92 | // Force immediate scroll to top 93 | view.scrollDOM.scrollTop = 0; 94 | view.scrollDOM.scrollLeft = 0; 95 | 96 | // Set cursor to beginning and focus 97 | view.dispatch({ 98 | selection: { anchor: 0, head: 0 }, 99 | }); 100 | view.focus(); 101 | } 102 | }, [activeIndex]); 103 | 104 | return ( 105 |
106 | 107 | 115 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |
128 | ); 129 | }; 130 | -------------------------------------------------------------------------------- /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/utils/storage/index.ts: -------------------------------------------------------------------------------- 1 | export type 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/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 | /* TOC styles */ 23 | .toc-container { 24 | position: sticky; 25 | top: 1rem; 26 | max-height: calc(100vh - 8rem); 27 | width: 100%; 28 | font-size: 0.875rem; 29 | transition: all 0.3s ease; 30 | background-color: transparent; 31 | border-radius: 0.375rem; 32 | padding: 0.5rem; 33 | } 34 | 35 | .dark .toc-container { 36 | background-color: transparent; 37 | border-color: transparent; 38 | } 39 | 40 | .toc-wrapper { 41 | position: fixed; 42 | right: 15rem; 43 | top: 10rem; 44 | width: 15rem; 45 | transition: all 0.3s ease-in-out; 46 | } 47 | 48 | .toc-wrapper.visible { 49 | opacity: 1; 50 | transform: translateX(0); 51 | } 52 | 53 | .toc-wrapper.hidden { 54 | opacity: 0; 55 | transform: translateX(100%); 56 | pointer-events: none; 57 | } 58 | 59 | @media (max-width: 1280px) { 60 | .toc-wrapper { 61 | position: fixed; 62 | right: 2rem; 63 | top: 4rem; 64 | max-width: 16rem; 65 | } 66 | } 67 | 68 | @media (max-width: 768px) { 69 | .toc-wrapper { 70 | right: 0; 71 | top: 0; 72 | height: 100%; 73 | width: 16rem; 74 | } 75 | 76 | .toc-container { 77 | height: 100%; 78 | border-radius: 0; 79 | margin-left: 0; 80 | padding-top: 2rem; 81 | } 82 | } 83 | 84 | /* Paper Mode: Graph Paper (Light Mode) */ 85 | .bg-graph-paper { 86 | background-image: 87 | linear-gradient(rgba(210, 210, 210, 0.3) 1px, transparent 1px), 88 | linear-gradient(to right, rgba(210, 210, 210, 0.3) 1px, transparent 1px); 89 | background-size: 12px 12px; 90 | background-color: #fff; 91 | background-position: -14px 14px; 92 | } 93 | 94 | /* Paper Mode: Graph Paper (Dark Mode) */ 95 | .dark .bg-graph-paper { 96 | background-image: 97 | linear-gradient(rgba(80, 80, 80, 0.3) 1px, transparent 1px), 98 | linear-gradient(to right, rgba(80, 80, 80, 0.3) 1px, transparent 1px); 99 | background-size: 12px 12px; 100 | background-color: #0d1117; 101 | background-position: -14px 14px; 102 | } 103 | 104 | /* Paper Mode: Normal (Light Mode) */ 105 | .bg-normal-paper { 106 | background-color: #fff; 107 | } 108 | 109 | /* Paper Mode: Normal (Dark Mode) */ 110 | .dark .bg-normal-paper { 111 | background-color: #0d1117; 112 | } 113 | 114 | /* Paper Mode: Dots (Light Mode) */ 115 | .bg-dots-paper { 116 | background-image: radial-gradient(rgba(210, 210, 210, 0.5) 1px, transparent 1px); 117 | background-size: 24px 24px; 118 | background-color: #fff; 119 | } 120 | 121 | /* Paper Mode: Dots (Dark Mode) */ 122 | .dark .bg-dots-paper { 123 | background-image: radial-gradient(rgba(80, 80, 80, 0.5) 1px, transparent 1px); 124 | background-size: 16px 16px; 125 | background-color: #0d1117; 126 | } 127 | 128 | .cm-task-hover { 129 | cursor: pointer; 130 | background-color: rgba(128, 128, 128, 0.1); 131 | } 132 | 133 | /* Dark mode hover style */ 134 | .dark .cm-task-hover { 135 | background-color: rgba(255, 255, 255, 0.1); 136 | } 137 | 138 | /* URL Tooltip styles for CodeMirror */ 139 | .cm-tooltip { 140 | background-color: white !important; 141 | border: none !important; 142 | outline: none !important; 143 | border-radius: var(--radius-md) !important; 144 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1) !important; 145 | padding: 6px 8px !important; 146 | font-size: 12px !important; 147 | animation: tooltip-show 0.2s cubic-bezier(0.16, 1, 0.3, 1) !important; 148 | line-height: 1 !important; 149 | align-items: center !important; 150 | transform-origin: center bottom !important; 151 | } 152 | 153 | .dark .cm-tooltip { 154 | background-color: oklch(0.205 0 0) !important; 155 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important; 156 | } 157 | 158 | @keyframes tooltip-show { 159 | from { 160 | opacity: 0; 161 | transform: translateY(4px); 162 | } 163 | to { 164 | opacity: 1; 165 | transform: translateY(0); 166 | } 167 | } 168 | 169 | @font-face { 170 | font-family: "iA Writer Mono"; 171 | font-style: normal; 172 | font-display: swap; 173 | font-weight: 400; 174 | src: 175 | url(https://cdn.jsdelivr.net/fontsource/fonts/ia-writer-mono@latest/latin-400-normal.woff2) format("woff2"), 176 | url(https://cdn.jsdelivr.net/fontsource/fonts/ia-writer-mono@latest/latin-400-normal.woff) format("woff"); 177 | } 178 | -------------------------------------------------------------------------------- /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 | const editor = page.getByTestId("code-mirror-editor"); 17 | await editor.focus(); 18 | 19 | // Use type method with increased delay for more reliable input 20 | await editor.type("# Hello World", { delay: 100 }); 21 | 22 | // check input 23 | const editorContent = await page.locator(".cm-content"); 24 | await expect(editorContent).toContainText("Hello World"); 25 | }); 26 | 27 | test("open and close command menu", async ({ page }) => { 28 | await page.goto("/"); 29 | 30 | // Initially, dialog should not be visible 31 | const dialog = page.locator('div[role="dialog"]'); 32 | await expect(dialog).not.toBeVisible(); 33 | 34 | // open command menu by Ctrl+K(or Cmd+K) 35 | const isMac = process.platform === "darwin"; 36 | const modifier = isMac ? "Meta" : "Control"; 37 | await page.keyboard.press(`${modifier}+k`); 38 | await expect(dialog).toBeVisible(); 39 | await expect(dialog).toHaveClass(/opacity-100/); 40 | 41 | // close 42 | await page.keyboard.press(`${modifier}+k`); 43 | await expect(dialog).not.toBeVisible(); 44 | }); 45 | 46 | test("close command menu with Escape key", async ({ page }) => { 47 | await page.goto("/"); 48 | 49 | // Initially, the dialog should not be visible 50 | const dialog = page.locator('div[role="dialog"]'); 51 | await expect(dialog).not.toBeVisible(); 52 | 53 | // open command menu 54 | const isMac = process.platform === "darwin"; 55 | const modifier = isMac ? "Meta" : "Control"; 56 | await page.keyboard.press(`${modifier}+k`); 57 | 58 | // Dialog should now be visible 59 | await expect(dialog).toBeVisible(); 60 | await expect(dialog).toHaveClass(/opacity-100/); 61 | 62 | await expect(page.locator('input[placeholder="Type a command or search..."]')).toBeFocused(); 63 | await page.keyboard.press("Escape"); 64 | 65 | // Dialog should not be visible again 66 | await expect(dialog).not.toBeVisible(); 67 | }); 68 | 69 | test("URL link styling and tooltip", async ({ page }) => { 70 | await page.goto("/"); 71 | 72 | // Type URL in editor 73 | await page.waitForSelector(".cm-editor"); 74 | const editor = page.getByTestId("code-mirror-editor"); 75 | await editor.focus(); 76 | await editor.type("Check this link: https://example.com", { delay: 100 }); 77 | 78 | // Wait for URL decoration to be applied 79 | await page.waitForTimeout(1000); 80 | 81 | // Check that URL has underline styling - find any element containing the URL 82 | const contentArea = page.locator(".cm-content"); 83 | await expect(contentArea).toContainText("https://example.com"); 84 | 85 | // Find the styled URL element 86 | const styledUrl = page.locator('.cm-content [style*="text-decoration"]').first(); 87 | await expect(styledUrl).toBeVisible(); 88 | 89 | // Test hover tooltip 90 | await styledUrl.hover(); 91 | await page.waitForTimeout(500); 92 | 93 | // Check if tooltip appears 94 | const tooltip = page.locator("text=Opt+Click to open link"); 95 | await expect(tooltip).toBeVisible(); 96 | }); 97 | 98 | test("Markdown link styling", async ({ page }) => { 99 | await page.goto("/"); 100 | 101 | // Type markdown link in editor 102 | await page.waitForSelector(".cm-editor"); 103 | const editor = page.getByTestId("code-mirror-editor"); 104 | await editor.focus(); 105 | await editor.type("[Example](https://example.com)", { delay: 100 }); 106 | 107 | // Wait for URL decoration to be applied 108 | await page.waitForTimeout(1000); 109 | 110 | // Check that content contains the markdown link 111 | const contentArea = page.locator(".cm-content"); 112 | await expect(contentArea).toContainText("[Example](https://example.com)"); 113 | 114 | // Find any element with URL styling 115 | const styledElement = page.locator('.cm-content [style*="text-decoration"]').first(); 116 | await expect(styledElement).toBeVisible(); 117 | 118 | // Test hover tooltip on styled element 119 | await styledElement.hover(); 120 | await page.waitForTimeout(500); 121 | 122 | // Check if tooltip appears 123 | const tooltip = page.locator("text=Opt+Click to open link"); 124 | await expect(tooltip).toBeVisible(); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /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, beforeEach } from "vitest"; 4 | import { urlClickPlugin, urlHoverTooltip } from "./url-click"; 5 | 6 | const createViewFromText = (text: string): EditorView => { 7 | const state = EditorState.create({ 8 | doc: text, 9 | extensions: [urlClickPlugin, urlHoverTooltip], 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 | describe("platform-specific behavior", () => { 108 | beforeEach(() => { 109 | vi.resetModules(); 110 | }); 111 | 112 | test("uses correct modifier key based on platform", async () => { 113 | // Test macOS behavior 114 | Object.defineProperty(navigator, "userAgent", { 115 | value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", 116 | configurable: true, 117 | }); 118 | const macPlatform = await import("../../../utils/platform"); 119 | expect(macPlatform.getModifierKeyName()).toBe("Cmd"); 120 | 121 | // Test Windows behavior 122 | Object.defineProperty(navigator, "userAgent", { 123 | value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", 124 | configurable: true, 125 | }); 126 | vi.resetModules(); 127 | const winPlatform = await import("../../../utils/platform"); 128 | expect(winPlatform.getModifierKeyName()).toBe("Ctrl"); 129 | 130 | // Test Linux behavior 131 | Object.defineProperty(navigator, "userAgent", { 132 | value: "Mozilla/5.0 (X11; Linux x86_64)", 133 | configurable: true, 134 | }); 135 | vi.resetModules(); 136 | const linuxPlatform = await import("../../../utils/platform"); 137 | expect(linuxPlatform.getModifierKeyName()).toBe("Ctrl"); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/features/editor/multi/dock-menu.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { activeDocumentIndexAtom, documentsAtom } from "../../../utils/atoms/multi-document"; 3 | import { useState } from "react"; 4 | import { motion } from "motion/react"; 5 | 6 | const SPRING_CONFIG = { 7 | stiffness: 200, 8 | damping: 30, 9 | }; 10 | 11 | type CardStyle = { 12 | x: number; 13 | y: number; 14 | rotate: number; 15 | scale: number; 16 | zIndex: number; 17 | }; 18 | 19 | type DocumentDockProps = { 20 | onNavigate: (index: number) => void; 21 | }; 22 | export const generatePreviewContent = (content: string): string => { 23 | const lines = content.split("\n").slice(0, 5).join("\n"); 24 | return lines.length > 100 ? `${lines.substring(0, 100)}...` : lines; 25 | }; 26 | 27 | export const calculateCardStyle = ( 28 | index: number, 29 | total: number, 30 | hoveredIndex: number | null, 31 | isDockHovered: boolean, 32 | ): CardStyle => { 33 | if (!isDockHovered) { 34 | return { x: 0, y: 0, rotate: 0, scale: 0, zIndex: 1 }; 35 | } 36 | 37 | const centerIndex = (total - 1) / 2; 38 | const offsetFromCenter = index - centerIndex; 39 | 40 | const x = offsetFromCenter * 80; 41 | const y = hoveredIndex === index ? -30 : 0; 42 | const rotation = (offsetFromCenter / Math.max(centerIndex, 1)) * 15; 43 | const scale = hoveredIndex === index ? 1.15 : 1.0; 44 | const zIndex = 100 - offsetFromCenter; 45 | 46 | return { x, y, rotate: rotation, scale, zIndex }; 47 | }; 48 | 49 | export const getCardButtonClasses = (isActive: boolean, isDockHovered: boolean): string => { 50 | const baseClasses = "w-full h-full relative overflow-hidden"; 51 | 52 | const shapeClasses = isDockHovered ? "rounded-lg border shadow-lg backdrop-blur-sm" : "rounded-md"; 53 | 54 | const stateClasses = isActive 55 | ? isDockHovered 56 | ? "border-2 border-blue-500 bg-white shadow-2xl dark:border-blue-400 dark:bg-neutral-800" 57 | : "bg-gray-200 dark:bg-gray-100" 58 | : isDockHovered 59 | ? "border-gray-300 bg-white/90 hover:border-gray-400 dark:border-gray-600 dark:bg-neutral-800/90" 60 | : "bg-gray-100 dark:bg-gray-600"; 61 | 62 | return `${baseClasses} ${shapeClasses} ${stateClasses}`; 63 | }; 64 | 65 | export const DocumentDock = ({ onNavigate }: DocumentDockProps) => { 66 | const [activeIndex] = useAtom(activeDocumentIndexAtom); 67 | const [documents] = useAtom(documentsAtom); 68 | const [isDockHovered, setIsDockHovered] = useState(false); 69 | const [hoveredIndex, setHoveredIndex] = useState(null); 70 | 71 | const documentPreviews = documents.map((doc) => generatePreviewContent(doc.content) || ""); 72 | 73 | const cardStyles = documents.map((_, index) => 74 | calculateCardStyle(index, documents.length, hoveredIndex, isDockHovered), 75 | ); 76 | 77 | return ( 78 |
setIsDockHovered(true)} 85 | onMouseLeave={() => { 86 | setIsDockHovered(false); 87 | setHoveredIndex(null); 88 | }} 89 | > 90 | 134 |
135 | ); 136 | }; 137 | -------------------------------------------------------------------------------- /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 | const formattedToday = now.toLocaleDateString(undefined, { 16 | weekday: "short", 17 | month: "short", 18 | day: "numeric", 19 | }); 20 | 21 | // Calculate hours remaining in the day 22 | const hoursRemaining = 24 - currentHour - 1; 23 | const minutesRemaining = 60 - now.getMinutes(); 24 | 25 | // Generate array of all hours in the day 26 | const hoursArray = Array.from({ length: 24 }, (_, i) => { 27 | return { 28 | hour: i, 29 | current: i === currentHour, 30 | past: i < currentHour, 31 | }; 32 | }); 33 | 34 | // Close tooltip when clicking outside 35 | useEffect(() => { 36 | const handleClickOutside = (event: MouseEvent) => { 37 | if (tooltipRef.current && !tooltipRef.current.contains(event.target as Node)) { 38 | setShowTooltip(false); 39 | } 40 | }; 41 | 42 | document.addEventListener("mousedown", handleClickOutside); 43 | return () => { 44 | document.removeEventListener("mousedown", handleClickOutside); 45 | }; 46 | }, []); 47 | 48 | // Format hour for display 49 | const formatHour = (hour: number): string => { 50 | return `${hour.toString().padStart(2, "0")}:00`; 51 | }; 52 | 53 | return ( 54 |
55 | 63 | 64 | {/* biome-ignore lint/a11y/noStaticElementInteractions: tooltip needs mouse events */} 65 |
setShowTooltip(false)} 81 | > 82 |
83 | {hoursRemaining > 0 ? `${hoursRemaining}h ${minutesRemaining}m left` : "End of day"} 84 |
85 |
97 | {hoursArray.map((hour) => ( 98 | // biome-ignore lint/a11y/noStaticElementInteractions: hour dots need hover events 99 |
setHoveredHour(formatHour(hour.hour))} 111 | onMouseLeave={() => setHoveredHour(null)} 112 | > 113 |
120 | {formatHour(hour.hour)} 121 |
122 |
123 | ))} 124 |
125 |
126 |
127 | ); 128 | }; 129 | -------------------------------------------------------------------------------- /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 { MultiDocumentEditor } from "../features/editor/multi/multi-editor"; 6 | import { CodeMirrorEditor } from "../features/editor/codemirror/codemirror-editor"; 7 | import type { MultiEditorRef, SingleEditorRef } from "../features/editor/editor-ref"; 8 | import { DocumentDock } from "../features/editor/multi/dock-menu"; 9 | import { SystemMenu } from "../features/menu/system-menu"; 10 | import { HoursDisplay } from "../features/time-display/hours-display"; 11 | import { Link } from "react-router-dom"; 12 | import { EPHE_VERSION } from "../utils/constants"; 13 | import { useCommandK } from "../utils/hooks/use-command-k"; 14 | import { useEditorMode } from "../utils/hooks/use-editor-mode"; 15 | import { useRef, useState, useEffect } from "react"; 16 | import { useAtom } from "jotai"; 17 | import { HistoryModal } from "../features/history/history-modal"; 18 | import { editorContentAtom } from "../utils/atoms/editor"; 19 | import { useMobileDetector } from "../utils/hooks/use-mobile-detector"; 20 | 21 | export const EditorPage = () => { 22 | const { paperModeClass } = usePaperMode(); 23 | const { editorMode } = useEditorMode(); 24 | const [historyModalOpen, setHistoryModalOpen] = useState(false); 25 | const [historyModalTabIndex, setHistoryModalTabIndex] = useState(0); 26 | // Track any modal being open 27 | const isAnyModalOpen = historyModalOpen; 28 | const { isCommandMenuOpen, closeCommandMenu } = useCommandK(isAnyModalOpen); 29 | const multiEditorRef = useRef(null); 30 | const singleEditorRef = useRef(null); 31 | const [editorContent] = useAtom(editorContentAtom); 32 | const { isMobile } = useMobileDetector(); 33 | 34 | // Unified snapshot restore event handler 35 | useEffect(() => { 36 | const handleContentRestored = (event: CustomEvent<{ content: string }>) => { 37 | const customEvent = event; 38 | const restoredContent = customEvent.detail.content; 39 | // Route to appropriate editor based on mode 40 | if (editorMode === "multi" && multiEditorRef.current) { 41 | multiEditorRef.current.setContent(restoredContent); 42 | } else if (editorMode === "single" && singleEditorRef.current) { 43 | singleEditorRef.current.setContent(restoredContent); 44 | } 45 | }; 46 | window.addEventListener("ephe:content-restored", handleContentRestored as EventListener); 47 | return () => { 48 | window.removeEventListener("ephe:content-restored", handleContentRestored as EventListener); 49 | }; 50 | }, [editorMode]); 51 | 52 | const handleCommandMenuClose = () => { 53 | closeCommandMenu(); 54 | // Return focus to editor after closing 55 | // Use requestAnimationFrame to ensure the menu is fully closed before focusing 56 | requestAnimationFrame(() => { 57 | if (editorMode === "multi" && multiEditorRef.current?.view) { 58 | multiEditorRef.current.view.focus(); 59 | } else if (editorMode === "single" && singleEditorRef.current?.view) { 60 | singleEditorRef.current.view.focus(); 61 | } 62 | }); 63 | }; 64 | 65 | const openHistoryModal = (tabIndex: number) => { 66 | setHistoryModalTabIndex(tabIndex); 67 | setHistoryModalOpen(true); 68 | }; 69 | 70 | return ( 71 |
72 |
73 |
74 | {editorMode === "multi" ? ( 75 | 76 | ) : ( 77 | 78 | )} 79 |
80 |
81 | 82 |
} 85 | centerContent={ 86 | isMobile || editorMode === "single" ? null : ( 87 | multiEditorRef.current?.navigateToDocument(index)} /> 88 | ) 89 | } 90 | rightContent={ 91 | <> 92 | 93 | 94 | Ephe v{EPHE_VERSION} 95 | 96 | 97 | } 98 | /> 99 | 113 | 114 | setHistoryModalOpen(false)} 117 | initialTabIndex={historyModalTabIndex} 118 | /> 119 |
120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /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 | import { useMemo } from "react"; 5 | 6 | /** 7 | * Manages the CodeMirror theme and highlight style based on dark mode, editor width, and font family. 8 | */ 9 | export const useEditorTheme = (isDarkMode: boolean, isWideMode: boolean, fontFamily: string) => { 10 | return useMemo(() => { 11 | const COLORS = isDarkMode ? EPHE_COLORS.dark : EPHE_COLORS.light; 12 | const CODE_SYNTAX_HIGHLIGHT = isDarkMode ? SYNTAX_HIGHLIGHT_STYLES.dark : SYNTAX_HIGHLIGHT_STYLES.light; 13 | 14 | const epheHighlightStyle = HighlightStyle.define([ 15 | ...CODE_SYNTAX_HIGHLIGHT, 16 | 17 | // Markdown Style 18 | { tag: tags.heading, color: COLORS.heading }, 19 | { 20 | tag: tags.heading1, 21 | color: COLORS.heading, 22 | fontSize: "1.2em", 23 | }, 24 | { 25 | tag: tags.heading2, 26 | color: COLORS.heading, 27 | fontSize: "1.2em", 28 | }, 29 | { 30 | tag: tags.heading3, 31 | color: COLORS.heading, 32 | fontSize: "1.1em", 33 | }, 34 | { tag: tags.emphasis, color: COLORS.emphasis, fontStyle: "italic" }, 35 | { tag: tags.strong, color: COLORS.emphasis }, 36 | { tag: tags.link, color: COLORS.string, textDecoration: "underline" }, 37 | { tag: tags.url, color: COLORS.string, textDecoration: "underline" }, 38 | { tag: tags.monospace, color: COLORS.constant, fontFamily: "monospace" }, 39 | ]); 40 | 41 | const theme = { 42 | "&": { 43 | height: "100%", 44 | width: "100%", 45 | background: COLORS.background, 46 | color: COLORS.foreground, 47 | }, 48 | ".cm-content": { 49 | fontFamily: fontFamily, 50 | fontSize: "16px", 51 | padding: "60px 20px", 52 | lineHeight: "1.6", 53 | maxWidth: isWideMode ? "100%" : "680px", 54 | margin: "0 auto", 55 | caretColor: COLORS.foreground, 56 | fontFeatureSettings: fontFamily.includes("Mynerve") ? '"calt" off, "salt" off' : "normal", 57 | fontVariantLigatures: fontFamily.includes("Mynerve") ? "none" : "normal", 58 | }, 59 | ".cm-cursor": { 60 | borderLeftColor: COLORS.foreground, 61 | borderLeftWidth: "2px", 62 | }, 63 | "&.cm-editor": { 64 | outline: "none", 65 | border: "none", 66 | background: "transparent", 67 | }, 68 | "&.cm-focused": { 69 | outline: "none", 70 | }, 71 | ".cm-scroller": { 72 | fontFamily: fontFamily, 73 | background: "transparent", 74 | fontFeatureSettings: fontFamily.includes("Mynerve") 75 | ? '"calt" off, "salt" off, "liga" off, "dlig" off, "clig" off' 76 | : "normal", 77 | fontVariantLigatures: fontFamily.includes("Mynerve") ? "none" : "normal", 78 | }, 79 | ".cm-gutters": { 80 | background: "transparent", 81 | border: "none", 82 | }, 83 | ".cm-activeLineGutter": { 84 | background: "transparent", 85 | }, 86 | ".cm-line": { 87 | padding: "0 4px 0 0", 88 | }, 89 | }; 90 | 91 | return { 92 | editorTheme: EditorView.theme(theme), 93 | editorHighlightStyle: syntaxHighlighting(epheHighlightStyle, { fallback: true }), 94 | }; 95 | }, [isDarkMode, isWideMode, fontFamily]); 96 | }; 97 | 98 | const EPHE_COLORS = { 99 | light: { 100 | background: "#FFFFFF", 101 | foreground: "#111111", 102 | comment: "#9E9E9E", 103 | keyword: "#111111", 104 | string: "#616161", 105 | number: "#555555", 106 | type: "#333333", 107 | function: "#555555", 108 | variable: "#666666", 109 | constant: "#555555", 110 | operator: "#757575", 111 | heading: "#000000", 112 | emphasis: "#000000", 113 | }, 114 | dark: { 115 | background: "#121212", 116 | foreground: "#F5F5F5", 117 | comment: "#757575", 118 | keyword: "#F5F5F5", 119 | string: "#AAAAAA", 120 | number: "#BDBDBD", 121 | type: "#E0E0E0", 122 | function: "#C0C0C0", 123 | variable: "#D0D0D0", 124 | constant: "#E0E0E0", 125 | operator: "#999999", 126 | heading: "#FFFFFF", 127 | emphasis: "#FFFFFF", 128 | }, 129 | } as const; 130 | 131 | const SYNTAX_HIGHLIGHT_STYLES = { 132 | light: [ 133 | { tag: tags.comment, color: "#6a737d", fontStyle: "italic" }, 134 | { tag: tags.keyword, color: "#d73a49" }, 135 | { tag: tags.string, color: "#032f62" }, 136 | { tag: tags.number, color: "#005cc5" }, 137 | { tag: tags.typeName, color: "#e36209", fontStyle: "italic" }, 138 | { tag: tags.function(tags.variableName), color: "#6f42c1" }, 139 | { tag: tags.definition(tags.variableName), color: "#22863a" }, 140 | { tag: tags.variableName, color: "#24292e" }, 141 | { tag: tags.constant(tags.variableName), color: "#b31d28" }, 142 | { tag: tags.operator, color: "#d73a49" }, 143 | ], 144 | dark: [ 145 | { tag: tags.comment, color: "#9ca3af", fontStyle: "italic" }, 146 | { tag: tags.keyword, color: "#f97583" }, 147 | { tag: tags.string, color: "#9ecbff" }, 148 | { tag: tags.number, color: "#79b8ff" }, 149 | { tag: tags.typeName, color: "#ffab70", fontStyle: "italic" }, 150 | { tag: tags.function(tags.variableName), color: "#b392f0" }, 151 | { tag: tags.definition(tags.variableName), color: "#85e89d" }, 152 | { tag: tags.variableName, color: "#e1e4e8" }, 153 | { tag: tags.constant(tags.variableName), color: "#f97583" }, 154 | { tag: tags.operator, color: "#f97583" }, 155 | ], 156 | } as const; 157 | -------------------------------------------------------------------------------- /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/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 | handleDeleteAllTasks: () => void; 26 | refresh: () => void; 27 | }; 28 | 29 | // Cache for date strings to avoid recreating them repeatedly 30 | const dateStringCache = new Map(); 31 | 32 | // Helper to get date string for grouping with caching 33 | const getDateString = (date: Date): string => { 34 | const time = date.getTime(); 35 | const cacheKey = `date_${time}`; 36 | 37 | if (dateStringCache.has(cacheKey)) { 38 | return dateStringCache.get(cacheKey)!; 39 | } 40 | 41 | const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD 42 | dateStringCache.set(cacheKey, dateStr); 43 | 44 | // Cleanup cache if it gets too large 45 | if (dateStringCache.size > 100) { 46 | // Get the oldest keys and remove them 47 | const keys = Array.from(dateStringCache.keys()).slice(0, 50); 48 | keys.forEach((key) => { 49 | dateStringCache.delete(key); 50 | }); 51 | } 52 | 53 | return dateStr; 54 | }; 55 | 56 | // Function to group items by date 57 | const groupItemsByDate = (items: T[]): DateGroupedItems => { 58 | const now = new Date(); 59 | const today = getDateString(now); 60 | 61 | const yesterday = new Date(now); 62 | yesterday.setDate(yesterday.getDate() - 1); 63 | const yesterdayStr = getDateString(yesterday); 64 | 65 | const result: DateGroupedItems = { 66 | today: [], 67 | yesterday: [], 68 | older: [], 69 | }; 70 | 71 | // Temporary storage for older dates 72 | const olderDates: Record = {}; 73 | 74 | items.forEach((item) => { 75 | // Get the date string from item (handle both snapshot and task) 76 | const dateStr = item.timestamp 77 | ? getDateString(new Date(item.timestamp)) 78 | : item.completedAt 79 | ? getDateString(new Date(item.completedAt)) 80 | : ""; 81 | 82 | if (dateStr === today) { 83 | result.today.push(item); 84 | } else if (dateStr === yesterdayStr) { 85 | result.yesterday.push(item); 86 | } else if (dateStr) { 87 | // Group by date for older items 88 | if (!olderDates[dateStr]) { 89 | olderDates[dateStr] = []; 90 | } 91 | olderDates[dateStr].push(item); 92 | } 93 | }); 94 | 95 | // Convert older dates to array and sort by date (newest first) 96 | result.older = Object.entries(olderDates) 97 | .map(([date, items]) => ({ date, items })) 98 | .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); 99 | 100 | return result; 101 | }; 102 | 103 | export const useHistoryData = (): HistoryData => { 104 | const [snapshots, setSnapshots] = useState([]); 105 | const [tasks, setTasks] = useState([]); 106 | const [isLoading, setIsLoading] = useState(true); 107 | 108 | const groupedSnapshots = groupItemsByDate(snapshots); 109 | const groupedTasks = groupItemsByDate(tasks); 110 | 111 | // Load data from storage with optimizations 112 | const loadData = () => { 113 | setIsLoading(true); 114 | let loadingComplete = false; 115 | 116 | // Create a timeout to ensure we don't show loading state for too long 117 | const loadingTimeout = setTimeout(() => { 118 | if (!loadingComplete) { 119 | setIsLoading(false); 120 | } 121 | }, 500); 122 | 123 | // Performance optimization: Use promise.all to load data in parallel 124 | Promise.all([ 125 | // Load snapshots with caching 126 | new Promise((resolve) => { 127 | try { 128 | const allSnapshots = snapshotStorage 129 | .getAll() 130 | .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); 131 | setSnapshots(allSnapshots); 132 | } catch (snapshotError) { 133 | console.error("Error loading snapshots:", snapshotError); 134 | setSnapshots([]); 135 | } 136 | resolve(); 137 | }), 138 | 139 | // Load tasks with caching 140 | new Promise((resolve) => { 141 | try { 142 | const allTasks = taskStorage 143 | .getAll() 144 | .sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()); 145 | setTasks(allTasks); 146 | } catch (taskError) { 147 | console.error("Error loading tasks:", taskError); 148 | setTasks([]); 149 | } 150 | resolve(); 151 | }), 152 | ]).then(() => { 153 | loadingComplete = true; 154 | clearTimeout(loadingTimeout); 155 | setIsLoading(false); 156 | }); 157 | }; 158 | 159 | // Initialize data 160 | useEffect(() => { 161 | loadData(); 162 | 163 | // Listen for storage changes 164 | const handleStorageChange = (e: StorageEvent) => { 165 | if (e.key?.includes("snapshot") || e.key?.includes("task")) { 166 | loadData(); 167 | } 168 | }; 169 | 170 | window.addEventListener("storage", handleStorageChange); 171 | return () => { 172 | window.removeEventListener("storage", handleStorageChange); 173 | }; 174 | }, []); 175 | 176 | // Handle restore snapshot 177 | const handleRestoreSnapshot = (snapshot: Snapshot) => { 178 | try { 179 | localStorage.setItem(LOCAL_STORAGE_KEYS.EDITOR_CONTENT, snapshot.content); 180 | // Dispatch a custom event instead of reloading 181 | window.dispatchEvent( 182 | new CustomEvent("ephe:content-restored", { 183 | detail: { content: snapshot.content }, 184 | }), 185 | ); 186 | showToast("Snapshot restored to editor", "success"); 187 | } catch (error) { 188 | console.error("Error restoring snapshot:", error); 189 | showToast("Failed to restore snapshot", "error"); 190 | } 191 | }; 192 | 193 | // Handle delete snapshot 194 | const handleDeleteSnapshot = (id: string) => { 195 | try { 196 | snapshotStorage.deleteById(id); 197 | const updatedSnapshots = snapshots.filter((snapshot) => snapshot.id !== id); 198 | setSnapshots(updatedSnapshots); 199 | showToast("Snapshot deleted", "success"); 200 | } catch (error) { 201 | console.error("Error deleting snapshot:", error); 202 | showToast("Failed to delete snapshot", "error"); 203 | } 204 | }; 205 | 206 | // Handle delete task 207 | const handleDeleteTask = (id: string) => { 208 | try { 209 | taskStorage.deleteById(id); 210 | const updatedTasks = tasks.filter((task) => task.id !== id); 211 | setTasks(updatedTasks); 212 | showToast("Task deleted", "success"); 213 | } catch (error) { 214 | console.error("Error deleting task:", error); 215 | showToast("Failed to delete task", "error"); 216 | } 217 | }; 218 | 219 | // Handle delete all snapshots 220 | const handleDeleteAllSnapshots = () => { 221 | try { 222 | snapshotStorage.deleteAll(); 223 | setSnapshots([]); 224 | showToast("All snapshots deleted", "success"); 225 | } catch (error) { 226 | console.error("Error deleting all snapshots:", error); 227 | showToast("Failed to delete all snapshots", "error"); 228 | } 229 | }; 230 | 231 | // Handle delete all tasks 232 | const handleDeleteAllTasks = () => { 233 | try { 234 | taskStorage.deleteAll(); 235 | setTasks([]); 236 | showToast("All tasks deleted", "success"); 237 | } catch (error) { 238 | console.error("Error deleting all tasks:", error); 239 | showToast("Failed to delete all tasks", "error"); 240 | } 241 | }; 242 | 243 | return { 244 | snapshots, 245 | tasks, 246 | groupedSnapshots, 247 | groupedTasks, 248 | isLoading, 249 | handleRestoreSnapshot, 250 | handleDeleteSnapshot, 251 | handleDeleteAllSnapshots, 252 | handleDeleteTask, 253 | handleDeleteAllTasks, 254 | refresh: loadData, 255 | }; 256 | }; 257 | -------------------------------------------------------------------------------- /src/features/editor/codemirror/tasklist/task-reorder.ts: -------------------------------------------------------------------------------- 1 | import type { EditorView } from "@codemirror/view"; 2 | import type { Text } from "@codemirror/state"; 3 | import { isTaskLine, isRegularListLine, parseTaskLine, parseRegularListLine } from "./task-list-utils"; 4 | 5 | const MARKDOWN_HEADING = /^#{1,6}\s+\S/; 6 | const EMPTY_LINE = /^\s*$/u; 7 | 8 | type LineRange = { 9 | startLine: number; 10 | endLine: number; 11 | }; 12 | 13 | type BlockRange = { 14 | start: number; 15 | end: number; 16 | content: string; 17 | }; 18 | 19 | type TaskBlock = readonly [number, number]; 20 | 21 | const isListLine = (text: string): boolean => isTaskLine(text) || isRegularListLine(text); 22 | 23 | const isHeadingLine = (text: string): boolean => MARKDOWN_HEADING.test(text); 24 | 25 | const isEmptyLine = (text: string): boolean => EMPTY_LINE.test(text); 26 | 27 | const getIndentLevel = (text: string): number => { 28 | const taskParsed = parseTaskLine(text); 29 | if (taskParsed) return taskParsed.indent.length; 30 | 31 | const listParsed = parseRegularListLine(text); 32 | if (listParsed) return listParsed.indent.length; 33 | 34 | return 0; 35 | }; 36 | 37 | const findSectionBoundaries = (doc: Text, lineNumber: number): LineRange => { 38 | const findSectionStart = (lineNum: number): number => { 39 | for (let i = lineNum - 1; i >= 1; i--) { 40 | if (isHeadingLine(doc.line(i).text)) { 41 | return i; // Include the heading line itself 42 | } 43 | } 44 | return 1; 45 | }; 46 | 47 | const findSectionEnd = (lineNum: number): number => { 48 | for (let i = lineNum + 1; i <= doc.lines; i++) { 49 | if (isHeadingLine(doc.line(i).text)) { 50 | return i - 1; 51 | } 52 | } 53 | return doc.lines; 54 | }; 55 | 56 | return { 57 | startLine: findSectionStart(lineNumber), 58 | endLine: findSectionEnd(lineNumber), 59 | }; 60 | }; 61 | 62 | const findTaskBlockWithChildren = (doc: Text, lineNumber: number): TaskBlock | undefined => { 63 | if (lineNumber < 1 || lineNumber > doc.lines) return undefined; 64 | 65 | const line = doc.line(lineNumber); 66 | if (!isListLine(line.text)) return undefined; 67 | 68 | const baseIndent = getIndentLevel(line.text); 69 | 70 | const findEndLine = (startLine: number): number => { 71 | let lastValidLine = startLine; 72 | 73 | for (let i = startLine + 1; i <= doc.lines; i++) { 74 | const nextLine = doc.line(i); 75 | 76 | if (isEmptyLine(nextLine.text)) break; 77 | if (!isListLine(nextLine.text)) break; 78 | 79 | const nextIndent = getIndentLevel(nextLine.text); 80 | if (nextIndent <= baseIndent) break; 81 | 82 | lastValidLine = i; 83 | } 84 | 85 | return lastValidLine; 86 | }; 87 | 88 | return [lineNumber, findEndLine(lineNumber)] as const; 89 | }; 90 | 91 | const findParentTask = (doc: Text, lineNumber: number): number | undefined => { 92 | if (lineNumber < 1 || lineNumber > doc.lines) return undefined; 93 | 94 | const line = doc.line(lineNumber); 95 | if (!isListLine(line.text)) return undefined; 96 | 97 | const currentIndent = getIndentLevel(line.text); 98 | if (currentIndent === 0) return undefined; 99 | 100 | for (let i = lineNumber - 1; i >= 1; i--) { 101 | const checkLine = doc.line(i); 102 | 103 | if (isEmptyLine(checkLine.text)) return undefined; 104 | 105 | if (isListLine(checkLine.text)) { 106 | const checkIndent = getIndentLevel(checkLine.text); 107 | if (checkIndent < currentIndent) return i; 108 | } 109 | } 110 | 111 | return undefined; 112 | }; 113 | 114 | const findTarget = ( 115 | doc: Text, 116 | startLine: number, 117 | currentIndent: number, 118 | parentLine: number | undefined, 119 | direction: "up" | "down", 120 | maxLine?: number, 121 | ): number | undefined => { 122 | const step = direction === "up" ? -1 : 1; 123 | const lowerBound = direction === "up" ? 1 : startLine + step; 124 | const upperBound = direction === "up" ? startLine - step : (maxLine ?? doc.lines); 125 | 126 | for (let i = startLine + step; direction === "up" ? i >= lowerBound : i <= upperBound; i += step) { 127 | const checkLine = doc.line(i); 128 | const text = checkLine.text; 129 | 130 | if (isEmptyLine(text) || isHeadingLine(text)) return undefined; 131 | if (!isListLine(text)) continue; 132 | 133 | const checkIndent = getIndentLevel(text); 134 | 135 | if (parentLine !== undefined) { 136 | if (checkIndent === currentIndent) return i; 137 | if (checkIndent < currentIndent) return undefined; 138 | } else { 139 | if (checkIndent <= currentIndent) return i; 140 | } 141 | } 142 | 143 | return undefined; 144 | }; 145 | 146 | const getBlockContent = (doc: Text, startLine: number, endLine: number): BlockRange | undefined => { 147 | if (startLine < 1 || endLine > doc.lines) return undefined; 148 | const start = doc.line(startLine).from; 149 | const end = doc.line(endLine).to; 150 | return { start, end, content: doc.sliceString(start, end) }; 151 | }; 152 | 153 | const calculateNewCursorPosition = ( 154 | cursorPos: number, 155 | currentLineStart: number, 156 | currentBlock: BlockRange, 157 | targetBlock: BlockRange, 158 | isMovingDown: boolean, 159 | ): number => { 160 | // Step 1: Calculate cursor offset within the current line 161 | // Example: If cursor is at pos 25 and line starts at pos 20, offset is 5 162 | const cursorOffsetInLine = cursorPos - currentLineStart; 163 | 164 | // Step 2: Calculate the line's offset within its block 165 | // Example: If line starts at pos 20 and block starts at pos 10, line offset is 10 166 | const currentLineOffset = currentLineStart - currentBlock.start; 167 | 168 | // Step 3: Calculate new position based on movement direction 169 | if (isMovingDown) { 170 | // When moving down, the current block will be placed after the target block 171 | // We need to account for the size difference between blocks 172 | // Example: If target is longer, cursor needs to shift further 173 | const sizeDiff = targetBlock.content.length - currentBlock.content.length; 174 | return targetBlock.start + currentLineOffset + cursorOffsetInLine + sizeDiff; 175 | } else { 176 | // When moving up, the current block will be placed where the target block is 177 | // The cursor maintains its relative position within the moved block 178 | return targetBlock.start + currentLineOffset + cursorOffsetInLine; 179 | } 180 | }; 181 | 182 | const swapBlocks = ( 183 | view: EditorView, 184 | currentBlock: BlockRange, 185 | targetBlock: BlockRange, 186 | cursorPos: number, 187 | currentLineStart: number, 188 | userEvent: string, 189 | ): boolean => { 190 | const isMovingDown = currentBlock.start < targetBlock.start; 191 | 192 | // Calculate where the cursor should be after the swap 193 | const newCursorPos = calculateNewCursorPosition(cursorPos, currentLineStart, currentBlock, targetBlock, isMovingDown); 194 | 195 | // Always swap in document order to avoid position conflicts 196 | // This ensures the first change doesn't affect the position of the second 197 | const [first, second] = isMovingDown ? [currentBlock, targetBlock] : [targetBlock, currentBlock]; 198 | 199 | view.dispatch({ 200 | changes: [ 201 | { from: first.start, to: first.end, insert: second.content }, 202 | { from: second.start, to: second.end, insert: first.content }, 203 | ], 204 | selection: { anchor: newCursorPos }, 205 | userEvent, 206 | }); 207 | 208 | return true; 209 | }; 210 | 211 | const moveTask = (view: EditorView, direction: "up" | "down"): boolean => { 212 | const { state } = view; 213 | const { selection } = state; 214 | 215 | if (selection.ranges.length > 1) return false; 216 | 217 | const pos = selection.main.head; 218 | const line = state.doc.lineAt(pos); 219 | 220 | // Early validation 221 | if (!isListLine(line.text)) return false; 222 | 223 | const blockRange = findTaskBlockWithChildren(state.doc, line.number); 224 | if (!blockRange) return false; 225 | 226 | const [blockStartLine, blockEndLine] = blockRange; 227 | const doc = state.doc; 228 | 229 | const currentBlock = getBlockContent(doc, blockStartLine, blockEndLine); 230 | if (!currentBlock) return false; 231 | 232 | const currentIndent = getIndentLevel(doc.line(blockStartLine).text); 233 | const parentLine = findParentTask(doc, line.number); 234 | 235 | // Find target based on direction 236 | const searchFromLine = direction === "up" ? line.number : blockEndLine; 237 | const maxLine = parentLine !== undefined ? (findTaskBlockWithChildren(doc, parentLine)?.[1] ?? doc.lines) : doc.lines; 238 | 239 | const targetLine = findTarget(doc, searchFromLine, currentIndent, parentLine, direction, maxLine); 240 | if (!targetLine) return false; 241 | 242 | // Validate section boundaries 243 | const currentSection = findSectionBoundaries(doc, line.number); 244 | if (targetLine < currentSection.startLine || targetLine > currentSection.endLine) return false; 245 | 246 | const targetBlockRange = findTaskBlockWithChildren(doc, targetLine); 247 | if (!targetBlockRange) return false; 248 | 249 | const targetBlock = getBlockContent(doc, targetBlockRange[0], targetBlockRange[1]); 250 | if (!targetBlock) return false; 251 | 252 | // Pass the current cursor position and line start for proper cursor positioning 253 | const currentLineStart = line.from; 254 | return swapBlocks(view, currentBlock, targetBlock, pos, currentLineStart, `move.task.${direction}`); 255 | }; 256 | 257 | export const moveTaskUp = (view: EditorView): boolean => { 258 | const { state } = view; 259 | const { selection } = state; 260 | 261 | // Multiple selections - return false 262 | if (selection.ranges.length > 1) { 263 | return false; 264 | } 265 | 266 | // Check if we're on a task/list line 267 | const line = state.doc.lineAt(selection.main.head); 268 | if (isListLine(line.text)) { 269 | // Try to move the task 270 | moveTask(view, "up"); 271 | // Always return true for task lines to prevent default behavior 272 | return true; 273 | } 274 | 275 | // Not on a task line, use default behavior 276 | return false; 277 | }; 278 | 279 | export const moveTaskDown = (view: EditorView): boolean => { 280 | const { state } = view; 281 | const { selection } = state; 282 | 283 | // Multiple selections - return false 284 | if (selection.ranges.length > 1) { 285 | return false; 286 | } 287 | 288 | // Check if we're on a task/list line 289 | const line = state.doc.lineAt(selection.main.head); 290 | if (isListLine(line.text)) { 291 | // Try to move the task 292 | moveTask(view, "down"); 293 | // Always return true for task lines to prevent default behavior 294 | return true; 295 | } 296 | 297 | // Not on a task line, use default behavior 298 | return false; 299 | }; 300 | -------------------------------------------------------------------------------- /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, useEffect, useLayoutEffect, useState } 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 { useFontFamily } from "../../../utils/hooks/use-font"; 12 | import { DprintMarkdownFormatter } from "../markdown/formatter/dprint-markdown-formatter"; 13 | import { getRandomQuote } from "../quotes"; 14 | import { taskStorage } from "../tasks/task-storage"; 15 | import { createDefaultTaskHandler, createChecklistPlugin } from "./tasklist"; 16 | import { registerTaskHandler } from "./tasklist/task-close"; 17 | import { snapshotStorage } from "../../snapshots/snapshot-storage"; 18 | import { useEditorTheme } from "./use-editor-theme"; 19 | import { useCharCount } from "../../../utils/hooks/use-char-count"; 20 | import { useTaskAutoFlush } from "../../../utils/hooks/use-task-auto-flush"; 21 | import { useMobileDetector } from "../../../utils/hooks/use-mobile-detector"; 22 | import { urlClickPlugin, urlHoverTooltip } from "./url-click"; 23 | import { useDebouncedCallback } from "use-debounce"; 24 | import { editorContentAtom } from "../../../utils/atoms/editor"; 25 | 26 | const useMarkdownFormatter = () => { 27 | const ref = useRef(null); 28 | useEffect(() => { 29 | let alive = true; 30 | (async () => { 31 | const fmt = await DprintMarkdownFormatter.getInstance(); 32 | if (alive) { 33 | ref.current = fmt; 34 | } 35 | })(); 36 | return () => { 37 | alive = false; 38 | ref.current = null; 39 | }; 40 | }, []); 41 | return ref; 42 | }; 43 | 44 | const useTaskHandler = () => { 45 | const { taskAutoFlushMode } = useTaskAutoFlush(); 46 | const handlerRef = useRef(createDefaultTaskHandler(taskStorage, taskAutoFlushMode)); 47 | useEffect(() => { 48 | handlerRef.current = createDefaultTaskHandler(taskStorage, taskAutoFlushMode); 49 | registerTaskHandler(handlerRef.current); 50 | return () => registerTaskHandler(undefined); 51 | }, [taskAutoFlushMode]); 52 | return handlerRef; 53 | }; 54 | 55 | export const useMarkdownEditor = ( 56 | initialContent?: string, 57 | documentId?: string, 58 | onChange?: (content: string) => void, 59 | ) => { 60 | const editorRef = useRef(null); 61 | const viewRef = useRef(null); 62 | // Use initial content if provided, otherwise fall back to editorContentAtom for backwards compatibility 63 | const [globalContent, setGlobalContent] = useAtom(editorContentAtom); 64 | const [localContent, setLocalContent] = useState(initialContent ?? globalContent); 65 | const content = documentId ? localContent : globalContent; 66 | const setContent = documentId ? setLocalContent : setGlobalContent; 67 | 68 | // Update local content when initialContent changes (when switching documents) 69 | useEffect(() => { 70 | if (documentId && initialContent !== undefined) { 71 | setLocalContent(initialContent); 72 | } 73 | }, [initialContent, documentId]); 74 | 75 | const formatterRef = useMarkdownFormatter(); 76 | const taskHandlerRef = useTaskHandler(); 77 | 78 | const { isDarkMode } = useTheme(); 79 | const { isWideMode } = useEditorWidth(); 80 | const { currentFontValue } = useFontFamily(); 81 | const { editorTheme, editorHighlightStyle } = useEditorTheme(isDarkMode, isWideMode, currentFontValue); 82 | const { setCharCount } = useCharCount(); 83 | const { isMobile } = useMobileDetector(); 84 | 85 | const themeCompartment = useRef(new Compartment()).current; 86 | const highlightCompartment = useRef(new Compartment()).current; 87 | 88 | const debouncedSetContent = useDebouncedCallback((view: EditorView) => { 89 | const newContent = view.state.doc.toString(); 90 | // Only update if content actually changed to prevent unnecessary updates 91 | setContent((prevContent) => (prevContent !== newContent ? newContent : prevContent)); 92 | // Call onChange callback if provided (for multi-editor) 93 | if (onChange) { 94 | onChange(newContent); 95 | } 96 | }, 300); 97 | 98 | const onFormat = async () => { 99 | const view = viewRef.current; 100 | if (!view) return false; 101 | if (!formatterRef.current) { 102 | showToast("Formatter not initialized yet", "error"); 103 | return false; 104 | } 105 | try { 106 | // format 107 | const { state } = view; 108 | const scrollTop = view.scrollDOM.scrollTop; 109 | const cursorPos = state.selection.main.head; 110 | const cursorLine = state.doc.lineAt(cursorPos); 111 | const cursorLineNumber = cursorLine.number; 112 | const cursorColumn = cursorPos - cursorLine.from; 113 | const currentText = state.doc.toString(); 114 | const formattedText = await formatterRef.current.formatMarkdown(currentText); 115 | if (formattedText !== currentText) { 116 | view.dispatch({ 117 | changes: { from: 0, to: state.doc.length, insert: formattedText }, 118 | }); 119 | // Persist formatted content to state/storage and notify listeners 120 | setContent((prevContent) => (prevContent !== formattedText ? formattedText : prevContent)); 121 | if (onChange) { 122 | onChange(formattedText); 123 | } 124 | setCharCount(formattedText.length); 125 | // Restore cursor position after formatting 126 | try { 127 | const newState = view.state; 128 | const newDocLineCount = newState.doc.lines; 129 | if (cursorLineNumber <= newDocLineCount) { 130 | const newLine = newState.doc.line(cursorLineNumber); 131 | const newColumn = Math.min(cursorColumn, newLine.length); 132 | const newPos = newLine.from + newColumn; 133 | view.dispatch({ selection: { anchor: newPos, head: newPos } }); 134 | } 135 | } catch (_selectionError) { 136 | view.dispatch({ selection: { anchor: 0, head: 0 } }); 137 | } 138 | view.scrollDOM.scrollTop = Math.min(scrollTop, view.scrollDOM.scrollHeight - view.scrollDOM.clientHeight); 139 | } 140 | 141 | showToast("Document formatted", "default", { duration: 1800 }); 142 | return true; 143 | } catch (error) { 144 | const message = error instanceof Error ? error.message : "unknown"; 145 | showToast(`Error formatting document: ${message}`, "error"); 146 | return false; 147 | } 148 | }; 149 | 150 | const onSaveSnapshot = async () => { 151 | const view = viewRef.current; 152 | if (!view) return false; 153 | try { 154 | const currentText = view.state.doc.toString(); 155 | if (!currentText) { 156 | showToast("No content to save", "error"); 157 | return false; 158 | } 159 | 160 | // snapshot 161 | snapshotStorage.save({ 162 | content: currentText, 163 | charCount: currentText.length, 164 | }); 165 | 166 | showToast("Snapshot saved", "default"); 167 | return true; 168 | } catch (error) { 169 | const message = error instanceof Error ? error.message : "unknown"; 170 | showToast(`Error saving snapshot: ${message}`, "error"); 171 | return false; 172 | } 173 | }; 174 | 175 | useLayoutEffect(() => { 176 | if (!editorRef.current || viewRef.current) return; 177 | 178 | const state = EditorState.create({ 179 | doc: content, 180 | extensions: [ 181 | keymap.of(defaultKeymap), 182 | history({ minDepth: 50, newGroupDelay: 250 }), 183 | keymap.of(historyKeymap), 184 | 185 | // Task key bindings with high priority BEFORE markdown extension 186 | Prec.high(createChecklistPlugin(taskHandlerRef.current)), 187 | 188 | markdown({ 189 | base: markdownLanguage, 190 | codeLanguages: languages, 191 | addKeymap: true, 192 | }), 193 | 194 | EditorView.lineWrapping, 195 | EditorView.updateListener.of((update) => { 196 | if (update.docChanged) { 197 | // Skip updates from programmatic changes (formatting, restore, etc.) 198 | // Only update for user input 199 | const isUserInput = update.transactions.some((tr) => tr.isUserEvent("input") || tr.isUserEvent("delete")); 200 | if (isUserInput) { 201 | debouncedSetContent(update.view); 202 | const updatedContent = update.state.doc.toString(); 203 | setCharCount(updatedContent.length); 204 | } 205 | } 206 | }), 207 | 208 | themeCompartment.of(editorTheme), 209 | highlightCompartment.of(editorHighlightStyle), 210 | // Only show placeholder on non-mobile devices 211 | ...(isMobile ? [] : [placeholder(getRandomQuote())]), 212 | 213 | keymap.of([ 214 | { 215 | key: "Mod-s", 216 | run: () => { 217 | void onFormat(); 218 | return true; 219 | }, 220 | preventDefault: true, 221 | }, 222 | { 223 | key: "Mod-Shift-s", 224 | run: () => { 225 | void onSaveSnapshot(); 226 | return true; 227 | }, 228 | preventDefault: true, 229 | }, 230 | ]), 231 | urlClickPlugin, 232 | urlHoverTooltip, 233 | ], 234 | }); 235 | viewRef.current = new EditorView({ state, parent: editorRef.current }); 236 | viewRef.current.focus(); 237 | 238 | return () => { 239 | viewRef.current?.destroy(); 240 | viewRef.current = null; 241 | }; 242 | }, [documentId]); // Re-initialize when documentId changes 243 | 244 | // Update theme when dark mode changes 245 | useEffect(() => { 246 | const view = viewRef.current; 247 | if (!view) return; 248 | view.dispatch({ 249 | effects: [themeCompartment.reconfigure(editorTheme), highlightCompartment.reconfigure(editorHighlightStyle)], 250 | }); 251 | }, [highlightCompartment.reconfigure, themeCompartment.reconfigure, editorTheme, editorHighlightStyle]); 252 | 253 | // Listen for external content updates 254 | // - text edit emits storage event 255 | // - subscribe updates the editor content 256 | useEffect(() => { 257 | const view = viewRef.current; 258 | if (!view) return; 259 | 260 | // Get the current document content 261 | const currentDocContent = view.state.doc.toString(); 262 | 263 | // Skip if content is exactly the same as current editor content 264 | // This prevents cyclic updates when our own changes come back through the state 265 | if (content === currentDocContent) { 266 | setCharCount(content.length); 267 | return; 268 | } 269 | 270 | // Update the editor with the new content while preserving cursor position 271 | const currentSelection = view.state.selection.main; 272 | const currentAnchor = currentSelection.anchor; 273 | const currentHead = currentSelection.head; 274 | 275 | view.dispatch({ 276 | changes: { from: 0, to: view.state.doc.length, insert: content }, 277 | selection: { 278 | anchor: Math.min(currentAnchor, content.length), 279 | head: Math.min(currentHead, content.length), 280 | }, 281 | }); 282 | 283 | setCharCount(content.length); 284 | }, [content, setCharCount]); 285 | 286 | return { 287 | editor: editorRef, 288 | view: viewRef, 289 | onFormat: viewRef.current ? () => onFormat() : undefined, 290 | onSaveSnapshot: viewRef.current ? () => onSaveSnapshot() : undefined, 291 | }; 292 | }; 293 | --------------------------------------------------------------------------------