Add polished UX: tabs, kinetic scroll, animations, search, typography
- Tabbed interface with duplicate detection, overflow scroll arrows, mouse wheel support, and animated enter/exit transitions - Kinetic scrolling on right-mouse drag with iOS-style rubber band overscroll and damped spring snapback (content area and sidebar) - Framer Motion animations on sidebar, context menu, drop zone, focus mode, tabs, and all modals - Full-text search with real-time DOM highlighting and match navigation - Custom OverlayScrollbars replacing native scrollbars - Tauri native drag-and-drop replacing broken browser drag events - UI scale control (View menu spinner) persisted to localStorage - Content zoom (Ctrl+Scroll) and width adjustment (Shift+Scroll) - Right-click context menu with copy, zoom-adjusted positioning, and boundary clamping - Text selection preserved on right-click via visual overlay - Focus mode hides title/menu bars with animated transitions - Premium editorial typography: progressive heading color cascade, gradient-fade horizontal rules, accent-colored list markers, subtle persistent link underlines, refined blockquotes and code blocks - Sidebar table of contents with "No headings" empty state - Markdown breaks and typographer enabled - New app icons and updated README with full feature documentation
7
.gitignore
vendored
@@ -12,6 +12,13 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Tauri build artifacts
|
||||
src-tauri/target/
|
||||
|
||||
# Dev tool files
|
||||
icon-*.html
|
||||
nul
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
234
README.md
@@ -7,78 +7,119 @@
|
||||
<img src="https://img.shields.io/badge/license-CC0-green" alt="License">
|
||||
</p>
|
||||
|
||||
A beautiful, distraction-free markdown reader for Windows. Vesper renders your markdown files with elegant typography and a refined dark aesthetic inspired by iA Writer, giving you the ultimate reading experience.
|
||||
A beautiful, distraction-free markdown reader for Windows. Vesper renders your markdown files with premium editorial typography and a refined dark aesthetic, giving you the ultimate reading experience.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### File Management
|
||||
- **Open Files** - Use the File menu or drag-and-drop markdown files directly onto the window
|
||||
- **Tabbed Interface** - Open multiple files in tabs and switch between them seamlessly
|
||||
- **Recent Files** - Files open in new tabs automatically
|
||||
- **Supported Formats** - `.md`, `.markdown`, and `.txt` files
|
||||
### Markdown Rendering
|
||||
|
||||
### Reading Experience
|
||||
- **Premium Typography** - Carefully tuned typography with Inter font family for optimal readability
|
||||
- **Syntax Highlighting** - Code blocks are beautifully highlighted using highlight.js with a matching dark theme
|
||||
- **Markdown Extensions** - Full support for:
|
||||
- Full CommonMark support via **markdown-it** with extensions:
|
||||
- Syntax highlighting for 190+ languages (highlight.js)
|
||||
- Task lists (checkboxes)
|
||||
- Superscript and subscript
|
||||
- Strikethrough and highlighting
|
||||
- Tables with elegant styling
|
||||
- Blockquotes with visual depth
|
||||
- Superscript and subscript (`^text^`, `~text~`)
|
||||
- ==Highlighted text== via `mark`
|
||||
- Smart quotes, em-dashes, and ellipses (typographer)
|
||||
- Line break preservation
|
||||
- Auto-linking of URLs
|
||||
- HTML passthrough
|
||||
- Premium dark-mode typography tuned for long-form reading:
|
||||
- Inter Variable at 17px with 1.7 line-height
|
||||
- ~65 character measure (680px max-width) for optimal readability
|
||||
- Progressive heading color cascade (H1 near-white through H6 muted)
|
||||
- Accent-colored list markers
|
||||
- Gradient-fade horizontal rules
|
||||
- Subtle persistent link underlines that intensify on hover
|
||||
- Refined blockquote, table, and code block styling
|
||||
- JetBrains Mono Variable for code at 14px with ligature support
|
||||
|
||||
### Tabbed Interface
|
||||
|
||||
- Open multiple documents in tabs
|
||||
- Duplicate detection: re-opening an already-open file switches to its existing tab instead of creating a duplicate
|
||||
- Animated tab enter/exit with layout transitions
|
||||
- Scroll arrows appear when tabs overflow the bar, with hold-to-scroll
|
||||
- Mouse wheel scrolling over the tab bar
|
||||
- New tabs automatically scroll into view
|
||||
- Close individual tabs with the X button or `Ctrl+W`
|
||||
|
||||
### Navigation
|
||||
- **Table of Contents Sidebar** - Automatically generated from markdown headings (H1-H6)
|
||||
- **Click-to-Navigate** - Click any heading in the sidebar to jump to that section
|
||||
- **Resizable Sidebar** - Drag the sidebar edge to resize it to your preference
|
||||
|
||||
### Search
|
||||
- **In-Document Search** - Press `Ctrl+F` to search within the current document
|
||||
- **Match Navigation** - Navigate between matches with previous/next buttons
|
||||
- **Match Counter** - See how many matches were found
|
||||
- **Table of Contents sidebar** auto-generated from document headings (H1-H6), with indentation by level
|
||||
- Click any heading in the sidebar to smooth-scroll to that section
|
||||
- Sidebar opens even when a document has no headings (shows "No headings" message)
|
||||
- Resizable sidebar via drag handle
|
||||
- **Full-text search** (`Ctrl+F`) with real-time DOM highlighting, match counter, and Enter to cycle through matches
|
||||
|
||||
### Window Controls
|
||||
- **Custom Frameless Window** - Sleek title bar with custom controls
|
||||
- **Window Controls** - Minimize, maximize/restore, and close buttons
|
||||
- **Draggable Title Area** - Drag the window by clicking the title bar
|
||||
- **Focus Mode** - Press `F11` to hide all UI chrome and focus purely on content
|
||||
### Reading Experience
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- **Focus Mode** (`F11`) hides the title bar and menu bar for immersive reading
|
||||
- **Content zoom** via `Ctrl+Scroll` (50%--200%)
|
||||
- **Content width** adjustment via `Shift+Scroll` (400px--1200px)
|
||||
- **UI scale** (50%--200%) with a spinner in the View menu, persisted across sessions via localStorage
|
||||
- **Kinetic scrolling** on right-mouse-button drag with iOS-style rubber band overscroll and damped spring snapback
|
||||
- **Custom scrollbars** via OverlayScrollbars -- thin, auto-hiding after 800ms, accent-colored on hover
|
||||
- Window state (size, position, maximized) remembered between sessions via `tauri-plugin-window-state`
|
||||
|
||||
### File Handling
|
||||
|
||||
- Open files via the File menu, `Ctrl+O` dialog, or drag-and-drop from Explorer
|
||||
- Supports `.md`, `.markdown`, and `.txt` files
|
||||
- Native Tauri drag-and-drop integration (receives real file system paths)
|
||||
|
||||
### UI Details
|
||||
|
||||
- Custom frameless title bar with minimize, maximize/restore, and close buttons
|
||||
- Draggable title area for window repositioning
|
||||
- Menu bar with File, View, and Help menus
|
||||
- Right-click context menu with: Copy (when text is selected), Open File, Toggle Search, Toggle Sidebar, Focus Mode, Keyboard Shortcuts, About, Exit
|
||||
- Text selection preserved visually on right-click via overlay rendering
|
||||
- Context menu stays within window bounds and respects UI zoom
|
||||
- All panels, modals, tabs, and menus animate with Framer Motion (slide, fade, scale)
|
||||
- Subtle noise texture overlay on the app background
|
||||
- Keyboard shortcuts modal listing all bindings
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
|---|---|
|
||||
| `Ctrl+O` | Open file |
|
||||
| `Ctrl+W` | Close current tab |
|
||||
| `Ctrl+Q` | Exit application |
|
||||
| `Ctrl+F` | Toggle search |
|
||||
| `Ctrl+Shift+S` | Toggle sidebar |
|
||||
| `F11` | Toggle focus mode |
|
||||
| `Escape` | Close search or sidebar |
|
||||
|
||||
### Context Menu
|
||||
Right-click anywhere in the content area for quick access to:
|
||||
- Open File
|
||||
- Find (search)
|
||||
- Toggle Sidebar
|
||||
- Focus Mode
|
||||
- Keyboard Shortcuts
|
||||
- About
|
||||
| `Escape` | Close search / sidebar |
|
||||
| `Ctrl+Scroll` | Zoom content in / out |
|
||||
| `Shift+Scroll` | Adjust content width |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Windows 10 or later
|
||||
- WebView2 Runtime (usually pre-installed on Windows 10/11)
|
||||
- WebView2 Runtime (pre-installed on Windows 10/11)
|
||||
|
||||
### Download
|
||||
Download the latest release from the [Releases](https://github.com/yourusername/vesper/releases) page.
|
||||
|
||||
Download the latest installer from the [Releases](https://github.com/yourusername/vesper/releases) page:
|
||||
|
||||
- **NSIS installer** (`.exe`) -- recommended, includes uninstaller
|
||||
- **MSI installer** (`.msi`) -- alternative for enterprise/group policy deployment
|
||||
- **Portable exe** -- standalone `vesper.exe` with no installation required
|
||||
|
||||
### Build from Source
|
||||
|
||||
**Prerequisites:**
|
||||
- [Node.js](https://nodejs.org/) v18+
|
||||
- [Rust](https://www.rust-lang.org/tools/install) (stable toolchain)
|
||||
- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/vesper.git
|
||||
@@ -87,98 +128,95 @@ cd vesper
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run in development mode
|
||||
# Run in development mode (hot-reload on localhost:1420)
|
||||
npm run tauri dev
|
||||
|
||||
# Build for production
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Build outputs:
|
||||
- `src-tauri/target/release/vesper.exe` -- portable executable
|
||||
- `src-tauri/target/release/bundle/msi/Vesper_1.0.0_x64_en-US.msi` -- MSI installer
|
||||
- `src-tauri/target/release/bundle/nsis/Vesper_1.0.0_x64-setup.exe` -- NSIS installer
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework**: [Tauri 2.0](https://tauri.app/) - Lightweight, secure desktop framework
|
||||
- **Frontend**: React 19 + TypeScript + Vite
|
||||
- **Styling**: Tailwind CSS v4 + daisyUI v5
|
||||
- **Markdown**: [markdown-it](https://github.com/markdown-it/markdown-it) with plugins
|
||||
- **Syntax Highlighting**: [highlight.js](https://highlightjs.org/)
|
||||
- **Animations**: [Framer Motion](https://www.framer.com/motion/)
|
||||
- **Icons**: [Lucide React](https://lucide.dev/)
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Runtime | [Tauri v2](https://tauri.app/) (Rust + WebView2) |
|
||||
| Frontend | React 19, TypeScript, Vite 7 |
|
||||
| Styling | Tailwind CSS 4, DaisyUI 5, custom CSS |
|
||||
| Markdown | [markdown-it](https://github.com/markdown-it/markdown-it) with plugins (task-lists, sup, sub, mark) |
|
||||
| Syntax Highlighting | [highlight.js](https://highlightjs.org/) |
|
||||
| Animation | [Framer Motion](https://www.framer.com/motion/) |
|
||||
| Fonts | [Inter Variable](https://rsms.me/inter/), [JetBrains Mono Variable](https://www.jetbrains.com/lp/mono/) |
|
||||
| Icons | [Lucide React](https://lucide.dev/) |
|
||||
| Scrollbars | [OverlayScrollbars](https://kingsora.github.io/OverlayScrollbars/) |
|
||||
|
||||
### Tauri Plugins
|
||||
|
||||
- `tauri-plugin-dialog` -- native file open dialog
|
||||
- `tauri-plugin-fs` -- file system read access
|
||||
- `tauri-plugin-window-state` -- persist and restore window size, position, and maximized state
|
||||
- `tauri-plugin-opener` -- system default app launcher
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Vesper was designed with these principles in mind:
|
||||
|
||||
1. **Content First** - The reader is the primary focus. All UI elements can be hidden.
|
||||
2. **Typography Matters** - Reading should be comfortable. We use Inter with carefully tuned line heights (1.7), optimal line width (~65 characters), and generous whitespace.
|
||||
3. **Dark by Default** - The deep onyx base color (#282C33) reduces eye strain and looks professional.
|
||||
4. **Keyboard Driven** - Power users can do everything with keyboard shortcuts.
|
||||
5. **Minimalist Chrome** - The interface gets out of your way.
|
||||
1. **Content First** -- the reader is the primary focus; all UI chrome can be hidden
|
||||
2. **Typography Matters** -- Inter Variable at 17px, 1.7 line-height, ~65 character measure, generous whitespace
|
||||
3. **Dark by Default** -- deep onyx base color (#282C33) reduces eye strain
|
||||
4. **Keyboard Driven** -- every feature accessible via keyboard shortcuts
|
||||
5. **Minimalist Chrome** -- the interface gets out of your way
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Color | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
|---|---|---|
|
||||
| Base | `#282C33` | Main window background |
|
||||
| Surface | `#2D323B` | Elevated elements, cards |
|
||||
| Overlay | `#333942` | Dropdowns, modals |
|
||||
| Surface | `#252A31` | Title bar, sidebar |
|
||||
| Elevated | `#2D333C` | Active tab, modals |
|
||||
| Overlay | `#333942` | Dropdowns, context menu |
|
||||
| Text Primary | `#E4E6EB` | Main content |
|
||||
| Text Secondary | `#ABB0B8` | UI labels |
|
||||
| Accent | `#6B8AFF` | Links, highlights |
|
||||
| Text Secondary | `#ABB0B8` | UI labels, muted text |
|
||||
| Text Tertiary | `#6B7280` | Placeholders, hints |
|
||||
| Accent | `#6B8AFF` | Interactive elements, highlights |
|
||||
| Link | `#7CA9F7` | Hyperlinks |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
vesper/
|
||||
├── src/
|
||||
│ ├── App.tsx # Main application component
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── styles.css # All styling
|
||||
├── src-tauri/
|
||||
│ ├── src/
|
||||
│ │ └── main.rs # Rust backend
|
||||
│ ├── Cargo.toml
|
||||
│ └── tauri.conf.json # Tauri configuration
|
||||
├── index.html
|
||||
├── package.json
|
||||
└── README.md
|
||||
md-reader/
|
||||
src/
|
||||
App.tsx # Main application component (all UI logic)
|
||||
main.tsx # React entry point
|
||||
styles.css # All styles -- variables, UI components, markdown typography
|
||||
src-tauri/
|
||||
src/
|
||||
main.rs # Rust entry point
|
||||
lib.rs # Tauri builder and plugin registration
|
||||
tauri.conf.json # Tauri window, bundle, and build configuration
|
||||
Cargo.toml # Rust dependencies
|
||||
icons/ # App icons (ICO, PNG, ICNS)
|
||||
index.html
|
||||
package.json
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Accessibility
|
||||
|
||||
Vesper is fully keyboard accessible. Every feature can be accessed without a mouse:
|
||||
|
||||
- **Menu Navigation** - Use the menu bar with keyboard
|
||||
- **Tab Switching** - Close tabs with `Ctrl+W`
|
||||
- **Search** - Find text with `Ctrl+F`
|
||||
- **Focus Mode** - Immerse yourself with `F11`
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is dedicated to the public domain under the CC0 1.0 Universal (CC0 1.0) Public Domain Dedication - see the [LICENSE](LICENSE) file for details.
|
||||
This project is dedicated to the public domain under the CC0 1.0 Universal (CC0 1.0) Public Domain Dedication -- see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -187,4 +225,4 @@ This project is dedicated to the public domain under the CC0 1.0 Universal (CC0
|
||||
- [iA Writer](https://ia.net/writer) for design inspiration
|
||||
- [Tauri](https://tauri.app/) for the excellent desktop framework
|
||||
- [markdown-it](https://github.com/markdown-it/markdown-it) for robust markdown parsing
|
||||
- [highlight.js](https://highlightjs.org/) for beautiful code syntax
|
||||
- [highlight.js](https://highlightjs.org/) for syntax highlighting
|
||||
|
||||
1619
package-lock.json
generated
43
package.json
@@ -1,42 +1 @@
|
||||
{
|
||||
"name": "vesper",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"daisyui": "^5.5.18",
|
||||
"framer-motion": "^12.34.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markdown-it-mark": "^4.0.0",
|
||||
"markdown-it-sub": "^2.0.0",
|
||||
"markdown-it-sup": "^2.0.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
{"name":"vesper","private":true,"version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"tsc && vite build","preview":"vite preview","tauri":"tauri"},"dependencies":{"@fontsource-variable/inter":"^5.2.8","@fontsource-variable/jetbrains-mono":"^5.2.8","@tailwindcss/typography":"^0.5.19","@tailwindcss/vite":"^4.1.18","@tauri-apps/api":"^2","@tauri-apps/plugin-dialog":"^2.6.0","@tauri-apps/plugin-fs":"^2.4.5","@tauri-apps/plugin-opener":"^2","@tauri-apps/plugin-window-state":"^2.4.1","daisyui":"^5.5.18","framer-motion":"^12.34.0","highlight.js":"^11.11.1","lucide-react":"^0.564.0","markdown-it":"^14.1.1","markdown-it-mark":"^4.0.0","markdown-it-sub":"^2.0.0","markdown-it-sup":"^2.0.0","markdown-it-task-lists":"^2.1.1","overlayscrollbars":"^2.14.0","overlayscrollbars-react":"^0.5.6","react":"^19.1.0","react-dom":"^19.1.0","tailwindcss":"^4.1.18"},"devDependencies":{"@tauri-apps/cli":"^2","@types/markdown-it":"^14.1.2","@types/react":"^19.1.8","@types/react-dom":"^19.1.6","@vitejs/plugin-react":"^4.6.0","png-to-ico":"^3.0.1","puppeteer-core":"^24.37.3","sharp":"^0.34.5","typescript":"~5.8.3","vite":"^7.0.4"}}
|
||||
28
src-tauri/Cargo.lock
generated
@@ -596,20 +596,6 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darkmark"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-window-state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -4308,6 +4294,20 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vesper"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-window-state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "darkmark"
|
||||
name = "vesper"
|
||||
version = "0.1.0"
|
||||
description = "A beautiful markdown reader"
|
||||
authors = ["you"]
|
||||
@@ -8,7 +8,7 @@ edition = "2021"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "darkmark_lib"
|
||||
name = "vesper_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 198 KiB |
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
darkmark_lib::run()
|
||||
vesper_lib::run()
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"transparent": false,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"dragDropEnabled": false
|
||||
"dragDropEnabled": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
741
src/App.tsx
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import hljs from "highlight.js";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@@ -9,10 +10,11 @@ import TaskLists from "markdown-it-task-lists";
|
||||
import sup from "markdown-it-sup";
|
||||
import sub from "markdown-it-sub";
|
||||
import mark from "markdown-it-mark";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
import "overlayscrollbars/overlayscrollbars.css";
|
||||
import {
|
||||
Minus, Square, X, FileText, Search, PanelLeft,
|
||||
Keyboard, Info, ChevronLeft, ChevronRight,
|
||||
FileInput, FolderOpen
|
||||
Minus, Plus, Square, X, FileText,
|
||||
ChevronLeft, ChevronRight, FolderOpen, FileDown
|
||||
} from "lucide-react";
|
||||
|
||||
interface Tab { id: string; title: string; content: string; path: string; }
|
||||
@@ -20,6 +22,7 @@ interface Heading { id: string; text: string; level: number; }
|
||||
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight: function(str: string, lang: string): string {
|
||||
@@ -32,12 +35,215 @@ const md: MarkdownIt = new MarkdownIt({
|
||||
}
|
||||
}).use(TaskLists).use(sup).use(sub).use(mark);
|
||||
|
||||
const materialEase = [0.4, 0, 0.2, 1] as const;
|
||||
const smoothTransition = { duration: 0.2, ease: materialEase };
|
||||
|
||||
// Saved selection range — set in mousedown (before browser clears it), read in contextmenu handler
|
||||
let _savedSelectionRange: Range | null = null;
|
||||
|
||||
// Reusable kinetic scroll + iOS overscroll setup for any scrollable area
|
||||
function setupKineticScroll(
|
||||
viewport: HTMLElement,
|
||||
contentEl: HTMLElement,
|
||||
kineticDragRef: { current: boolean }
|
||||
): () => void {
|
||||
const DRAG_THRESHOLD = 5;
|
||||
let isTracking = false;
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startScrollTop = 0;
|
||||
let velocityY = 0;
|
||||
let lastY = 0;
|
||||
let lastTime = 0;
|
||||
let momentumRaf: number | null = null;
|
||||
let springRaf: number | null = null;
|
||||
let overscrollOffset = 0;
|
||||
|
||||
const cancelAnimations = () => {
|
||||
if (momentumRaf) { cancelAnimationFrame(momentumRaf); momentumRaf = null; }
|
||||
if (springRaf) { cancelAnimationFrame(springRaf); springRaf = null; }
|
||||
};
|
||||
|
||||
const rubberBand = (offset: number, dimension: number): number => {
|
||||
const c = 0.55;
|
||||
const x = Math.abs(offset);
|
||||
return Math.sign(offset) * (1.0 - (1.0 / ((x * c / dimension) + 1.0))) * dimension;
|
||||
};
|
||||
|
||||
const setOverscroll = (offset: number) => {
|
||||
overscrollOffset = offset;
|
||||
contentEl.style.transform = offset !== 0 ? `translateY(${offset}px)` : '';
|
||||
contentEl.style.transition = '';
|
||||
};
|
||||
|
||||
const springBack = () => {
|
||||
cancelAnimations();
|
||||
let position = overscrollOffset;
|
||||
let velocity = 0;
|
||||
const stiffness = 60;
|
||||
const damping = 14;
|
||||
const dt = 1 / 60;
|
||||
|
||||
const animate = () => {
|
||||
const acceleration = -stiffness * position - damping * velocity;
|
||||
velocity += acceleration * dt;
|
||||
position += velocity * dt;
|
||||
|
||||
if (Math.abs(position) < 0.5 && Math.abs(velocity) < 0.5) {
|
||||
setOverscroll(0);
|
||||
return;
|
||||
}
|
||||
setOverscroll(position);
|
||||
springRaf = requestAnimationFrame(animate);
|
||||
};
|
||||
springRaf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const applyMomentum = () => {
|
||||
cancelAnimations();
|
||||
let v = velocityY;
|
||||
const friction = 0.99;
|
||||
|
||||
const animate = () => {
|
||||
v *= friction;
|
||||
if (Math.abs(v) < 0.05) return;
|
||||
|
||||
const desiredTop = viewport.scrollTop - v;
|
||||
|
||||
// Top edge
|
||||
if (desiredTop < 0) {
|
||||
viewport.scrollTop = 0;
|
||||
const stretch = rubberBand(Math.abs(v) * 10, viewport.clientHeight);
|
||||
setOverscroll(Math.abs(stretch));
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set scroll and let browser clamp to the real maximum
|
||||
viewport.scrollTop = desiredTop;
|
||||
const actualTop = viewport.scrollTop;
|
||||
|
||||
// Bottom edge: browser clamped scrollTop below what we asked for
|
||||
if (desiredTop - actualTop > 1) {
|
||||
const stretch = rubberBand(Math.abs(v) * 10, viewport.clientHeight);
|
||||
setOverscroll(-Math.abs(stretch));
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
|
||||
momentumRaf = requestAnimationFrame(animate);
|
||||
};
|
||||
momentumRaf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 2) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.os-scrollbar')) return;
|
||||
|
||||
// If text is selected, don't start kinetic scroll
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.toString().length > 0) return;
|
||||
|
||||
cancelAnimations();
|
||||
if (overscrollOffset !== 0) setOverscroll(0);
|
||||
|
||||
isTracking = true;
|
||||
isDragging = false;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startScrollTop = viewport.scrollTop;
|
||||
lastY = e.clientY;
|
||||
lastTime = performance.now();
|
||||
velocityY = 0;
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isTracking) return;
|
||||
|
||||
if (!isDragging) {
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) return;
|
||||
isDragging = true;
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const now = performance.now();
|
||||
const dt = now - lastTime;
|
||||
const dy = e.clientY - lastY;
|
||||
|
||||
if (dt > 0) {
|
||||
velocityY = velocityY * 0.85 + (dy / dt) * 16 * 0.15;
|
||||
}
|
||||
|
||||
lastY = e.clientY;
|
||||
lastTime = now;
|
||||
|
||||
const scrollDelta = startY - e.clientY;
|
||||
const desiredTop = startScrollTop + scrollDelta;
|
||||
|
||||
// Top edge
|
||||
if (desiredTop < 0) {
|
||||
viewport.scrollTop = 0;
|
||||
setOverscroll(rubberBand(Math.abs(desiredTop), viewport.clientHeight));
|
||||
} else {
|
||||
// Set scroll and let browser clamp
|
||||
viewport.scrollTop = desiredTop;
|
||||
const actualTop = viewport.scrollTop;
|
||||
|
||||
// Bottom edge: browser clamped
|
||||
if (desiredTop - actualTop > 1) {
|
||||
setOverscroll(-rubberBand(desiredTop - actualTop, viewport.clientHeight));
|
||||
} else {
|
||||
if (overscrollOffset !== 0) setOverscroll(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isTracking) return;
|
||||
isTracking = false;
|
||||
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
kineticDragRef.current = true;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
if (overscrollOffset !== 0) {
|
||||
springBack();
|
||||
} else if (Math.abs(velocityY) > 0.5) {
|
||||
applyMomentum();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
return () => {
|
||||
cancelAnimations();
|
||||
viewport.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
contentEl.style.transform = '';
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [focusMode, setFocusMode] = useState(false);
|
||||
const [focusMode, setFocusMode] = useState(true);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchMatches, setSearchMatches] = useState<number[]>([]);
|
||||
@@ -47,16 +253,30 @@ function App() {
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
const [showAboutModal, setShowAboutModal] = useState(false);
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [uiZoom, setUiZoom] = useState(() => {
|
||||
const saved = localStorage.getItem('vesper-ui-zoom');
|
||||
return saved ? parseInt(saved, 10) : 100;
|
||||
});
|
||||
const [contentWidth, setContentWidth] = useState(680);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(220);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem('vesper-sidebar-width');
|
||||
return saved ? parseInt(saved, 10) : 220;
|
||||
});
|
||||
const [searchTriggerCount, setSearchTriggerCount] = useState(0);
|
||||
const [resizeStartState, setResizeStartState] = useState<{ startX: number; startWidth: number } | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; selectedText: string } | null>(null);
|
||||
const [selectionRects, setSelectionRects] = useState<{ left: number; top: number; width: number; height: number }[]>([]);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const menuItemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
const kineticDragActiveRef = useRef(false);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const tabScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [tabScrollState, setTabScrollState] = useState({ canLeft: false, canRight: false });
|
||||
const tabsRef = useRef(tabs);
|
||||
tabsRef.current = tabs;
|
||||
|
||||
const [appWindow, setAppWindow] = useState<ReturnType<typeof getCurrentWindow> | null>(null);
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) || null;
|
||||
@@ -65,11 +285,46 @@ function App() {
|
||||
setAppWindow(getCurrentWindow());
|
||||
}, []);
|
||||
|
||||
// Capture-phase mousedown: save selection range before browser clears it
|
||||
useEffect(() => {
|
||||
const saveSelection = (e: MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0 && sel.toString().length > 0) {
|
||||
_savedSelectionRange = sel.getRangeAt(0).cloneRange();
|
||||
} else {
|
||||
_savedSelectionRange = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', saveSelection, true);
|
||||
return () => document.removeEventListener('mousedown', saveSelection, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (activeTab) {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
if (kineticDragActiveRef.current) {
|
||||
kineticDragActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (activeTab) {
|
||||
const selectedText = window.getSelection()?.toString() || '';
|
||||
const zf = uiZoom / 100;
|
||||
const x = e.clientX / zf;
|
||||
const y = e.clientY / zf;
|
||||
|
||||
// Build selection overlay rects from saved range (before browser cleared it)
|
||||
let rects: { left: number; top: number; width: number; height: number }[] = [];
|
||||
if (selectedText && _savedSelectionRange) {
|
||||
const clientRects = _savedSelectionRange.getClientRects();
|
||||
rects = Array.from(clientRects).map(r => ({
|
||||
left: r.left / zf, top: r.top / zf, width: r.width / zf, height: r.height / zf
|
||||
}));
|
||||
}
|
||||
|
||||
setContextMenu({ x, y, selectedText });
|
||||
setSelectionRects(rects);
|
||||
}
|
||||
};
|
||||
const handleClick = () => setContextMenu(null);
|
||||
@@ -79,7 +334,38 @@ function App() {
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [activeTab]);
|
||||
}, [activeTab, uiZoom]);
|
||||
|
||||
// Clear selection overlay when context menu closes
|
||||
useEffect(() => {
|
||||
if (!contextMenu) setSelectionRects(prev => prev.length > 0 ? [] : prev);
|
||||
}, [contextMenu]);
|
||||
|
||||
// Clamp context menu within window bounds after render
|
||||
useLayoutEffect(() => {
|
||||
const menu = contextMenuRef.current;
|
||||
if (!menu || !contextMenu) return;
|
||||
const zf = uiZoom / 100;
|
||||
// Menu rect is in viewport pixels (already scaled by zoom)
|
||||
const rect = menu.getBoundingClientRect();
|
||||
// Convert menu dimensions to zoomed coordinate space
|
||||
const menuW = rect.width / zf;
|
||||
const menuH = rect.height / zf;
|
||||
// Window dimensions in zoomed coordinate space
|
||||
const winW = window.innerWidth / zf;
|
||||
const winH = window.innerHeight / zf;
|
||||
|
||||
let x = contextMenu.x;
|
||||
let y = contextMenu.y;
|
||||
|
||||
if (x + menuW > winW) x = winW - menuW;
|
||||
if (y + menuH > winH) y = winH - menuH;
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
}, [contextMenu, uiZoom]);
|
||||
|
||||
const parseHeadings = useCallback((content: string) => {
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
@@ -91,49 +377,161 @@ function App() {
|
||||
setHeadings(parsed);
|
||||
}, []);
|
||||
|
||||
const renderMarkdown = useCallback((content: string) => ({ __html: md.render(content) }), []);
|
||||
// Render markdown to HTML — memoized so it only re-runs when content changes
|
||||
const renderedHtml = useMemo(() => {
|
||||
if (!activeTab) return '';
|
||||
return md.render(activeTab.content);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleFileDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Inject search highlights into the HTML string (survives React re-renders)
|
||||
const { highlightedHtml, highlightCount } = useMemo(() => {
|
||||
if (!searchQuery || !renderedHtml) return { highlightedHtml: renderedHtml, highlightCount: 0 };
|
||||
|
||||
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(escaped, 'gi');
|
||||
let count = 0;
|
||||
|
||||
// Split on HTML tags, only highlight text segments
|
||||
const parts = renderedHtml.split(/(<[^>]*>)/);
|
||||
const highlighted = parts.map(part => {
|
||||
if (part.startsWith('<')) return part;
|
||||
return part.replace(regex, (match) => {
|
||||
const idx = count++;
|
||||
return `<span class="search-highlight" data-match-index="${idx}">${match}</span>`;
|
||||
});
|
||||
}).join('');
|
||||
|
||||
return { highlightedHtml: highlighted, highlightCount: count };
|
||||
}, [renderedHtml, searchQuery]);
|
||||
|
||||
// Sync highlight count with search matches state
|
||||
useEffect(() => {
|
||||
setSearchMatches(Array.from({ length: highlightCount }, (_, i) => i));
|
||||
if (highlightCount === 0) setCurrentMatchIndex(0);
|
||||
}, [highlightCount]);
|
||||
|
||||
// Scroll to active match and toggle active class
|
||||
useEffect(() => {
|
||||
const container = document.querySelector('.markdown-content');
|
||||
if (!container || highlightCount === 0) return;
|
||||
|
||||
container.querySelectorAll('.search-highlight-active').forEach(el => {
|
||||
el.classList.remove('search-highlight-active');
|
||||
});
|
||||
|
||||
const active = container.querySelector(`[data-match-index="${currentMatchIndex}"]`);
|
||||
if (active) {
|
||||
active.classList.add('search-highlight-active');
|
||||
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [currentMatchIndex, highlightCount, searchTriggerCount]);
|
||||
|
||||
const updateTabScrollState = useCallback(() => {
|
||||
const el = tabScrollRef.current;
|
||||
if (!el) { setTabScrollState({ canLeft: false, canRight: false }); return; }
|
||||
setTabScrollState({
|
||||
canLeft: el.scrollLeft > 0,
|
||||
canRight: el.scrollLeft < el.scrollWidth - el.clientWidth - 1,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const tabScrollInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startScrollingTabs = useCallback((dir: 'left' | 'right') => {
|
||||
const el = tabScrollRef.current;
|
||||
if (!el) return;
|
||||
const step = dir === 'left' ? -8 : 8;
|
||||
el.scrollBy({ left: step });
|
||||
updateTabScrollState();
|
||||
tabScrollInterval.current = setInterval(() => {
|
||||
el.scrollBy({ left: step });
|
||||
updateTabScrollState();
|
||||
}, 16);
|
||||
}, [updateTabScrollState]);
|
||||
|
||||
const stopScrollingTabs = useCallback(() => {
|
||||
if (tabScrollInterval.current) {
|
||||
clearInterval(tabScrollInterval.current);
|
||||
tabScrollInterval.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollTabsToEnd = useCallback(() => {
|
||||
const el = tabScrollRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
|
||||
setTimeout(updateTabScrollState, 300);
|
||||
});
|
||||
}, [updateTabScrollState]);
|
||||
|
||||
const scrollToTabIndex = useCallback((index: number) => {
|
||||
const el = tabScrollRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
const tab = el.children[0]?.children[index] as HTMLElement | undefined;
|
||||
if (tab) tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
setTimeout(updateTabScrollState, 300);
|
||||
});
|
||||
}, [updateTabScrollState]);
|
||||
|
||||
// Tauri native drag-and-drop (bypasses browser event interception)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type === 'enter') {
|
||||
setIsDraggingOver(true);
|
||||
} else if (event.payload.type === 'leave') {
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const mdFile = files.find(f => f.name.endsWith('.md') || f.name.endsWith('.markdown') || f.name.endsWith('.txt'));
|
||||
|
||||
if (mdFile) {
|
||||
const content = await mdFile.text();
|
||||
const newTab: Tab = { id: Date.now().toString(), title: mdFile.name, content, path: mdFile.name };
|
||||
} else if (event.payload.type === 'drop') {
|
||||
setIsDraggingOver(false);
|
||||
const paths = event.payload.paths;
|
||||
const mdPath = paths.find((p: string) => p.endsWith('.md') || p.endsWith('.markdown') || p.endsWith('.txt'));
|
||||
if (mdPath) {
|
||||
try {
|
||||
const existing = tabsRef.current.find(t => t.path === mdPath);
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id);
|
||||
parseHeadings(existing.content);
|
||||
scrollToTabIndex(tabsRef.current.indexOf(existing));
|
||||
} else {
|
||||
const content = await readTextFile(mdPath);
|
||||
const title = mdPath.split(/[/\\]/).pop() || 'Untitled';
|
||||
const newTab: Tab = { id: Date.now().toString(), title, content, path: mdPath };
|
||||
setTabs(prev => [...prev, newTab]);
|
||||
setActiveTabId(newTab.id);
|
||||
parseHeadings(content);
|
||||
scrollTabsToEnd();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read dropped file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).then(fn => { unlisten = fn; });
|
||||
return () => { unlisten?.(); };
|
||||
}, [parseHeadings]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenDialog = useCallback(async () => {
|
||||
try {
|
||||
const selected = await open({ multiple: false, filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'txt'] }] });
|
||||
if (selected) {
|
||||
const existing = tabsRef.current.find(t => t.path === selected);
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id);
|
||||
parseHeadings(existing.content);
|
||||
scrollToTabIndex(tabsRef.current.indexOf(existing));
|
||||
} else {
|
||||
const content = await readTextFile(selected);
|
||||
const newTab: Tab = { id: Date.now().toString(), title: selected.split(/[/\\]/).pop() || 'Untitled', content, path: selected };
|
||||
setTabs(prev => [...prev, newTab]);
|
||||
setActiveTabId(newTab.id);
|
||||
parseHeadings(content);
|
||||
scrollTabsToEnd();
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error('Failed to open file:', err); }
|
||||
}, [parseHeadings]);
|
||||
}, [parseHeadings, scrollTabsToEnd, scrollToTabIndex]);
|
||||
|
||||
const closeTab = useCallback((id: string) => {
|
||||
setTabs(prev => {
|
||||
@@ -148,6 +546,25 @@ function App() {
|
||||
});
|
||||
}, [activeTabId, parseHeadings]);
|
||||
|
||||
// Track tab scroll state + mouse wheel horizontal scroll
|
||||
useEffect(() => {
|
||||
updateTabScrollState();
|
||||
const el = tabScrollRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => updateTabScrollState();
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
el.scrollBy({ left: e.deltaY });
|
||||
updateTabScrollState();
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => { el.removeEventListener('scroll', onScroll); el.removeEventListener('wheel', onWheel); window.removeEventListener('resize', onScroll); };
|
||||
}, [tabs.length, updateTabScrollState]);
|
||||
|
||||
const closeWindow = useCallback(async () => { await appWindow?.close(); }, [appWindow]);
|
||||
|
||||
const scrollToHeading = useCallback((text: string) => {
|
||||
@@ -156,39 +573,6 @@ function App() {
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery || !activeTab) {
|
||||
setSearchMatches([]);
|
||||
return;
|
||||
}
|
||||
const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
const matches: number[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(activeTab.content)) !== null) {
|
||||
matches.push(match.index);
|
||||
}
|
||||
setSearchMatches(matches);
|
||||
setCurrentMatchIndex(0);
|
||||
}, [searchQuery, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMatches.length > 0 && contentRef.current) {
|
||||
const contentEl = contentRef.current;
|
||||
const textContent = contentEl.innerText || '';
|
||||
const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
const matchIdx = searchMatches[currentMatchIndex];
|
||||
if (matchIdx !== undefined) {
|
||||
const matchPos = textContent.indexOf(searchQuery, Math.max(0, matchIdx - 50));
|
||||
if (matchPos !== -1) {
|
||||
const element = contentEl.querySelector(`[data-search-index="${currentMatchIndex}"]`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [searchTriggerCount, searchMatches, currentMatchIndex, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
@@ -212,17 +596,13 @@ function App() {
|
||||
setZoom(z => Math.max(50, Math.min(200, z + delta)));
|
||||
} else if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setContentWidth(w => Math.max(400, Math.min(1200, w + e.deltaY)));
|
||||
setContentWidth(w => Math.max(400, Math.min(1200, w - e.deltaY)));
|
||||
}
|
||||
};
|
||||
document.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => document.removeEventListener('wheel', handleWheel);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = `${zoom}%`;
|
||||
}, [zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
let isDraggingWindow = false;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
@@ -241,6 +621,42 @@ function App() {
|
||||
};
|
||||
}, [appWindow, focusMode]);
|
||||
|
||||
// Kinetic scroll: right-click + drag with iOS-style elastic overscroll
|
||||
// Applied to both content area and sidebar
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
// Content area
|
||||
const contentWrapper = document.querySelector('.content-area-scroll-wrapper');
|
||||
if (contentWrapper) {
|
||||
const viewport = contentWrapper.querySelector('[data-overlayscrollbars-viewport]') as HTMLElement;
|
||||
const contentDiv = contentWrapper.querySelector('.content-area-scroll') as HTMLElement;
|
||||
if (viewport && contentDiv) {
|
||||
cleanups.push(setupKineticScroll(viewport, contentDiv, kineticDragActiveRef));
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar
|
||||
const sidebarWrapper = document.querySelector('.sidebar-scroll-wrapper');
|
||||
if (sidebarWrapper) {
|
||||
const viewport = sidebarWrapper.querySelector('[data-overlayscrollbars-viewport]') as HTMLElement;
|
||||
const sidebarDiv = sidebarWrapper.querySelector('.sidebar') as HTMLElement;
|
||||
if (viewport && sidebarDiv) {
|
||||
cleanups.push(setupKineticScroll(viewport, sidebarDiv, kineticDragActiveRef));
|
||||
}
|
||||
}
|
||||
}); });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanups.forEach(fn => fn());
|
||||
};
|
||||
}, [activeTabId, showSidebar]);
|
||||
|
||||
const startSidebarResize = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -250,7 +666,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (!resizeStartState) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const zoomFactor = zoom / 100;
|
||||
const zoomFactor = uiZoom / 100;
|
||||
const diff = (e.clientX - resizeStartState.startX) / zoomFactor;
|
||||
const newWidth = Math.max(150, Math.min(400, resizeStartState.startWidth + diff));
|
||||
setSidebarWidth(newWidth);
|
||||
@@ -266,26 +682,41 @@ function App() {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [resizeStartState, zoom]);
|
||||
}, [resizeStartState, uiZoom]);
|
||||
|
||||
const focusModeClass = focusMode ? 'focus-mode' : '';
|
||||
// Persist sidebar width to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('vesper-sidebar-width', String(Math.round(sidebarWidth)));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
// Persist UI zoom to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('vesper-ui-zoom', String(Math.round(uiZoom)));
|
||||
}, [uiZoom]);
|
||||
|
||||
const osScrollbarOptions = {
|
||||
scrollbars: { autoHide: 'scroll' as const, autoHideDelay: 800, theme: 'os-theme-dark' as const }
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`app-container ${focusModeClass}`}
|
||||
onDrop={handleFileDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div className={`app-container ${focusMode ? 'focus-mode' : ''}`} style={{ zoom: `${uiZoom}%` }}>
|
||||
<AnimatePresence>
|
||||
{isDraggingOver && (
|
||||
<div className="drop-zone">
|
||||
<div className="drop-zone-content">
|
||||
<div className="drop-zone-icon">📄</div>
|
||||
<motion.div className="drop-zone" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15, ease: materialEase }}>
|
||||
<motion.div className="drop-zone-content" initial={{ opacity: 0, scale: 0.92, y: 8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.92, y: 8 }} transition={{ duration: 0.2, ease: materialEase }}>
|
||||
<motion.div className="drop-zone-icon-wrapper" animate={{ y: [0, -6, 0] }} transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}>
|
||||
<FileDown size={40} strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
<div className="drop-zone-text">Drop markdown file here</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="drop-zone-hint">.md, .markdown, or .txt</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="title-bar">
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{!focusMode && (
|
||||
<motion.div key="title-bar" className="title-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 32, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={smoothTransition} style={{ overflow: 'hidden' }}>
|
||||
<div className="title-bar-left">
|
||||
<div className="title-bar-icon"><FileText size={14} /></div>
|
||||
<span className="title-bar-text">Vesper</span>
|
||||
@@ -296,15 +727,19 @@ function App() {
|
||||
<button className="title-bar-button" onClick={() => appWindow?.toggleMaximize()}><Square size={12} /></button>
|
||||
<button className="title-bar-button close" onClick={closeWindow}><X size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="menu-bar">
|
||||
<AnimatePresence initial={false}>
|
||||
{!focusMode && (
|
||||
<motion.div key="menu-bar" className="menu-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 28, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={smoothTransition} style={{ overflow: 'hidden' }}>
|
||||
<div className="menu-item" ref={(el) => { menuItemRefs.current['file'] = el; }} onClick={() => setMenuOpen(prev => prev === 'file' ? null : 'file')}>File</div>
|
||||
<div className="menu-item" ref={(el) => { menuItemRefs.current['view'] = el; }} onClick={() => setMenuOpen(prev => prev === 'view' ? null : 'view')}>View</div>
|
||||
<div className="menu-item" ref={(el) => { menuItemRefs.current['help'] = el; }} onClick={() => setMenuOpen(prev => prev === 'help' ? null : 'help')}>Help</div>
|
||||
<AnimatePresence>
|
||||
{menuOpen === 'file' && menuItemRefs.current['file'] && (
|
||||
<motion.div className="menu-dropdown" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} style={{ position: 'fixed', left: menuItemRefs.current['file']!.getBoundingClientRect().left, top: menuItemRefs.current['file']!.getBoundingClientRect().bottom - 6 }}>
|
||||
<motion.div className="menu-dropdown" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['file']!.getBoundingClientRect().left, top: menuItemRefs.current['file']!.getBoundingClientRect().bottom - 6 }}>
|
||||
<button className="menu-dropdown-item" onClick={() => { handleOpenDialog(); setMenuOpen(null); }}>Open File <span className="menu-shortcut">Ctrl+O</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { if (activeTabId) closeTab(activeTabId); setMenuOpen(null); }}>Close Tab <span className="menu-shortcut">Ctrl+W</span></button>
|
||||
<div className="menu-separator"></div>
|
||||
@@ -312,52 +747,74 @@ function App() {
|
||||
</motion.div>
|
||||
)}
|
||||
{menuOpen === 'view' && menuItemRefs.current['view'] && (
|
||||
<motion.div className="menu-dropdown" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} style={{ position: 'fixed', left: menuItemRefs.current['view']!.getBoundingClientRect().left, top: menuItemRefs.current['view']!.getBoundingClientRect().bottom - 6 }}>
|
||||
<motion.div className="menu-dropdown" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['view']!.getBoundingClientRect().left, top: menuItemRefs.current['view']!.getBoundingClientRect().bottom - 6 }}>
|
||||
<button className="menu-dropdown-item" onClick={() => { setShowSearch(s => !s); setMenuOpen(null); }}>Toggle Search <span className="menu-shortcut">Ctrl+F</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { setShowSidebar(s => !s); setMenuOpen(null); }}>Toggle Sidebar <span className="menu-shortcut">Ctrl+Shift+S</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { setFocusMode(f => !f); setMenuOpen(null); }}>Focus Mode <span className="menu-shortcut">F11</span></button>
|
||||
<div className="menu-separator"></div>
|
||||
<div className="menu-dropdown-zoom" onClick={e => e.stopPropagation()}>
|
||||
<span>UI Scale</span>
|
||||
<div className="zoom-spinner">
|
||||
<button className="zoom-spinner-btn" onClick={() => setUiZoom(z => Math.max(50, z - 10))}><Minus size={12} /></button>
|
||||
<span className="zoom-spinner-value">{uiZoom}%</span>
|
||||
<button className="zoom-spinner-btn" onClick={() => setUiZoom(z => Math.min(200, z + 10))}><Plus size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{menuOpen === 'help' && menuItemRefs.current['help'] && (
|
||||
<motion.div className="menu-dropdown" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} style={{ position: 'fixed', left: menuItemRefs.current['help']!.getBoundingClientRect().left, top: menuItemRefs.current['help']!.getBoundingClientRect().bottom - 6 }}>
|
||||
<motion.div className="menu-dropdown" initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -5 }} transition={smoothTransition} style={{ position: 'fixed', left: menuItemRefs.current['help']!.getBoundingClientRect().left, top: menuItemRefs.current['help']!.getBoundingClientRect().bottom - 6 }}>
|
||||
<button className="menu-dropdown-item" onClick={() => { setShowShortcutsModal(true); setMenuOpen(null); }}>Keyboard Shortcuts</button>
|
||||
<button className="menu-dropdown-item" onClick={() => { setShowAboutModal(true); setMenuOpen(null); }}>About Vesper</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{tabs.length > 1 && (
|
||||
<motion.div className="tab-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 38, opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
|
||||
<div className="tab-scroll-container">
|
||||
<motion.div className="tab-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 38, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: materialEase }}>
|
||||
<button className={`tab-scroll-arrow ${tabScrollState.canLeft ? 'visible' : ''}`} onMouseDown={() => startScrollingTabs('left')} onMouseUp={stopScrollingTabs} onMouseLeave={stopScrollingTabs}><ChevronLeft size={14} /></button>
|
||||
<div className="tab-scroll-container" ref={tabScrollRef}>
|
||||
<AnimatePresence initial={false}>
|
||||
{tabs.map(tab => (
|
||||
<div key={tab.id} className={`tab ${tab.id === activeTabId ? 'active' : ''}`} onClick={() => { setActiveTabId(tab.id); parseHeadings(tab.content); }}>
|
||||
<motion.div layout key={tab.id} className={`tab ${tab.id === activeTabId ? 'active' : ''}`} initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={smoothTransition} onClick={() => { setActiveTabId(tab.id); parseHeadings(tab.content); }}>
|
||||
<span className="tab-title">{tab.title}</span>
|
||||
<button className="tab-close" onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}><X size={12} /></button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<button className={`tab-scroll-arrow ${tabScrollState.canRight ? 'visible' : ''}`} onMouseDown={() => startScrollingTabs('right')} onMouseUp={stopScrollingTabs} onMouseLeave={stopScrollingTabs}><ChevronRight size={14} /></button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="main-content">
|
||||
{showSidebar && headings.length > 0 && (
|
||||
<div className="sidebar-scroll-wrapper" style={{ width: sidebarWidth, height: '100%' }}>
|
||||
<AnimatePresence initial={false}>
|
||||
{showSidebar && (
|
||||
<motion.div className="sidebar-scroll-wrapper" initial={{ width: 0, opacity: 0 }} animate={{ width: sidebarWidth, opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={resizeStartState ? { duration: 0 } : smoothTransition} style={{ height: '100%' }}>
|
||||
<OverlayScrollbarsComponent options={osScrollbarOptions} style={{ height: '100%' }}>
|
||||
<div className="sidebar" ref={sidebarRef}>
|
||||
<div className="sidebar-heading">Contents</div>
|
||||
{headings.map(h => <button key={h.id} className={`sidebar-item h${h.level}`} onClick={() => scrollToHeading(h.text)}>{h.text}</button>)}
|
||||
{headings.length > 0
|
||||
? headings.map(h => <button key={h.id} className={`sidebar-item h${h.level}`} onClick={() => scrollToHeading(h.text)}>{h.text}</button>)
|
||||
: <div className="sidebar-empty">No headings</div>
|
||||
}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
<div className="sidebar-resize-handle" onMouseDown={startSidebarResize}></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="content-column">
|
||||
<AnimatePresence>
|
||||
{showSearch && activeTab && (
|
||||
<motion.div className="search-bar" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<input ref={searchInputRef} type="text" className="search-input" placeholder="Search..." value={searchQuery} onChange={e => { setSearchQuery(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') { setSearchTriggerCount(c => c + 1); searchInputRef.current?.blur(); } }} />
|
||||
<motion.div className="search-bar" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={smoothTransition}>
|
||||
<input ref={searchInputRef} type="text" className="search-input" placeholder="Search..." value={searchQuery} onChange={e => { setSearchQuery(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setCurrentMatchIndex(i => (i + 1) % Math.max(searchMatches.length, 1)); setSearchTriggerCount(c => c + 1); } }} autoFocus />
|
||||
{searchMatches.length > 0 && (
|
||||
<>
|
||||
<button className="search-nav-btn" onClick={() => { setCurrentMatchIndex(i => (i - 1 + searchMatches.length) % searchMatches.length); setSearchTriggerCount(c => c + 1); }}><ChevronLeft size={16} /></button>
|
||||
@@ -370,12 +827,13 @@ function App() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div className="content-area-scroll-wrapper" key={activeTabId} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<div className="content-area-scroll" ref={contentRef}>
|
||||
<motion.div className="content-area-scroll-wrapper" key={activeTabId} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={smoothTransition}>
|
||||
<OverlayScrollbarsComponent options={osScrollbarOptions} style={{ height: '100%' }}>
|
||||
<div className="content-area-scroll" ref={contentRef} style={{ zoom: `${(zoom / uiZoom) * 100}%`, ...(!activeTab ? { height: '100%' } : {}) }}>
|
||||
{activeTab ? (
|
||||
<article className="markdown-content" style={{ maxWidth: contentWidth }} dangerouslySetInnerHTML={renderMarkdown(activeTab.content)} />
|
||||
<article className="markdown-content" style={{ maxWidth: contentWidth }} dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
|
||||
) : (
|
||||
<motion.div className="welcome-screen" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}>
|
||||
<motion.div className="welcome-screen" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={smoothTransition}>
|
||||
<motion.div className="welcome-icon" animate={{ y: [0, -5, 0] }} transition={{ duration: 2, repeat: Infinity }}><FolderOpen size={64} /></motion.div>
|
||||
<h1 className="welcome-title">Vesper</h1>
|
||||
<p className="welcome-subtitle">A beautiful markdown reader</p>
|
||||
@@ -384,36 +842,47 @@ function App() {
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showShortcutsModal && (
|
||||
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setShowShortcutsModal(false)}>
|
||||
<motion.div className="modal" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Keyboard Shortcuts</h2>
|
||||
<button className="modal-close" onClick={() => setShowShortcutsModal(false)}><X size={16} /></button>
|
||||
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowShortcutsModal(false)}>
|
||||
<motion.div className="shortcuts-dialog" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition} onClick={e => e.stopPropagation()}>
|
||||
<div className="shortcuts-dialog-header">
|
||||
<h2 className="shortcuts-dialog-title">Keyboard Shortcuts</h2>
|
||||
<button className="shortcuts-dialog-close" onClick={() => setShowShortcutsModal(false)}><X size={16} /></button>
|
||||
</div>
|
||||
<div className="modal-content">
|
||||
<div className="shortcut-group"><h3 className="shortcut-group-title">File</h3>
|
||||
<div className="shortcut"><span>Open File</span><kbd>Ctrl+O</kbd></div>
|
||||
<div className="shortcut"><span>Close Tab</span><kbd>Ctrl+W</kbd></div>
|
||||
<div className="shortcut"><span>Exit</span><kbd>Ctrl+Q</kbd></div>
|
||||
<div className="shortcuts-dialog-body">
|
||||
<div>
|
||||
<h3 className="shortcuts-dialog-group-title">File</h3>
|
||||
<div className="shortcuts-dialog-item"><span>Open File</span><kbd>Ctrl+O</kbd></div>
|
||||
<div className="shortcuts-dialog-item"><span>Close Tab</span><kbd>Ctrl+W</kbd></div>
|
||||
<div className="shortcuts-dialog-item"><span>Exit</span><kbd>Ctrl+Q</kbd></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="shortcuts-dialog-group-title">View</h3>
|
||||
<div className="shortcuts-dialog-item"><span>Toggle Search</span><kbd>Ctrl+F</kbd></div>
|
||||
<div className="shortcuts-dialog-item"><span>Toggle Sidebar</span><kbd>Ctrl+Shift+S</kbd></div>
|
||||
<div className="shortcuts-dialog-item"><span>Focus Mode</span><kbd>F11</kbd></div>
|
||||
</div>
|
||||
<div className="shortcuts-dialog-nav-grid">
|
||||
<h3 className="shortcuts-dialog-group-title">Navigation</h3>
|
||||
<div className="shortcuts-dialog-items-row">
|
||||
<div className="shortcuts-dialog-item"><span>Close Search / Sidebar</span><kbd>Esc</kbd></div>
|
||||
<div className="shortcuts-dialog-item"><span>Zoom In / Out</span><kbd>Ctrl+Scroll</kbd></div>
|
||||
<div className="shortcuts-dialog-item"><span>Content Width</span><kbd>Shift+Scroll</kbd></div>
|
||||
</div>
|
||||
<div className="shortcut-group"><h3 className="shortcut-group-title">View</h3>
|
||||
<div className="shortcut"><span>Toggle Search</span><kbd>Ctrl+F</kbd></div>
|
||||
<div className="shortcut"><span>Toggle Sidebar</span><kbd>Ctrl+Shift+S</kbd></div>
|
||||
<div className="shortcut"><span>Focus Mode</span><kbd>F11</kbd></div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
{showAboutModal && (
|
||||
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setShowAboutModal(false)}>
|
||||
<motion.div className="modal" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} onClick={e => e.stopPropagation()}>
|
||||
<motion.div className="modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={smoothTransition} onClick={() => setShowAboutModal(false)}>
|
||||
<motion.div className="modal" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">About Vesper</h2>
|
||||
<button className="modal-close" onClick={() => setShowAboutModal(false)}><X size={16} /></button>
|
||||
@@ -436,18 +905,36 @@ function App() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{selectionRects.length > 0 && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 999 }}>
|
||||
{selectionRects.map((r, i) => (
|
||||
<div key={i} style={{ position: 'absolute', left: r.left, top: r.top, width: r.width, height: r.height, backgroundColor: 'rgba(56, 130, 221, 0.3)', borderRadius: 2 }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{contextMenu && (
|
||||
<div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}>
|
||||
<button className="context-menu-item" onClick={() => { handleOpenDialog(); setContextMenu(null); }}>Open File</button>
|
||||
<button className="context-menu-item" onClick={() => { setShowSearch(true); setContextMenu(null); }}>Find <span className="menu-shortcut">Ctrl+F</span></button>
|
||||
<motion.div ref={contextMenuRef} className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={smoothTransition}>
|
||||
{contextMenu.selectedText && (
|
||||
<>
|
||||
<button className="context-menu-item" onClick={() => { navigator.clipboard.writeText(contextMenu.selectedText); setContextMenu(null); }}>Copy</button>
|
||||
<div className="context-menu-separator"></div>
|
||||
</>
|
||||
)}
|
||||
<button className="context-menu-item" onClick={() => { handleOpenDialog(); setContextMenu(null); }}>Open File</button>
|
||||
<div className="context-menu-separator"></div>
|
||||
<button className="context-menu-item" onClick={() => { setShowSearch(s => !s); setContextMenu(null); }}>Toggle Search</button>
|
||||
<button className="context-menu-item" onClick={() => { setShowSidebar(s => !s); setContextMenu(null); }}>Toggle Sidebar</button>
|
||||
<button className="context-menu-item" onClick={() => { setFocusMode(f => !f); setContextMenu(null); }}>Focus Mode</button>
|
||||
<div className="context-menu-separator"></div>
|
||||
<button className="context-menu-item" onClick={() => { setShowShortcutsModal(true); setContextMenu(null); }}>Keyboard Shortcuts</button>
|
||||
<button className="context-menu-item" onClick={() => { setShowAboutModal(true); setContextMenu(null); }}>About</button>
|
||||
</div>
|
||||
<div className="context-menu-separator"></div>
|
||||
<button className="context-menu-item" onClick={() => { closeWindow(); setContextMenu(null); }}>Exit <span className="menu-shortcut">Ctrl+Q</span></button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,8 @@ import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
console.log("main.tsx loading...");
|
||||
|
||||
try {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
console.log("React rendered successfully");
|
||||
} catch (e) {
|
||||
console.error("Error rendering:", e);
|
||||
}
|
||||
|
||||
386
src/styles.css
@@ -1,10 +1,11 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "darkmark";
|
||||
name: "vesper";
|
||||
default: true;
|
||||
prefersdark: true;
|
||||
|
||||
@@ -69,7 +70,7 @@
|
||||
--color-border-strong: rgba(255, 255, 255, 0.12);
|
||||
|
||||
/* Typography */
|
||||
font-family: 'Inter', -apple-system, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
|
||||
font-family: 'Inter Variable', 'Inter', -apple-system, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-primary);
|
||||
@@ -239,6 +240,7 @@ html, body, #root {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
padding: 0 12px;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
@@ -259,6 +261,16 @@ html, body, #root {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Menu backdrop — invisible overlay to catch outside clicks */
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* Menu dropdown */
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
@@ -321,6 +333,52 @@ html, body, #root {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Zoom spinner in View menu */
|
||||
.menu-dropdown-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.zoom-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.zoom-spinner-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.zoom-spinner-btn:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.zoom-spinner-value {
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
@@ -331,6 +389,32 @@ html, body, #root {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-scroll-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-scroll-arrow.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tab-scroll-arrow:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.tab-scroll-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -338,35 +422,30 @@ html, body, #root {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
gap: 2px;
|
||||
padding: 0 8px;
|
||||
scrollbar-width: thin;
|
||||
padding: 0 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tab-scroll-container::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.tab-scroll-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tab-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
@@ -381,7 +460,6 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
@@ -400,6 +478,7 @@ html, body, #root {
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
@@ -453,8 +532,6 @@ html, body, #root {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding-bottom: 20px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle {
|
||||
@@ -485,6 +562,13 @@ html, body, #root {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 12px 18px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-disabled);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -531,11 +615,9 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.content-area-scroll {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
padding: 48px 64px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Search bar */
|
||||
@@ -614,7 +696,8 @@ html, body, #root {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(30, 33, 40, 0.92);
|
||||
background-color: rgba(20, 22, 28, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -625,30 +708,40 @@ html, body, #root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 56px 80px;
|
||||
border: 2px dashed var(--color-accent);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-accent-subtle);
|
||||
gap: 14px;
|
||||
padding: 48px 72px;
|
||||
border: 2px dashed rgba(107, 138, 255, 0.5);
|
||||
border-radius: 16px;
|
||||
background-color: rgba(107, 138, 255, 0.06);
|
||||
box-shadow: 0 0 0 1px rgba(107, 138, 255, 0.1), 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
font-size: 48px;
|
||||
.drop-zone-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(107, 138, 255, 0.1);
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.drop-zone-text {
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Focus mode - hide title bar, tabs, and menu, but allow sidebar/search */
|
||||
.focus-mode .title-bar,
|
||||
.focus-mode .tab-bar,
|
||||
.focus-mode .menu-bar {
|
||||
display: none !important;
|
||||
.drop-zone-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
/* Focus mode - animated via framer-motion, extra padding for content */
|
||||
.focus-mode .content-area-scroll {
|
||||
padding: 64px 96px;
|
||||
}
|
||||
@@ -662,7 +755,7 @@ html, body, #root {
|
||||
height: 100%;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-family: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
@@ -768,6 +861,7 @@ html, body, #root {
|
||||
box-shadow: var(--shadow-modal);
|
||||
min-width: 360px;
|
||||
max-width: 480px;
|
||||
width: 90vw;
|
||||
display: block;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
@@ -778,6 +872,103 @@ html, body, #root {
|
||||
z-index: 10003 !important;
|
||||
}
|
||||
|
||||
/* Shortcuts dialog — uses its own class to avoid DaisyUI .modal conflicts */
|
||||
.shortcuts-dialog {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-modal);
|
||||
width: 640px;
|
||||
max-width: 92vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
z-index: 10003;
|
||||
}
|
||||
|
||||
.shortcuts-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.shortcuts-dialog-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.shortcuts-dialog-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.shortcuts-dialog-close:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.shortcuts-dialog-body {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px 32px;
|
||||
}
|
||||
|
||||
.shortcuts-dialog-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.shortcuts-dialog-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shortcuts-dialog-item kbd {
|
||||
font-family: 'JetBrains Mono Variable', 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
background-color: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcuts-dialog-nav-grid {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.shortcuts-dialog-nav-grid .shortcuts-dialog-items-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 32px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -873,6 +1064,24 @@ html, body, #root {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.shortcut kbd {
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -901,7 +1110,7 @@ html, body, #root {
|
||||
/* Premium typography settings */
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-family: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
font-feature-settings: "liga" 1, "kern" 1, "calt" 1;
|
||||
@@ -913,15 +1122,15 @@ html, body, #root {
|
||||
text-spacing-trim: trim-both;
|
||||
}
|
||||
|
||||
/* Refined heading hierarchy */
|
||||
/* Refined heading hierarchy — progressive color cascade */
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
color: var(--color-text-primary);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
color: #F0F1F3;
|
||||
font-family: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.025em;
|
||||
@@ -933,7 +1142,8 @@ html, body, #root {
|
||||
.markdown-content h1 {
|
||||
font-size: 2.35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
letter-spacing: -0.03em;
|
||||
color: #F5F6F7;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
padding-bottom: 0.55em;
|
||||
@@ -942,6 +1152,7 @@ html, body, #root {
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.7rem;
|
||||
color: #EDEEF0;
|
||||
margin-top: 1.85em;
|
||||
padding-bottom: 0.4em;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
@@ -949,22 +1160,25 @@ html, body, #root {
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.3rem;
|
||||
color: #E4E5E8;
|
||||
}
|
||||
|
||||
.markdown-content h4 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
color: #D8DADE;
|
||||
}
|
||||
|
||||
.markdown-content h5 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: #CBCED3;
|
||||
}
|
||||
|
||||
.markdown-content h6 {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
color: #ABB0B8;
|
||||
}
|
||||
|
||||
/* Paragraphs with refined spacing */
|
||||
@@ -974,18 +1188,19 @@ html, body, #root {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Links with subtle interaction */
|
||||
/* Links with subtle persistent underline */
|
||||
.markdown-content a {
|
||||
color: #7CA9F7;
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(124, 169, 247, 0.3);
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
transition: all 0.2s ease;
|
||||
transition: color 0.2s ease, text-decoration-color 0.2s ease;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
color: #93B8F9;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(147, 184, 249, 0.7);
|
||||
text-decoration-thickness: 1.5px;
|
||||
}
|
||||
|
||||
@@ -1023,12 +1238,19 @@ html, body, #root {
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.markdown-content ol li::marker {
|
||||
.markdown-content li::marker {
|
||||
color: #7CA9F7;
|
||||
}
|
||||
|
||||
.markdown-content ol li::marker {
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-content ul li::marker {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
/* Fix ALL nested content inside list items - remove extra margins */
|
||||
.markdown-content li > p {
|
||||
margin-top: 0;
|
||||
@@ -1098,18 +1320,17 @@ html, body, #root {
|
||||
.markdown-content blockquote {
|
||||
margin: 1.65em 0;
|
||||
padding: 1.1em 1.4em;
|
||||
border-left: 5px solid #7CA9F7;
|
||||
background-color: rgba(124, 169, 247, 0.05);
|
||||
border-radius: 10px;
|
||||
color: #A8ACB0;
|
||||
border-left: 3px solid #7CA9F7;
|
||||
background-color: rgba(124, 169, 247, 0.04);
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: #B0B4B9;
|
||||
font-style: normal;
|
||||
line-height: 1.7;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.markdown-content blockquote p {
|
||||
margin-bottom: 0.5em;
|
||||
color: #A8ACB0;
|
||||
color: #B0B4B9;
|
||||
}
|
||||
|
||||
.markdown-content blockquote p:last-child {
|
||||
@@ -1118,13 +1339,13 @@ html, body, #root {
|
||||
|
||||
/* Inline code */
|
||||
.markdown-content code:not(pre code) {
|
||||
background-color: rgba(124, 169, 247, 0.12);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.22em 0.48em;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
border-radius: 5px;
|
||||
font-family: 'JetBrains Mono Variable', 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 0.88em;
|
||||
font-weight: 400;
|
||||
color: #F79E7E;
|
||||
color: #E0A88A;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@@ -1142,8 +1363,8 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono Variable', 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #E4E6EB;
|
||||
background: none;
|
||||
@@ -1257,12 +1478,12 @@ html, body, #root {
|
||||
background-color: rgba(124, 169, 247, 0.05);
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
/* Horizontal rule — gradient fade */
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 2.2em 0;
|
||||
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
margin: 2.5em 0;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
@@ -1335,6 +1556,41 @@ html, body, #root {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Search highlights — high specificity to override Tailwind reset */
|
||||
span.search-highlight {
|
||||
background-color: rgba(251, 191, 36, 0.3) !important;
|
||||
color: inherit !important;
|
||||
border-radius: 2px;
|
||||
padding: 1px 0;
|
||||
transition: background-color 0.15s ease, outline-color 0.15s ease;
|
||||
}
|
||||
|
||||
span.search-highlight.search-highlight-active {
|
||||
background-color: rgba(251, 191, 36, 0.6) !important;
|
||||
outline: 2px solid rgba(251, 191, 36, 0.7);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* OverlayScrollbars custom theme */
|
||||
.os-theme-dark {
|
||||
--os-handle-bg: rgba(255, 255, 255, 0.12);
|
||||
--os-handle-bg-hover: var(--color-accent);
|
||||
--os-handle-bg-active: var(--color-accent-hover);
|
||||
--os-size: 8px;
|
||||
--os-handle-border-radius: 4px;
|
||||
--os-padding-perpendicular: 2px;
|
||||
--os-padding-axis: 2px;
|
||||
--os-handle-interactive-area-offset: 4px;
|
||||
}
|
||||
|
||||
.os-theme-dark .os-scrollbar {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.os-theme-dark .os-scrollbar-handle {
|
||||
transition: background-color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
@@ -8,6 +8,9 @@ const host = process.env.TAURI_DEV_HOST;
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1600,
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
|
||||