mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d865b181e9 | ||
|
|
5e5bab3da9 | ||
|
|
ce2bf62d42 | ||
|
|
6da98c3a54 | ||
|
|
3598d06240 | ||
|
|
650c266945 | ||
|
|
05bbb9f86b | ||
|
|
77c8e05fa5 | ||
|
|
17f318dd75 | ||
|
|
fa5a46d5ac | ||
|
|
a4201acad9 | ||
|
|
0b5af04a6a | ||
|
|
2d6f909b0e | ||
|
|
8624257c1f | ||
|
|
b75f436aaa | ||
|
|
d81d8c28d2 | ||
|
|
559d09cad9 | ||
|
|
5ad582ec3e | ||
|
|
d02f896167 | ||
|
|
f34775ce53 | ||
|
|
5d6a55dd09 | ||
|
|
0e80e37d9d | ||
|
|
9a7e994224 | ||
|
|
3effacf816 | ||
|
|
1be10f9ae5 | ||
|
|
4c0b7d6e84 | ||
|
|
db04ea5854 | ||
|
|
567726022c | ||
|
|
1999a7716e | ||
|
|
16e071ae5e | ||
|
|
7ecf54ceb0 | ||
|
|
95e2f45f1f | ||
|
|
7fbbd60525 | ||
|
|
c715bb354c | ||
|
|
6525de7667 | ||
|
|
a2c1d0c11a | ||
|
|
e0d6315204 | ||
|
|
68ff7d076a | ||
|
|
67b57eae78 | ||
|
|
301c51ac84 | ||
|
|
904ffbdd5b | ||
|
|
ea383783ec | ||
|
|
e758a81c4c | ||
|
|
ea0f3f93bf | ||
|
|
188e73d7c1 | ||
|
|
8265e4ff88 | ||
|
|
8a8671b128 | ||
|
|
3646dc29c6 | ||
|
|
a999f598cc | ||
|
|
7a396f64a6 | ||
|
|
88e9a5290d | ||
|
|
fff15d8d3e | ||
|
|
c574d77c20 | ||
|
|
272215f9af | ||
|
|
0192590567 | ||
|
|
96df5652d6 | ||
|
|
a9e05d2c0f | ||
|
|
c982b3e90f | ||
|
|
cfaafc07b2 | ||
|
|
94ad99dc4d | ||
|
|
baf644d62b | ||
|
|
f9a5420ad2 | ||
|
|
f8361174f2 |
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/renderer/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Building MarkdownConverter with Tauri 2.x
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. **Node.js 18+** and npm
|
|
||||||
2. **Rust 1.75+** — install via [rustup](https://rustup.rs/)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Rust
|
|
||||||
winget install Rust.Rustup # Windows
|
|
||||||
# OR
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # macOS/Linux
|
|
||||||
|
|
||||||
# Set stable as default
|
|
||||||
rustup default stable
|
|
||||||
|
|
||||||
# Install Tauri CLI (alternative to npm package)
|
|
||||||
cargo install tauri-cli --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Run frontend dev server
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# In another terminal, run Tauri dev
|
|
||||||
npm run tauri:dev
|
|
||||||
|
|
||||||
# Or use Tauri CLI directly (if installed globally via cargo)
|
|
||||||
cargo tauri dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the frontend first
|
|
||||||
npm run build:frontend
|
|
||||||
|
|
||||||
# Then build Tauri app
|
|
||||||
npm run tauri:build
|
|
||||||
|
|
||||||
# Output:
|
|
||||||
# Windows: src-tauri/target/release/markdown-converter.exe
|
|
||||||
# Installer: dist/MarkdownConverter-Setup-4.3.0.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Rust not found
|
|
||||||
Ensure Rust is in PATH: `rustc --version`
|
|
||||||
|
|
||||||
### WebView2 not installed (Windows)
|
|
||||||
Download from https://developer.microsoft.com/en-us/microsoft-edge/webview2/
|
|
||||||
|
|
||||||
### pandoc not found
|
|
||||||
Pandoc must be installed and in PATH. Download from https://pandoc.org/installing.html
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src-tauri/
|
|
||||||
src/
|
|
||||||
main.rs # Binary entry point
|
|
||||||
lib.rs # Tauri app setup + command registration
|
|
||||||
commands/ # Rust commands (file, git, pdf, export, etc.)
|
|
||||||
menu.rs # Native menu
|
|
||||||
tray.rs # System tray
|
|
||||||
pdf_ops.rs # PDF processing
|
|
||||||
plugin_manager.rs
|
|
||||||
error.rs
|
|
||||||
Cargo.toml
|
|
||||||
tauri.conf.json
|
|
||||||
capabilities/default.json
|
|
||||||
icons/
|
|
||||||
src/
|
|
||||||
renderer.js # Main renderer (updated for Tauri invoke/listen)
|
|
||||||
tauri-commands.js # Tauri command bridge
|
|
||||||
index.html # Main HTML (copied to dist/index.html)
|
|
||||||
dist/
|
|
||||||
index.html # Built frontend output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Differences from Electron
|
|
||||||
|
|
||||||
| Aspect | Electron | Tauri |
|
|
||||||
|--------|----------|-------|
|
|
||||||
| IPC | ipcRenderer.invoke() | invoke() from @tauri-apps/api |
|
|
||||||
| Events | ipcRenderer.on() | listen() from @tauri-apps/api |
|
|
||||||
| File access | Node.js fs module | Rust std::fs or tauri-plugin-fs |
|
|
||||||
| Native menus | Electron Menu API | tauri menu API |
|
|
||||||
| Settings | electron-store | tauri-plugin-store |
|
|
||||||
| Binary size | ~150MB | ~10MB |
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
|||||||
|
# MarkdownConverter — React + shadcn/ui UI Redesign
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Design (awaiting user approval)
|
||||||
|
**Branch:** `react-electron`
|
||||||
|
**Author:** Brainstormed with user via superpowers:brainstorming
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the legacy vanilla JS renderer (`src/renderer.js` is 213 KB; `src/styles.css` is 74 KB) with a modern React 19 + Vite + TypeScript + shadcn/ui renderer that achieves visual feature parity while delivering a Polished + Glassy (Raycast/Arc-style) aesthetic. The Electron main process, preload bridge, and IPC contracts stay unchanged. Work proceeds via vertical slices — each PR ships a demoable feature.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Replace every render-time feature of the legacy renderer with an idiomatic React + shadcn/ui implementation.
|
||||||
|
2. Adopt the "Polished + Glassy" visual language (subtle shadows, 8–12 px radii, gradient accents, gentle depth) on top of the existing ConcreteInfo brand tokens and design system.
|
||||||
|
3. Use a single component foundation (shadcn/ui) and a single motion library (Motion / Framer Motion) to keep the dependency surface narrow and the design coherent.
|
||||||
|
4. Keep the main process, preload, and IPC contracts untouched. The renderer is the only surface being rewritten.
|
||||||
|
5. Keep the app runnable at every commit. No "big bang" merge.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Migrating the main process to TypeScript (out of scope for this spec).
|
||||||
|
- Adding new features the legacy renderer does not have (plugin system, AI features, etc. — those are future specs).
|
||||||
|
- Replacing Pandoc/FFmpeg/ImageMagick orchestration.
|
||||||
|
- Changing the packaging pipeline (electron-builder config stays).
|
||||||
|
- Replacing the CodeMirror 6 editor — it is the right tool and is already wired up.
|
||||||
|
|
||||||
|
## Decisions Locked During Brainstorming
|
||||||
|
|
||||||
|
| Decision | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Scope | Full feature parity with legacy renderer | User-selected option. Renderer is one cohesive surface; splitting it across specs would force premature contracts. |
|
||||||
|
| Visual style | **B — Polished + Glassy** (Raycast/Arc aesthetic) | "Fancy" comes from elevation, gradient accents, and material depth — not over-designed visuals. |
|
||||||
|
| Layout | **2 — IDE-style** (equal editor/preview, draggable divider, collapsible sidebar) | Power-user markdown apps converge on this. Draggable divider + keyboard reset. |
|
||||||
|
| Modal patterns | **A (Centered Dialog), B (Right Side-Sheet), D (Toasts)** | No command palette. |
|
||||||
|
| Command palette | **No** — full menus, no ⌘K | Power users can use the OS-level launcher. App stays focused on writing. |
|
||||||
|
| Animation | **Motion (Framer Motion)** | Best for layout transitions, modal/drawer enter/exit, drag. Sparingly used. |
|
||||||
|
| Component library | **shadcn/ui** | Tailwind + Radix primitives, copy-paste ownership, perfect fit for the "glassy" aesthetic and the HSL-CSS-variable foundation that is already in place. |
|
||||||
|
| Implementation strategy | **Vertical slices** — one feature end-to-end per PR | App stays runnable. Each PR is reviewable. |
|
||||||
|
|
||||||
|
## Defaults (Used Unless Overridden Later)
|
||||||
|
|
||||||
|
| Item | Default | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| State management | Zustand (already in deps) + Immer for nested patches | Already in `package.json`; perfect for editor state. |
|
||||||
|
| Theming | shadcn `next-themes`, dark default + light, system-aware | shadcn canonical pattern. Brand colors already in CSS vars. |
|
||||||
|
| Icons | `lucide-react` (already in deps) | 1000+ tree-shakable icons. |
|
||||||
|
| Forms | `react-hook-form` + `zod` | Standard for shadcn forms. |
|
||||||
|
| Drag/drop (sortable lists) | `@dnd-kit/core` | De facto React drag lib (file tree reordering, plugin list reorder). |
|
||||||
|
| Pane resize (split layout) | `react-resizable-panels` | Canonical React lib for resizable pane groups; handles drag, arrow keys, snap, persisted sizes. |
|
||||||
|
| Testing | Vitest + RTL + Playwright (E2E + visual regression) | Standard for React + Electron. |
|
||||||
|
| TypeScript | Strict mode (already wired) | Already in deps and `vite.renderer.config.ts`. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The Electron app keeps its existing process model. Only the renderer is rewritten.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Electron Main (UNCHANGED) │
|
||||||
|
│ - BrowserWindow, IPC handlers, file/fs ops │
|
||||||
|
│ - Pandoc, FFmpeg, ImageMagick orchestration │
|
||||||
|
└────────────┬─────────────────────────────────────────────┘
|
||||||
|
│ contextBridge (UNCHANGED preload.js)
|
||||||
|
┌────────────▼─────────────────────────────────────────────┐
|
||||||
|
│ React Renderer (REWRITTEN) │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AppShell (layout) │ │
|
||||||
|
│ │ ├─ MenuBar (native) │ │
|
||||||
|
│ │ ├─ AppHeader (logo, breadcrumbs, theme toggle) │ │
|
||||||
|
│ │ ├─ TabBar (open files) │ │
|
||||||
|
│ │ ├─ Toolbar (formatting) │ │
|
||||||
|
│ │ ├─ ResizablePaneGroup (sidebar | editor | preview) │ │
|
||||||
|
│ │ │ ├─ Sidebar (file tree, outline) │ │
|
||||||
|
│ │ │ ├─ EditorPane (CodeMirror 6) │ │
|
||||||
|
│ │ │ └─ PreviewPane (marked + KaTeX + Mermaid) │ │
|
||||||
|
│ │ ├─ StatusBar (word count, encoding, cursor pos) │ │
|
||||||
|
│ │ └─ ModalLayer (Dialog, SideSheet, Toaster) │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Feature Modules (each owns UI + state slice) │ │
|
||||||
|
│ │ ├─ editor/ CodeMirror wrapper, syntax, themes │ │
|
||||||
|
│ │ ├─ preview/ markdown→html, KaTeX, Mermaid │ │
|
||||||
|
│ │ ├─ tabs/ open files, dirty state │ │
|
||||||
|
│ │ ├─ sidebar/ file tree, outline, search results │ │
|
||||||
|
│ │ ├─ modals/ export, settings, about, etc. │ │
|
||||||
|
│ │ ├─ tools/ zen, repl, ascii-gen, table-gen │ │
|
||||||
|
│ │ └─ export/ pdf, docx, html, image batch │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Shared Infrastructure │ │
|
||||||
|
│ │ ├─ stores/ Zustand slices per feature │ │
|
||||||
|
│ │ ├─ hooks/ useFile, useEditor, useTheme, etc. │ │
|
||||||
|
│ │ ├─ lib/ cn, ipc, formatters, validators │ │
|
||||||
|
│ │ ├─ ui/ shadcn primitives: button, dialog… │ │
|
||||||
|
│ │ └─ types/ shared TS types │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architectural Rules
|
||||||
|
|
||||||
|
- **Shell components** (`AppHeader`, `TabBar`, `StatusBar`) subscribe only to `useAppStore`.
|
||||||
|
- **Feature components** subscribe to their own feature's store.
|
||||||
|
- **Cross-feature access** goes through hooks, not direct store imports (e.g., `useFileTree()` wraps `useFileStore`).
|
||||||
|
- **Feature modules are self-contained** — each owns its components, its Zustand slice, and its types. Safe to develop in parallel later.
|
||||||
|
- **No direct `window.electronAPI` calls** from feature code. All IPC goes through `lib/ipc.ts` for type safety and error normalization.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
```
|
||||||
|
stores/
|
||||||
|
├─ useAppStore // global UI: theme, sidebar, pane sizes, modals open
|
||||||
|
├─ useFileStore // file system: tree, open files, active tab
|
||||||
|
├─ useEditorStore // editor: per-file content, cursor, selection, dirty
|
||||||
|
├─ usePreviewStore // preview: scroll sync, zoom, theme
|
||||||
|
├─ useSettingsStore // user prefs (persisted to electron-store)
|
||||||
|
└─ useCommandStore // menu actions registry, recent actions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why slices instead of one mega-store?** Editor content can be megabytes. Keeping it in its own slice lets React avoid re-rendering the preview when only the editor changes — components subscribe with selectors.
|
||||||
|
|
||||||
|
**Persistent state** (auto-saved to electron-store via Zustand `persist` middleware): theme, sidebar visibility, last open files, pane divider positions, settings.
|
||||||
|
|
||||||
|
**Ephemeral state** (lost on quit): active modal, hover states, search query, current file content (saved to disk on idle).
|
||||||
|
|
||||||
|
## Visual Design System
|
||||||
|
|
||||||
|
### Color Tokens
|
||||||
|
|
||||||
|
The existing `globals.css` HSL variables stay. Additions for the "glassy" aesthetic:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
```
|
||||||
|
|
||||||
|
### shadcn Components
|
||||||
|
|
||||||
|
Install via `npx shadcn@latest add`:
|
||||||
|
|
||||||
|
**Primitives** (need all of these): `button`, `dialog`, `sheet`, `popover`, `tooltip`, `select`, `dropdown-menu`, `tabs`, `separator`, `scroll-area`, `toggle`, `switch`, `slider`, `input`, `textarea`, `label`, `form`, `skeleton`, `sonner`, `command`.
|
||||||
|
|
||||||
|
**Composite** (custom-built on top of primitives): `file-tree`, `pane-group`, `divider`, `status-bar`, `menu-bar`, `toast`.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Body: Plus Jakarta Sans 15 px / line-height 1.6
|
||||||
|
- Code: JetBrains Mono 13.5 px
|
||||||
|
- Display: Barlow Condensed 700/800 — used sparingly (page titles, big metrics)
|
||||||
|
- Headings: Plus Jakarta Sans 600/700 with tight letter-spacing
|
||||||
|
- Labels (`.label`): Plus Jakarta Sans 500, 12 px, uppercase, 0.05 em letter-spacing
|
||||||
|
|
||||||
|
### Motion Choreography
|
||||||
|
|
||||||
|
| Element | Motion | Duration | Easing |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Modal (Dialog A) | Scale 0.96→1, opacity 0→1 | 200 ms | `ease-out` |
|
||||||
|
| Side sheet (B) | TranslateX 100%→0, opacity 0→1 | 300 ms | `cubic-bezier(0.16, 1, 0.3, 1)` |
|
||||||
|
| Toast | TranslateY 100%→0, opacity | 250 ms | spring(stiffness: 300, damping: 30) |
|
||||||
|
| Sidebar toggle | Width 264 px↔72 px, content fade | 250 ms | `ease-in-out` |
|
||||||
|
| Divider drag | Live width update | 0 ms | none (instant) |
|
||||||
|
| Theme switch | CSS variables, opacity overlay | 300 ms | `ease-in-out` |
|
||||||
|
| Tab switch | Underline slide | 200 ms | `ease-out` |
|
||||||
|
| Hover (buttons) | `bg` + `shadow` shift | 150 ms | `ease-out` |
|
||||||
|
| Focus ring | Outline grow | 100 ms | `ease-out` |
|
||||||
|
|
||||||
|
**Rules:** Every motion has a purpose. No gratuitous animation. Respects `prefers-reduced-motion` (Motion handles this automatically).
|
||||||
|
|
||||||
|
### Empty / Loading / Error States
|
||||||
|
|
||||||
|
Every async surface ships all three. Sonner toasts for ephemeral feedback. Skeleton screens for content areas. Empty-state components with icon + message + primary CTA.
|
||||||
|
|
||||||
|
## Modal/Overlay Patterns
|
||||||
|
|
||||||
|
| Use case | Pattern | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Export (PDF/DOCX/HTML) | A — Centered Dialog | Focused decision, ~3–5 fields, "do one thing" |
|
||||||
|
| Settings | B — Right Side-Sheet | Long form, tabbed sections, stays open while editing |
|
||||||
|
| Find/Replace | Inline toolbar (not modal) | Always-visible utility |
|
||||||
|
| About | A — Centered Dialog | Tiny info dump |
|
||||||
|
| Confirm destructive (delete, close unsaved) | A — Centered Dialog | Forces attention |
|
||||||
|
| File save error | D — Toast | Non-blocking info |
|
||||||
|
| Pandoc/FFmpeg progress | D — Toast → sticky on >3 s | Background work feedback |
|
||||||
|
| Plugin manager | B — Right Side-Sheet | Lists + per-item actions |
|
||||||
|
| ASCII/Table generators | A — Centered Dialog | Tool-style focused input → output |
|
||||||
|
| Print preview | A — Centered Dialog | Full-screen-ish modal overlay |
|
||||||
|
| Welcome (first launch) | A — Centered Dialog | One-time onboarding |
|
||||||
|
| Word export | A — Centered Dialog | Template selection |
|
||||||
|
| Zen mode | Full-viewport toggle (no modal) | Replaces the editor entirely |
|
||||||
|
| REPL | Bottom-pinned panel (split-pane) | Persistent terminal-like UI |
|
||||||
|
| Quick file open | B — Right Side-Sheet | File tree, search, recent |
|
||||||
|
|
||||||
|
### Dialog A Anatomy (Export as example)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Backdrop (rgba(13,11,9,0.45) + backdrop-blur 4px) ─────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌──── Modal (max-w-md, rounded-2xl, shadow-lg) ─────┐ │
|
||||||
|
│ │ ┌── Header ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Icon] Export as PDF [×] │ │ │
|
||||||
|
│ │ │ Choose format options │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Body ──────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Format: [Letter] [A4] [Legal] │ │ │
|
||||||
|
│ │ │ Margins: ────●──── │ │ │
|
||||||
|
│ │ │ ☐ Include table of contents │ │ │
|
||||||
|
│ │ │ ☐ Embed fonts │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Footer ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Cancel] [Export →] │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Side-Sheet B Anatomy (Settings as example)
|
||||||
|
|
||||||
|
Slides in from right, ~50 % width (max 560 px). Backdrop is `rgba(13,11,9,0.2)` with `backdrop-blur(2 px)` (lighter than dialog — the sheet is the focus). Tab navigation in the sheet header (Editor / Theme / Export / Plugins / About).
|
||||||
|
|
||||||
|
### Toast D Anatomy (Sonner)
|
||||||
|
|
||||||
|
Stacked bottom-right, max 3 visible. Glass background. Color-coded 3 px left border — success `#1a7a56`, error `#ef4444`, info `#0ea5e9`, warning `#eab308`. Icon + title + description. Optional action button. Auto-dismiss 4 s for success, sticky for error.
|
||||||
|
|
||||||
|
## Data Flow (Editor → Preview Pipeline)
|
||||||
|
|
||||||
|
```
|
||||||
|
User types
|
||||||
|
↓
|
||||||
|
CodeMirror onChange
|
||||||
|
↓
|
||||||
|
useEditorStore.updateContent(tabId, content) ← debounced 50 ms
|
||||||
|
↓
|
||||||
|
┌─ Persist to electron-store on idle (1 s) ─┐
|
||||||
|
│ │
|
||||||
|
└─ Publish to usePreviewStore (subscribed) ─┘
|
||||||
|
↓
|
||||||
|
marked(content) → sanitized HTML
|
||||||
|
↓
|
||||||
|
PreviewPane renders HTML
|
||||||
|
KaTeX post-processes $...$
|
||||||
|
Mermaid post-processes ```mermaid
|
||||||
|
Highlight.js post-processes ```lang
|
||||||
|
↓
|
||||||
|
useEffect: scroll-sync editor cursor → preview position
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bidirectional scroll sync:** editor scroll → preview (primary), preview click → editor cursor (secondary). Throttled to 60 fps via `requestAnimationFrame`.
|
||||||
|
|
||||||
|
## IPC Contract
|
||||||
|
|
||||||
|
The preload bridge stays unchanged. A new `src/renderer/lib/ipc.ts` wraps every channel with TypeScript types and normalizes errors to a discriminated union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult>>,
|
||||||
|
read: (path: string): Promise<IpcResult<string>>,
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void>>,
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[]>>,
|
||||||
|
onChange: (cb: (path: string) => void) => () => void,
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult>>,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string>>,
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void>>,
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void>>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Layered defense — no single failure can crash the app:
|
||||||
|
|
||||||
|
1. **IPC errors** → caught in `lib/ipc.ts` wrapper, returned as `IpcResult<T>` discriminated union. Components show toast on error.
|
||||||
|
2. **Component errors** → React error boundary per feature area (one for editor, one for preview, one for modals). On error: show inline error UI with "Reload this panel" + "Copy details" actions.
|
||||||
|
3. **Async operations** (export, file ops) → loading state on button + toast on completion. Long ops (>2 s) get a sticky toast with cancel.
|
||||||
|
4. **Validation errors** (settings, export options) → inline form errors via `react-hook-form` + `zod`. shadcn `Form` component for consistent error display.
|
||||||
|
5. **Pandoc/FFmpeg missing** → detected at startup, banner + disable export menu items. Don't surprise-fail at export time.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit (Vitest):** stores, hooks, lib utilities, formatters, validators
|
||||||
|
- **Component (Vitest + RTL):** shadcn wrappers, dialog interactions, sidebar toggle, divider drag, theme toggle
|
||||||
|
- **Integration (Vitest + RTL):** editor ↔ preview flow, file open → tab add → content display, settings change persists
|
||||||
|
- **E2E (Playwright):** app launches, file opens, markdown renders, export to PDF works
|
||||||
|
- **Visual regression (Playwright snapshots):** locked-in screenshots for header, sidebar, dialogs, toasts — catch accidental style drift
|
||||||
|
- **Target coverage:** stores/hooks/lib ≥ 90 %, components ≥ 75 %, E2E covers all critical paths
|
||||||
|
|
||||||
|
## Accessibility (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
Baked in via shadcn + Radix:
|
||||||
|
|
||||||
|
- All dialogs focus-trap, Esc to close, return focus to trigger
|
||||||
|
- All interactive elements keyboard-reachable
|
||||||
|
- Visible focus rings (2 px brand ring)
|
||||||
|
- 4.5:1 contrast minimum (already met by brand colors)
|
||||||
|
- `aria-label` on icon buttons, `role="alert"` on toasts
|
||||||
|
- Respects `prefers-reduced-motion` (Motion handles this automatically)
|
||||||
|
|
||||||
|
## Implementation Phases (Vertical Slices)
|
||||||
|
|
||||||
|
Each phase is a shippable PR that keeps the app runnable. The legacy `renderer.js` is gradually replaced; until each phase is complete, the old renderer code still runs the missing parts.
|
||||||
|
|
||||||
|
| # | Phase | Output | Acceptance |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Foundation** | shadcn installed; `next-themes`, `motion`, `react-hook-form`, `zod`, `@dnd-kit/core`, `react-resizable-panels`, `sonner` installed; design tokens finalized; `lib/utils.ts` (`cn`), `lib/ipc.ts` typed wrappers, `lib/motion.ts` preset transitions, `App.tsx` shell skeleton renders | `npm run build` succeeds; dev server shows the new shell with theme toggle working |
|
||||||
|
| 2 | **App shell + layout** | `AppHeader`, `TabBar`, `Toolbar`, `Breadcrumb`, `StatusBar`, `ResizablePaneGroup` with sidebar toggle and draggable divider. Empty editor/preview panes. | Resize divider with mouse and arrow keys; sidebar collapses; pane sizes persist. |
|
||||||
|
| 3 | **Editor pane** | CodeMirror 6 wrapped, dark/light themes wired to shadcn theme, syntax highlighting, line numbers, search, autocomplete | Open `.md` file → renders in editor; can edit and save. |
|
||||||
|
| 4 | **Preview pane** | marked + DOMPurify + KaTeX + Mermaid + highlight.js. Bidirectional scroll sync with editor. | Open file → preview renders, follows cursor. |
|
||||||
|
| 5 | **File tree + tabs** | Sidebar file tree (read directory on demand, lazy-expand children only on click). Tabs for open files. Dirty state indicator. File content is read from disk only when the tab is activated. | Open folder → root populates; click folder → children load; click file → opens in tab; close tab reverts dirty. |
|
||||||
|
| 6 | **Native menus + toolbar** | Replace the legacy `CommandPalette` with full menus (File / Edit / View / Insert / Format / Tools / Help) bound to keyboard shortcuts. Toolbar buttons. | All menu items invoke the right action; shortcuts work; toolbar reflects active state. |
|
||||||
|
| 7 | **Modals** | Export dialog (PDF/DOCX/HTML/batch), Settings side-sheet (Editor/Theme/Export/Plugins/About tabs), About dialog, Confirm-destructive dialog | All dialogs and sheet render with proper motion; settings persist; export works end-to-end. |
|
||||||
|
| 8 | **Toasts** | Sonner wired into all async operations (save, export, errors, tool missing) | All operations give appropriate feedback; no silent failures. |
|
||||||
|
| 9 | **Advanced tools** | Zen mode (full-viewport toggle), REPL (bottom-pinned panel), ASCII generator, Table generator, Word export template picker, Print preview | Each tool is feature-complete vs. legacy version. |
|
||||||
|
| 10 | **Polish + delete legacy** | Visual regression snapshots locked; remove `renderer.js` and old `styles*.css` references from build; `npm run build:linux` produces a working installer | Final PR ships an installer with no legacy code paths. |
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| shadcn CLI requires Vite (not vanilla Electron renderer) | Vite is already configured for the renderer in `vite.renderer.config.ts`; the CLI works against the same project. |
|
||||||
|
| shadcn CLI assumes Tailwind is at project root; we have it in renderer/ only | Point the CLI at `src/renderer/components.json` and set `tailwind.css` to the right path. |
|
||||||
|
| Marked + KaTeX + Mermaid + highlight.js together is heavy | Lazy-load Mermaid (only when `mermaid` code block encountered). KaTeX is loaded once, cached. |
|
||||||
|
| Editor content state can be megabytes → re-render storms | Editor content lives in its own slice; preview subscribes to a derived/cached HTML string; components use `useShallow` / selector subscriptions. |
|
||||||
|
| Bidirectional scroll sync loops | `useEffect` debounce + ignore if cursor/selection is the sync source. |
|
||||||
|
| CodeMirror 6 doesn't ship a Tailwind theme by default | Use `@codemirror/theme-one-dark` for dark; build a custom theme that pulls from CSS variables for light. |
|
||||||
|
| The legacy `renderer.js` and `styles*.css` are referenced from `index.html` (now `src/renderer/index.html`) | Phase 10 deletes the references; until then, both run side-by-side and the new React app sits in a known root div. |
|
||||||
|
| Electron `nodeIntegration` is off; we use contextBridge | Already the case. Document the contract clearly in `lib/ipc.ts`. |
|
||||||
|
|
||||||
|
## Open Questions (to confirm before implementation)
|
||||||
|
|
||||||
|
None. All major decisions are locked. Defaults will be used unless the user overrides them when reviewing the implementation plan.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `src/renderer.js` (213 KB) — legacy renderer being replaced
|
||||||
|
- `src/styles.css` (74 KB), `src/styles-modern.css` (71 KB), `src/styles-concreteinfo.css` (21 KB) — legacy styles being replaced
|
||||||
|
- `src/renderer/styles/globals.css` — already shadcn-compatible (HSL CSS variables)
|
||||||
|
- `src/renderer/App.tsx` — empty skeleton that references components we will build
|
||||||
|
- `tailwind.config.js` — brand colors and fonts already defined
|
||||||
|
- `vite.renderer.config.ts` — Vite + React plugin already configured
|
||||||
|
- `package.json` — CodeMirror 6, TanStack Table, lucide-react, cva, clsx, tailwind-merge, tailwindcss-animate already installed
|
||||||
@@ -75,6 +75,7 @@ module.exports = [
|
|||||||
jest: 'readonly',
|
jest: 'readonly',
|
||||||
describe: 'readonly',
|
describe: 'readonly',
|
||||||
test: 'readonly',
|
test: 'readonly',
|
||||||
|
it: 'readonly',
|
||||||
expect: 'readonly',
|
expect: 'readonly',
|
||||||
beforeEach: 'readonly',
|
beforeEach: 'readonly',
|
||||||
afterEach: 'readonly',
|
afterEach: 'readonly',
|
||||||
|
|||||||
Generated
+4154
-612
File diff suppressed because it is too large
Load Diff
+56
-14
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.3.0",
|
"version": "4.4.2",
|
||||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"dev": "vite",
|
|
||||||
"build:frontend": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:renderer": "vitest run",
|
||||||
|
"test:renderer:watch": "vitest",
|
||||||
|
"test:renderer:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src tests",
|
"lint": "eslint src tests",
|
||||||
"lint:fix": "eslint src tests --fix",
|
"lint:fix": "eslint src tests --fix",
|
||||||
"format": "prettier --write src tests",
|
"format": "prettier --write src tests",
|
||||||
@@ -27,11 +27,7 @@
|
|||||||
"dist": "electron-builder --publish=never",
|
"dist": "electron-builder --publish=never",
|
||||||
"dist:all": "electron-builder -mwl",
|
"dist:all": "electron-builder -mwl",
|
||||||
"download-tools": "node scripts/download-tools.js",
|
"download-tools": "node scripts/download-tools.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js",
|
"generate-icons": "node scripts/generate-icons.js"
|
||||||
"tauri": "tauri",
|
|
||||||
"tauri:dev": "tauri dev",
|
|
||||||
"tauri:build": "tauri build",
|
|
||||||
"tauri:debug": "tauri build --debug"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"markdown",
|
"markdown",
|
||||||
@@ -50,7 +46,19 @@
|
|||||||
"url": "https://github.com/amitwh/markdown-converter"
|
"url": "https://github.com/amitwh/markdown-converter"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"@vitest/ui": "^4.1.8",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
@@ -59,8 +67,17 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -77,25 +94,44 @@
|
|||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.16",
|
"@codemirror/view": "^6.39.16",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
"docx4js": "^3.3.0",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
|
"immer": "^11.1.8",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-footnote": "^1.4.0",
|
"marked-footnote": "^1.4.0",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
|
"motion": "^12.40.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.5.207",
|
"pdfjs-dist": "^5.5.207",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pizzip": "^3.2.0",
|
"pizzip": "^3.2.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
|
"react-resizable-panels": "^4.11.2",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"tslib": "^2.8.1"
|
"sonner": "^2.0.7",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -177,7 +213,10 @@
|
|||||||
"verifyUpdateCodeSignature": false,
|
"verifyUpdateCodeSignature": false,
|
||||||
"signAndEditExecutable": false,
|
"signAndEditExecutable": false,
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
{ "from": "bin/win32/pandoc.exe", "to": "bin/pandoc.exe" }
|
{
|
||||||
|
"from": "bin/win32/pandoc.exe",
|
||||||
|
"to": "bin/pandoc.exe"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
@@ -205,7 +244,10 @@
|
|||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
{ "from": "bin/linux/pandoc", "to": "bin/pandoc" }
|
{
|
||||||
|
"from": "bin/linux/pandoc",
|
||||||
|
"to": "bin/pandoc"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"deb": {
|
"deb": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[build]
|
|
||||||
rustflags = ["-C", "target-cpu=native"]
|
|
||||||
|
|
||||||
[target.x86_64-pc-windows-msvc]
|
|
||||||
rustflags = ["-C", "target-feature=+crt-static"]
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "markdown-converter"
|
|
||||||
version = "4.3.0"
|
|
||||||
description = "Professional Markdown editor and universal file converter"
|
|
||||||
authors = ["ConcreteInfo <amit.wh@gmail.com>"]
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "markdown_converter_lib"
|
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "2", features = [] }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tauri = { version = "2", features = ["devtools", "protocol-asset"] }
|
|
||||||
tauri-plugin-shell = "2"
|
|
||||||
tauri-plugin-dialog = "2"
|
|
||||||
tauri-plugin-fs = "2"
|
|
||||||
tauri-plugin-process = "2"
|
|
||||||
tauri-plugin-clipboard-manager = "2"
|
|
||||||
tauri-plugin-notification = "2"
|
|
||||||
tauri-plugin-log = { version = "2", features = ["colored"] }
|
|
||||||
tauri-plugin-store = "2"
|
|
||||||
tauri-plugin-os = "2"
|
|
||||||
tauri-plugin-opener = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
git2 = "0.19"
|
|
||||||
log = "0.4"
|
|
||||||
walkdir = "2"
|
|
||||||
dirs = "5"
|
|
||||||
chrono = "0.4"
|
|
||||||
thiserror = "1"
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
winreg = "0.52"
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
panic = "abort"
|
|
||||||
codegen-units = 1
|
|
||||||
lto = true
|
|
||||||
opt-level = "s"
|
|
||||||
strip = true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2/capabilities",
|
|
||||||
"identifier": "default",
|
|
||||||
"description": "MarkdownConverter capabilities",
|
|
||||||
"windows": ["main"],
|
|
||||||
"permissions": [
|
|
||||||
"core:default",
|
|
||||||
"core:event:default",
|
|
||||||
"core:window:default",
|
|
||||||
"core:window:allow-create",
|
|
||||||
"core:window:allow-close",
|
|
||||||
"core:window:allow-set-focus",
|
|
||||||
"core:window:allow-show",
|
|
||||||
"core:window:allow-hide",
|
|
||||||
"core:window:allow-minimize",
|
|
||||||
"core:window:allow-maximize",
|
|
||||||
"core:window:allow-unmaximize",
|
|
||||||
"core:window:allow-set-title",
|
|
||||||
"core:webview:default",
|
|
||||||
"core:webview:allow-create-webview-window",
|
|
||||||
"shell:allow-open",
|
|
||||||
"shell:allow-execute",
|
|
||||||
"dialog:allow-open",
|
|
||||||
"dialog:allow-save",
|
|
||||||
"dialog:allow-message",
|
|
||||||
"dialog:allow-ask",
|
|
||||||
"dialog:allow-confirm",
|
|
||||||
"fs:allow-read",
|
|
||||||
"fs:allow-write",
|
|
||||||
"fs:allow-exists",
|
|
||||||
"fs:allow-mkdir",
|
|
||||||
"fs:allow-remove",
|
|
||||||
"fs:allow-rename",
|
|
||||||
"fs:allow-copy-file",
|
|
||||||
"fs:default",
|
|
||||||
"fs:allow-read-dir",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"fs:allow-write-file",
|
|
||||||
"process:allow-exit",
|
|
||||||
"process:allow-restart",
|
|
||||||
"clipboard-manager:allow-read",
|
|
||||||
"clipboard-manager:allow-write",
|
|
||||||
"notification:default",
|
|
||||||
"os:default",
|
|
||||||
"opener:default",
|
|
||||||
"store:default",
|
|
||||||
"log:default"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "writing-studio",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Goal tracking, manuscript panels, snapshots, and sprint engine for writers",
|
|
||||||
"author": "ConcreteInfo",
|
|
||||||
"builtin": true
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use tauri::CommandResult;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_app_version() -> String {
|
|
||||||
env!("CARGO_PKG_VERSION").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_recent_files() -> CommandResult<Vec<String>, AppError> {
|
|
||||||
let config_dir = dirs::config_dir()
|
|
||||||
.ok_or_else(|| AppError::Config("Cannot find config directory".to_string()))?;
|
|
||||||
let recent_path = config_dir.join("markdown-converter").join("recent-files.json");
|
|
||||||
if recent_path.exists() {
|
|
||||||
let content = fs::read_to_string(&recent_path).map_err(|e| AppError::FileRead(e.to_string()))?;
|
|
||||||
let files: Vec<String> = serde_json::from_str(&content).unwrap_or_default();
|
|
||||||
Ok(files)
|
|
||||||
} else {
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_recent_files(files: Vec<String>) -> CommandResult<(), AppError> {
|
|
||||||
let config_dir = dirs::config_dir()
|
|
||||||
.ok_or_else(|| AppError::Config("Cannot find config directory".to_string()))?;
|
|
||||||
let app_dir = config_dir.join("markdown-converter");
|
|
||||||
fs::create_dir_all(&app_dir).map_err(|e| AppError::FileWrite(e.to_string()))?;
|
|
||||||
let recent_path = app_dir.join("recent-files.json");
|
|
||||||
let content = serde_json::to_string_pretty(&files).map_err(|e| AppError::Config(e.to_string()))?;
|
|
||||||
fs::write(&recent_path, content).map_err(|e| AppError::FileWrite(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
use tauri_plugin_dialog::DialogExt;
|
|
||||||
use tauri::CommandResult;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn open_file_dialog(app: tauri::AppHandle) -> CommandResult<Option<String>, AppError> {
|
|
||||||
let file = app.dialog()
|
|
||||||
.file()
|
|
||||||
.add_filter("Markdown", &["md", "markdown"])
|
|
||||||
.add_filter("All Files", &["*"]);
|
|
||||||
let result = file.blocking_pick_file();
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn save_file_dialog(app: tauri::AppHandle, default_name: Option<String>) -> CommandResult<Option<String>, AppError> {
|
|
||||||
let file = app.dialog()
|
|
||||||
.file()
|
|
||||||
.set_file_name(default_name.unwrap_or_else(|| "untitled.md".to_string()));
|
|
||||||
let result = file.blocking_save_file();
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn select_folder_dialog(app: tauri::AppHandle) -> CommandResult<Option<String>, AppError> {
|
|
||||||
let folder = app.dialog().file().blocking_pick_folder();
|
|
||||||
Ok(folder.map(|p| p.to_string()))
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
use tauri::CommandResult;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn export_markdown(
|
|
||||||
input_path: String,
|
|
||||||
output_path: String,
|
|
||||||
format: String,
|
|
||||||
options: ExportOptions,
|
|
||||||
) -> CommandResult<String, AppError> {
|
|
||||||
let mut args = vec![input_path.clone(), "-o".to_string(), output_path.clone()];
|
|
||||||
|
|
||||||
match format.as_str() {
|
|
||||||
"pdf" => {
|
|
||||||
args.push("--pdf-engine=xelatex".to_string());
|
|
||||||
if options.standalone.unwrap_or(false) {
|
|
||||||
args.push("-s".to_string());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"docx" => {
|
|
||||||
args.push("--to=docx".to_string());
|
|
||||||
if options.standalone.unwrap_or(false) {
|
|
||||||
args.push("-s".to_string());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"html" => {
|
|
||||||
args.push("--to=html".to_string());
|
|
||||||
if options.standalone.unwrap_or(false) {
|
|
||||||
args.push("-s".to_string());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
args.push(format!("--to={}", format));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = Command::new("pandoc")
|
|
||||||
.args(&args)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| AppError::Export(e.to_string()))?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(AppError::Export(stderr.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct ExportOptions {
|
|
||||||
pub standalone: Option<bool>,
|
|
||||||
pub template: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn check_pandoc_available() -> CommandResult<bool, AppError> {
|
|
||||||
let output = Command::new("pandoc")
|
|
||||||
.arg("--version")
|
|
||||||
.output();
|
|
||||||
Ok(output.is_ok())
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use tauri::CommandResult;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn read_file(path: String) -> CommandResult<String, AppError> {
|
|
||||||
fs::read_to_string(&path).map_err(|e| AppError::FileRead(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn write_file(path: String, content: String) -> CommandResult<(), AppError> {
|
|
||||||
if let Some(parent) = Path::new(&path).parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::FileWrite(e.to_string()))?;
|
|
||||||
}
|
|
||||||
fs::write(&path, content).map_err(|e| AppError::FileWrite(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_file(path: String) -> CommandResult<(), AppError> {
|
|
||||||
fs::remove_file(&path).map_err(|e| AppError::FileDelete(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn path_exists(path: String) -> bool {
|
|
||||||
Path::new(&path).exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn is_directory(path: String) -> bool {
|
|
||||||
Path::new(&path).is_dir()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn list_directory(path: String) -> CommandResult<Vec<FileEntry>, AppError> {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
for entry in walkdir::WalkDir::new(&path).max_depth(1).into_iter().filter_map(|e| e.ok()) {
|
|
||||||
let p = entry.path();
|
|
||||||
if p == Path::new(&path) { continue; }
|
|
||||||
entries.push(FileEntry {
|
|
||||||
name: p.file_name().unwrap_or_default().to_string_lossy().to_string(),
|
|
||||||
path: p.to_string_lossy().to_string(),
|
|
||||||
is_dir: p.is_dir(),
|
|
||||||
is_file: p.is_file(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn ensure_directory(path: String) -> CommandResult<(), AppError> {
|
|
||||||
fs::create_dir_all(&path).map_err(|e| AppError::FileWrite(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn copy_path(source: String, destination: String) -> CommandResult<(), AppError> {
|
|
||||||
fs::copy(&source, &destination).map_err(|e| AppError::FileCopy(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn move_path(source: String, destination: String) -> CommandResult<(), AppError> {
|
|
||||||
fs::rename(&source, &destination).map_err(|e| AppError::FileMove(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct FileEntry {
|
|
||||||
pub name: String,
|
|
||||||
pub path: String,
|
|
||||||
pub is_dir: bool,
|
|
||||||
pub is_file: bool,
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
use git2::{Repository, Status, DiffOptions};
|
|
||||||
use tauri::CommandResult;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn git_status(repo_path: String) -> CommandResult<Vec<GitStatusEntry>, AppError> {
|
|
||||||
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
|
|
||||||
let statuses = repo.statuses(None).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
for entry in statuses.iter() {
|
|
||||||
if let Some(status) = entry.status() {
|
|
||||||
let path = entry.path().unwrap_or("").to_string();
|
|
||||||
entries.push(GitStatusEntry {
|
|
||||||
path,
|
|
||||||
is_staged: status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED),
|
|
||||||
is_modified: status.intersects(Status::WT_MODIFIED),
|
|
||||||
is_new: status.intersects(Status::WT_NEW),
|
|
||||||
is_deleted: status.intersects(Status::WT_DELETED),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct GitStatusEntry {
|
|
||||||
pub path: String,
|
|
||||||
pub is_staged: bool,
|
|
||||||
pub is_modified: bool,
|
|
||||||
pub is_new: bool,
|
|
||||||
pub is_deleted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn git_stage(repo_path: String, path: String) -> CommandResult<(), AppError> {
|
|
||||||
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let mut index = repo.index().map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
index.add_path(std::path::Path::new(&path)).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
index.write().map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn git_commit(repo_path: String, message: String) -> CommandResult<String, AppError> {
|
|
||||||
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let mut index = repo.index().map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let oid = index.write_tree().map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let tree = repo.find_tree(oid).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let signature = repo.signature().map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
|
|
||||||
let commit = repo.commit(
|
|
||||||
Some("HEAD"), &signature, &signature, &message, &tree,
|
|
||||||
parent.as_ref(),
|
|
||||||
).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
Ok(commit.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn git_log(repo_path: String, limit: Option<usize>) -> CommandResult<Vec<GitCommitEntry>, AppError> {
|
|
||||||
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let mut revwalk = repo.revwalk().map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
revwalk.push_head().map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let max = limit.unwrap_or(50);
|
|
||||||
let mut commits = Vec::new();
|
|
||||||
for (i, oid) in revwalk.enumerate() {
|
|
||||||
if i >= max { break; }
|
|
||||||
let commit = repo.find_commit(oid).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
commits.push(GitCommitEntry {
|
|
||||||
hash: commit.id().to_string(),
|
|
||||||
message: commit.message().unwrap_or("").to_string(),
|
|
||||||
author: commit.author().name().unwrap_or("").to_string(),
|
|
||||||
date: chrono::DateTime::from_timestamp(commit.time().seconds(), 0)
|
|
||||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(commits)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct GitCommitEntry {
|
|
||||||
pub hash: String,
|
|
||||||
pub message: String,
|
|
||||||
pub author: String,
|
|
||||||
pub date: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn git_diff(repo_path: String, path: Option<String>) -> CommandResult<String, AppError> {
|
|
||||||
let repo = Repository::open(&repo_path).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let mut opts = DiffOptions::new();
|
|
||||||
if let Some(p) = path {
|
|
||||||
opts.path_filter(&p);
|
|
||||||
}
|
|
||||||
let diff = repo.diff_index_to_workdir(None, Some(&mut opts))
|
|
||||||
.map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
|
||||||
let prefix = match line.origin() {
|
|
||||||
'+' => " +",
|
|
||||||
'-' => " -",
|
|
||||||
' ' => " ",
|
|
||||||
_ => "",
|
|
||||||
};
|
|
||||||
if let Ok(content) = std::str::from_utf8(line.content()) {
|
|
||||||
out.push(format!("{}{}", prefix, content));
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}).map_err(|e| AppError::Git(e.to_string()))?;
|
|
||||||
Ok(out.join(""))
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
pub mod file;
|
|
||||||
pub mod export;
|
|
||||||
pub mod git;
|
|
||||||
pub mod app;
|
|
||||||
pub mod pdf;
|
|
||||||
pub mod dialog;
|
|
||||||
pub mod plugins;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
use tauri::CommandResult;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::pdf_ops::PdfProcessor;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_pdf_page_count(path: String) -> CommandResult<usize, AppError> {
|
|
||||||
PdfProcessor::get_page_count(&path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn merge_pdfs(paths: Vec<String>, output: String) -> CommandResult<String, AppError> {
|
|
||||||
PdfProcessor::merge_pdfs(&paths, &output)?;
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn split_pdf(input: String, output_dir: String, pages_per_split: usize) -> CommandResult<Vec<String>, AppError> {
|
|
||||||
PdfProcessor::split_pdf(&input, &output_dir, pages_per_split)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn rotate_pdf(input: String, output: String, degrees: i32) -> CommandResult<(), AppError> {
|
|
||||||
PdfProcessor::rotate_pages(&input, &output, degrees)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_pdf_pages(input: String, output: String, pages: Vec<usize>) -> CommandResult<(), AppError> {
|
|
||||||
PdfProcessor::delete_pages(&input, &output, &pages)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
use crate::error::AppError;
|
|
||||||
use crate::plugin_manager::PluginMetadata;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn list_plugins() -> Vec<PluginMetadata> {
|
|
||||||
// This would be connected to the app state in a full implementation
|
|
||||||
// For now, return the built-in plugins
|
|
||||||
vec![
|
|
||||||
PluginMetadata {
|
|
||||||
name: "writing-studio".to_string(),
|
|
||||||
version: "1.0.0".to_string(),
|
|
||||||
description: "Goal tracking, manuscript panels, snapshots, and sprint engine for writers".to_string(),
|
|
||||||
author: "ConcreteInfo".to_string(),
|
|
||||||
builtin: true,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_plugin(name: String) -> Option<PluginMetadata> {
|
|
||||||
match name.as_str() {
|
|
||||||
"writing-studio" => Some(PluginMetadata {
|
|
||||||
name: "writing-studio".to_string(),
|
|
||||||
version: "1.0.0".to_string(),
|
|
||||||
description: "Goal tracking, manuscript panels, snapshots, and sprint engine for writers".to_string(),
|
|
||||||
author: "ConcreteInfo".to_string(),
|
|
||||||
builtin: true,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum AppError {
|
|
||||||
#[error("File read error: {0}")]
|
|
||||||
FileRead(String),
|
|
||||||
#[error("File write error: {0}")]
|
|
||||||
FileWrite(String),
|
|
||||||
#[error("File delete error: {0}")]
|
|
||||||
FileDelete(String),
|
|
||||||
#[error("File copy error: {0}")]
|
|
||||||
FileCopy(String),
|
|
||||||
#[error("File move error: {0}")]
|
|
||||||
FileMove(String),
|
|
||||||
#[error("Export error: {0}")]
|
|
||||||
Export(String),
|
|
||||||
#[error("Git error: {0}")]
|
|
||||||
Git(String),
|
|
||||||
#[error("PDF error: {0}")]
|
|
||||||
Pdf(String),
|
|
||||||
#[error("Config error: {0}")]
|
|
||||||
Config(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl serde::Serialize for AppError {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: serde::ser::Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(self.to_string().as_ref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
pub mod menu;
|
|
||||||
pub mod tray;
|
|
||||||
pub mod pdf_ops;
|
|
||||||
pub mod plugin_manager;
|
|
||||||
|
|
||||||
pub fn run() {
|
|
||||||
tauri::Builder::default()
|
|
||||||
.plugin(tauri_plugin_log::Builder::new().build())
|
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
|
||||||
.plugin(tauri_plugin_shell::init())
|
|
||||||
.plugin(tauri_plugin_dialog::init())
|
|
||||||
.plugin(tauri_plugin_fs::init())
|
|
||||||
.plugin(tauri_plugin_process::init())
|
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
|
||||||
.plugin(tauri_plugin_notification::init())
|
|
||||||
.plugin(tauri_plugin_os::init())
|
|
||||||
.plugin(tauri_plugin_opener::init())
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
crate::commands::file::read_file,
|
|
||||||
crate::commands::file::write_file,
|
|
||||||
crate::commands::file::delete_file,
|
|
||||||
crate::commands::file::path_exists,
|
|
||||||
crate::commands::file::is_directory,
|
|
||||||
crate::commands::file::list_directory,
|
|
||||||
crate::commands::file::ensure_directory,
|
|
||||||
crate::commands::file::copy_path,
|
|
||||||
crate::commands::file::move_path,
|
|
||||||
crate::commands::export::export_markdown,
|
|
||||||
crate::commands::export::check_pandoc_available,
|
|
||||||
crate::commands::git::git_status,
|
|
||||||
crate::commands::git::git_stage,
|
|
||||||
crate::commands::git::git_commit,
|
|
||||||
crate::commands::git::git_log,
|
|
||||||
crate::commands::git::git_diff,
|
|
||||||
crate::commands::app::get_app_version,
|
|
||||||
crate::commands::app::get_recent_files,
|
|
||||||
crate::commands::app::save_recent_files,
|
|
||||||
crate::commands::pdf::get_pdf_page_count,
|
|
||||||
crate::commands::pdf::merge_pdfs,
|
|
||||||
crate::commands::pdf::split_pdf,
|
|
||||||
crate::commands::pdf::rotate_pdf,
|
|
||||||
crate::commands::pdf::delete_pdf_pages,
|
|
||||||
crate::commands::dialog::open_file_dialog,
|
|
||||||
crate::commands::dialog::save_file_dialog,
|
|
||||||
crate::commands::dialog::select_folder_dialog,
|
|
||||||
crate::commands::plugins::list_plugins,
|
|
||||||
crate::commands::plugins::get_plugin,
|
|
||||||
])
|
|
||||||
.setup(|app| {
|
|
||||||
log::info!("MarkdownConverter starting up");
|
|
||||||
|
|
||||||
// Set up native menu
|
|
||||||
let menu = crate::menu::create_menu(app.handle())?;
|
|
||||||
app.set_menu(menu)?;
|
|
||||||
app.on_menu_event(|app, event| crate::menu::handle_menu_event(app, event));
|
|
||||||
|
|
||||||
// Set up system tray
|
|
||||||
let _ = crate::tray::create_tray(app.handle());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.on_window_event(|window, event| {
|
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
|
||||||
let _ = window.emit("file-save", ());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("error while running tauri application");
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
markdown_converter_lib::run()
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
use tauri::{AppHandle, Emitter, menu::{Menu, MenuItem, Submenu, PredefinedMenuItem}};
|
|
||||||
|
|
||||||
pub fn create_menu(app: &AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
|
||||||
let file_menu = Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"File",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "new", "New", true, Some("CmdOrCtrl+N"))?,
|
|
||||||
&MenuItem::with_id(app, "open", "Open...", true, Some("CmdOrCtrl+O"))?,
|
|
||||||
&MenuItem::with_id(app, "open_pdf", "Open PDF...", true, Some("CmdOrCtrl+Shift+O"))?,
|
|
||||||
&MenuItem::with_id(app, "save", "Save", true, Some("CmdOrCtrl+S"))?,
|
|
||||||
&MenuItem::with_id(app, "save_as", "Save As...", true, Some("CmdOrCtrl+Shift+S"))?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&MenuItem::with_id(app, "print", "Print Preview", true, Some("CmdOrCtrl+P"))?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&MenuItem::with_id(app, "import", "Import Document...", true, Some("CmdOrCtrl+I"))?,
|
|
||||||
&Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"Export",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "export_html", "HTML", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "export_pdf", "PDF", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "export_docx", "DOCX", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "export_latex", "LaTeX", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "export_rtf", "RTF", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "export_odt", "ODT", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "export_epub", "EPUB", true, None)?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&MenuItem::with_id(app, "export_pptx", "PowerPoint (PPTX)", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "export_csv", "CSV (Tables)", true, None)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&MenuItem::with_id(app, "quit", "Quit", true, Some("CmdOrCtrl+Q"))?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let edit_menu = Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"Edit",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "undo", "Undo", true, Some("CmdOrCtrl+Z"))?,
|
|
||||||
&MenuItem::with_id(app, "redo", "Redo", true, Some("CmdOrCtrl+Shift+Z"))?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&MenuItem::with_id(app, "cut", "Cut", true, Some("CmdOrCtrl+X"))?,
|
|
||||||
&MenuItem::with_id(app, "copy", "Copy", true, Some("CmdOrCtrl+C"))?,
|
|
||||||
&MenuItem::with_id(app, "paste", "Paste", true, Some("CmdOrCtrl+V"))?,
|
|
||||||
&MenuItem::with_id(app, "select_all", "Select All", true, Some("CmdOrCtrl+A"))?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&MenuItem::with_id(app, "find", "Find & Replace", true, Some("CmdOrCtrl+F"))?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let view_menu = Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"View",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "toggle_preview", "Toggle Preview", true, Some("CmdOrCtrl+Shift+V"))?,
|
|
||||||
&MenuItem::with_id(app, "command_palette", "Command Palette", true, Some("CmdOrCtrl+Shift+P"))?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"Sidebar",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "sidebar_explorer", "File Explorer", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "sidebar_git", "Git", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "sidebar_snippets", "Snippets", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "sidebar_templates", "Templates", true, None)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
&MenuItem::with_id(app, "toggle_bottom_panel", "Bottom Panel (REPL)", true, None)?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"Theme",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "theme_light", "Atom One Light", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "theme_dark", "Dark", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "theme_solarized", "Solarized Light", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "theme_monokai", "Monokai", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "theme_github", "GitHub Light", true, None)?,
|
|
||||||
],
|
|
||||||
)?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let batch_menu = Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"Batch",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "batch_convert_md", "Convert Markdown Folder...", true, None)?,
|
|
||||||
&PredefinedMenuItem::separator(app)?,
|
|
||||||
&MenuItem::with_id(app, "batch_image", "Batch Image Conversion...", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "batch_audio", "Batch Audio Conversion...", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "batch_video", "Batch Video Conversion...", true, None)?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let convert_menu = Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"Convert",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "universal_converter", "Universal File Converter...", true, Some("CmdOrCtrl+Shift+C"))?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let tools_menu = Submenu::with_items(
|
|
||||||
app,
|
|
||||||
"Tools",
|
|
||||||
true,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "table_generator", "Table Generator", true, Some("CmdOrCtrl+Shift+T"))?,
|
|
||||||
&MenuItem::with_id(app, "ascii_generator", "ASCII Art Generator", true, Some("CmdOrCtrl+Shift+A"))?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let menu = Menu::with_items(
|
|
||||||
app,
|
|
||||||
&[
|
|
||||||
&file_menu,
|
|
||||||
&edit_menu,
|
|
||||||
&view_menu,
|
|
||||||
&batch_menu,
|
|
||||||
&convert_menu,
|
|
||||||
&tools_menu,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_menu_event(app: &AppHandle, event: menu::MenuEvent) {
|
|
||||||
let id = event.id().as_ref();
|
|
||||||
match id {
|
|
||||||
"new" => { let _ = app.emit("file-new", ()); }
|
|
||||||
"open" => { let _ = app.emit("menu-open", "open"); }
|
|
||||||
"save" => { let _ = app.emit("file-save", ()); }
|
|
||||||
"toggle_preview" => { let _ = app.emit("toggle-preview", ()); }
|
|
||||||
"command_palette" => { let _ = app.emit("toggle-command-palette", ()); }
|
|
||||||
"toggle_bottom_panel" => { let _ = app.emit("toggle-bottom-panel", ()); }
|
|
||||||
"sidebar_explorer" => { let _ = app.emit("toggle-sidebar-panel", "explorer"); }
|
|
||||||
"sidebar_git" => { let _ = app.emit("toggle-sidebar-panel", "git"); }
|
|
||||||
"sidebar_snippets" => { let _ = app.emit("toggle-sidebar-panel", "snippets"); }
|
|
||||||
"sidebar_templates" => { let _ = app.emit("toggle-sidebar-panel", "templates"); }
|
|
||||||
"theme_light" => { let _ = app.emit("theme-changed", "atomonelight"); }
|
|
||||||
"theme_dark" => { let _ = app.emit("theme-changed", "dark"); }
|
|
||||||
"theme_solarized" => { let _ = app.emit("theme-changed", "solarized"); }
|
|
||||||
"theme_monokai" => { let _ = app.emit("theme-changed", "monokai"); }
|
|
||||||
"theme_github" => { let _ = app.emit("theme-changed", "github"); }
|
|
||||||
"undo" => { let _ = app.emit("undo", ()); }
|
|
||||||
"redo" => { let _ = app.emit("redo", ()); }
|
|
||||||
"find" => { let _ = app.emit("toggle-find", ()); }
|
|
||||||
"quit" => { app.exit(0); }
|
|
||||||
"cut" => { /* native cut handled by OS */ }
|
|
||||||
"copy" => { /* native copy handled by OS */ }
|
|
||||||
"paste" => { /* native paste handled by OS */ }
|
|
||||||
"select_all" => { /* native select all handled by OS */ }
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
use std::fs::File;
|
|
||||||
use std::io::Write;
|
|
||||||
use printpdf::*;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
pub struct PdfProcessor;
|
|
||||||
|
|
||||||
impl PdfProcessor {
|
|
||||||
pub fn get_page_count(path: &str) -> Result<usize, AppError> {
|
|
||||||
let file = File::open(path).map_err(|e| AppError::Pdf(format!("Failed to open {}: {}", path, e)))?;
|
|
||||||
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
|
|
||||||
Ok(reader.get_pages().len())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge_pdfs(paths: &[String], output: &str) -> Result<(), AppError> {
|
|
||||||
let mut output_doc = PdfDocument::new("Merged PDF", Mm(210.0), Mm(297.0), None);
|
|
||||||
|
|
||||||
for path in paths {
|
|
||||||
let file = File::open(path).map_err(|e| AppError::Pdf(format!("Failed to open {}: {}", path, e)))?;
|
|
||||||
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read {}: {}", path, e)))?;
|
|
||||||
|
|
||||||
for (page_idx, (_, page)) in reader.get_pages().iter().enumerate() {
|
|
||||||
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
|
|
||||||
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
|
|
||||||
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save PDF: {}", e)))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn split_pdf(input: &str, output_dir: &str, pages_per_split: usize) -> Result<Vec<String>, AppError> {
|
|
||||||
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
|
|
||||||
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
|
|
||||||
|
|
||||||
let total_pages = reader.get_pages().len();
|
|
||||||
let mut output_files = Vec::new();
|
|
||||||
|
|
||||||
for start in (0..total_pages).step_by(pages_per_split) {
|
|
||||||
let end = (start + pages_per_split).min(total_pages);
|
|
||||||
let output_path = format!("{}/split_{}_{}.pdf", output_dir, start, end);
|
|
||||||
|
|
||||||
let mut new_doc = PdfDocument::new(
|
|
||||||
&format!("Pages {} - {}", start + 1, end),
|
|
||||||
Mm(210.0),
|
|
||||||
Mm(297.0),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
for page_idx in start..end {
|
|
||||||
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
|
|
||||||
let _ = new_doc.add_page(doc_idx as usize, page_obj, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let out_file = File::create(&output_path).map_err(|e| AppError::Pdf(format!("Failed to create {}: {}", output_path, e)))?;
|
|
||||||
new_doc.save(&out_file).map_err(|e| AppError::Pdf(format!("Failed to save split PDF: {}", e)))?;
|
|
||||||
output_files.push(output_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output_files)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rotate_pages(input: &str, output: &str, degrees: i32) -> Result<(), AppError> {
|
|
||||||
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
|
|
||||||
let mut reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
|
|
||||||
|
|
||||||
// For rotation, we'll recreate the PDF with rotated pages
|
|
||||||
let mut output_doc = PdfDocument::new("Rotated PDF", Mm(210.0), Mm(297.0), None);
|
|
||||||
|
|
||||||
for (page_idx, (_, _)) in reader.get_pages().iter().enumerate() {
|
|
||||||
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
|
|
||||||
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
|
|
||||||
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save rotated PDF: {}", e)))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_pages(input: &str, output: &str, pages_to_delete: &[usize]) -> Result<(), AppError> {
|
|
||||||
let file = File::open(input).map_err(|e| AppError::Pdf(format!("Failed to open input: {}", e)))?;
|
|
||||||
let reader = PdfReader::new(file).map_err(|e| AppError::Pdf(format!("Failed to read PDF: {}", e)))?;
|
|
||||||
|
|
||||||
let total_pages = reader.get_pages().len();
|
|
||||||
let delete_set: std::collections::HashSet<usize> = pages_to_delete.iter().cloned().collect();
|
|
||||||
|
|
||||||
let mut output_doc = PdfDocument::new("Modified PDF", Mm(210.0), Mm(297.0), None);
|
|
||||||
|
|
||||||
for page_idx in 0..total_pages {
|
|
||||||
if delete_set.contains(&page_idx) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok((doc_idx, page_obj)) = reader.get_page(page_idx) {
|
|
||||||
let _ = output_doc.add_page(doc_idx as usize, page_obj, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let output_file = File::create(output).map_err(|e| AppError::Pdf(format!("Failed to create output: {}", e)))?;
|
|
||||||
output_doc.save(&output_file).map_err(|e| AppError::Pdf(format!("Failed to save modified PDF: {}", e)))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PluginMetadata {
|
|
||||||
pub name: String,
|
|
||||||
pub version: String,
|
|
||||||
pub description: String,
|
|
||||||
pub author: String,
|
|
||||||
pub builtin: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PluginManager {
|
|
||||||
plugins: HashMap<String, PluginMetadata>,
|
|
||||||
enabled: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
PluginManager {
|
|
||||||
plugins: HashMap::new(),
|
|
||||||
enabled: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_plugins(&mut self, plugin_dir: PathBuf) -> Result<(), AppError> {
|
|
||||||
if !plugin_dir.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in walkdir::WalkDir::new(&plugin_dir)
|
|
||||||
.max_depth(1)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
{
|
|
||||||
let path = entry.path();
|
|
||||||
if !path.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest_path = path.join("manifest.json");
|
|
||||||
if manifest_path.exists() {
|
|
||||||
let content = std::fs::read_to_string(&manifest_path)
|
|
||||||
.map_err(|e| AppError::Config(format!("Failed to read manifest: {}", e)))?;
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct RawPluginMetadata {
|
|
||||||
name: String,
|
|
||||||
version: String,
|
|
||||||
description: String,
|
|
||||||
author: String,
|
|
||||||
builtin: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw: RawPluginMetadata = serde_json::from_str(&content)
|
|
||||||
.map_err(|e| AppError::Config(format!("Failed to parse manifest: {}", e)))?;
|
|
||||||
|
|
||||||
let metadata = PluginMetadata {
|
|
||||||
name: raw.name,
|
|
||||||
version: raw.version,
|
|
||||||
description: raw.description,
|
|
||||||
author: raw.author,
|
|
||||||
builtin: raw.builtin.unwrap_or(false),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.plugins.insert(metadata.name.clone(), metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enable_plugin(&mut self, name: &str) {
|
|
||||||
if !self.enabled.contains(&name.to_string()) {
|
|
||||||
self.enabled.push(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn disable_plugin(&mut self, name: &str) {
|
|
||||||
self.enabled.retain(|n| n != name);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_enabled(&self) -> Vec<String> {
|
|
||||||
self.enabled.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_plugins(&self) -> Vec<PluginMetadata> {
|
|
||||||
self.plugins.values().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_plugin(&self, name: &str) -> Option<PluginMetadata> {
|
|
||||||
self.plugins.get(name).cloned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PluginManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
use tauri::{AppHandle, Emitter, tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent}, menu::{Menu, MenuItem}};
|
|
||||||
|
|
||||||
pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
|
||||||
let tray_menu = Menu::with_items(
|
|
||||||
app,
|
|
||||||
&[
|
|
||||||
&MenuItem::with_id(app, "show", "Show MarkdownConverter", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "new_file", "New File", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "open_file", "Open File...", true, None)?,
|
|
||||||
&MenuItem::with_id(app, "quit", "Quit", true, None)?,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let _tray = TrayIconBuilder::new()
|
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
|
||||||
.menu(&tray_menu)
|
|
||||||
.menu_on_left_click(false)
|
|
||||||
.on_menu_event(|app, event| {
|
|
||||||
match event.id().as_ref() {
|
|
||||||
"show" => {
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"new_file" => { let _ = app.emit("file-new", ()); }
|
|
||||||
"open_file" => { let _ = app.emit("menu-open", "open"); }
|
|
||||||
"quit" => { app.exit(0); }
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_tray_icon_event(|tray, event| {
|
|
||||||
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
|
|
||||||
let app = tray.app_handle();
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build(app)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"productName": "MarkdownConverter",
|
|
||||||
"version": "4.3.0",
|
|
||||||
"identifier": "com.concreteinfo.markdownconverter",
|
|
||||||
"build": {
|
|
||||||
"devUrl": "http://localhost:1420",
|
|
||||||
"frontendDist": "../dist",
|
|
||||||
"devtools": true
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"withGlobalTauri": true,
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"title": "MarkdownConverter",
|
|
||||||
"width": 1200,
|
|
||||||
"height": 800,
|
|
||||||
"minWidth": 800,
|
|
||||||
"minHeight": 600,
|
|
||||||
"resizable": true,
|
|
||||||
"fullscreen": false,
|
|
||||||
"center": true,
|
|
||||||
"fileDropEnabled": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"security": {
|
|
||||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://kroki.io https://plantuml.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"active": true,
|
|
||||||
"targets": ["nsis", "msi"],
|
|
||||||
"icon": [
|
|
||||||
"../assets/icons/32x32.png",
|
|
||||||
"../assets/icons/128x128.png",
|
|
||||||
"../assets/icons/128x128@2x.png",
|
|
||||||
"../assets/icons/icon.icns",
|
|
||||||
"../assets/icons/icon.ico"
|
|
||||||
],
|
|
||||||
"fileAssociations": [
|
|
||||||
{
|
|
||||||
"ext": ["md", "markdown"],
|
|
||||||
"name": "Markdown Document",
|
|
||||||
"description": "Markdown Document",
|
|
||||||
"role": "Editor",
|
|
||||||
"mimeType": "text/markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ext": ["pdf"],
|
|
||||||
"name": "PDF Document",
|
|
||||||
"description": "PDF Document",
|
|
||||||
"role": "Viewer",
|
|
||||||
"mimeType": "application/pdf"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resources": {},
|
|
||||||
"windows": {
|
|
||||||
"certificateThumbprint": null,
|
|
||||||
"digestAlgorithm": "sha256",
|
|
||||||
"timestampUrl": "",
|
|
||||||
"nsis": {
|
|
||||||
"languages": ["English", "German", "French", "Spanish", "Portuguese", "Japanese", "Korean", "SimpChinese", "TradChinese"],
|
|
||||||
"displayLanguageSelector": true,
|
|
||||||
"installMode": "currentUser"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"shell": {
|
|
||||||
"open": true,
|
|
||||||
"scope": [
|
|
||||||
{ "name": "pandoc", "cmd": "pandoc", "args": true },
|
|
||||||
{ "name": "ffmpeg", "cmd": "ffmpeg", "args": true }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Implements file system operations for Electron using IPC.
|
* Implements file system operations for Electron using IPC.
|
||||||
* This abstracts file operations to enable easier testing and migration.
|
* This abstracts file operations to enable easier testing and migration.
|
||||||
*
|
*
|
||||||
* @version 4.3.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Adapters abstract file system, conversion, and system operations
|
* Adapters abstract file system, conversion, and system operations
|
||||||
* to enable easier testing and future platform migration.
|
* to enable easier testing and future platform migration.
|
||||||
*
|
*
|
||||||
* @version 4.3.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
* @returns {EditorView} the created editor view
|
* @returns {EditorView} the created editor view
|
||||||
*/
|
*/
|
||||||
function createEditor(parentElement, options = {}) {
|
function createEditor(parentElement, options = {}) {
|
||||||
|
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
|
||||||
|
if (!parentElement) {
|
||||||
|
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
content = '',
|
content = '',
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
|
|||||||
+61
-1
@@ -2,6 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
|
||||||
|
<!-- TODO: Migrate to nonce-based CSP for better security -->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://www.plantuml.com;">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MarkdownConverter</title>
|
<title>MarkdownConverter</title>
|
||||||
<!-- Design tokens - loaded first for CSS variable availability -->
|
<!-- Design tokens - loaded first for CSS variable availability -->
|
||||||
@@ -285,6 +288,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="export-section revealjs-only" style="display: none;">
|
||||||
|
<label>Reveal.js Options:</label>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-theme">Slide Theme:</label>
|
||||||
|
<select id="reveal-theme">
|
||||||
|
<option value="black">Black</option>
|
||||||
|
<option value="white">White</option>
|
||||||
|
<option value="league">League</option>
|
||||||
|
<option value="beige">Beige</option>
|
||||||
|
<option value="sky">Sky</option>
|
||||||
|
<option value="night">Night</option>
|
||||||
|
<option value="serif">Serif</option>
|
||||||
|
<option value="simple">Simple</option>
|
||||||
|
<option value="solarized">Solarized</option>
|
||||||
|
<option value="blood">Blood</option>
|
||||||
|
<option value="moon">Moon</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-transition">Transition:</label>
|
||||||
|
<select id="reveal-transition">
|
||||||
|
<option value="slide">Slide</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="fade">Fade</option>
|
||||||
|
<option value="convex">Convex</option>
|
||||||
|
<option value="concave">Concave</option>
|
||||||
|
<option value="zoom">Zoom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-speed">Transition Speed:</label>
|
||||||
|
<select id="reveal-speed">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="fast">Fast</option>
|
||||||
|
<option value="slow">Slow</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label><input type="checkbox" id="reveal-slide-number"> Slide Numbers</label>
|
||||||
|
<label><input type="checkbox" id="reveal-controls" checked> Controls</label>
|
||||||
|
<label><input type="checkbox" id="reveal-progress" checked> Progress Bar</label>
|
||||||
|
<label><input type="checkbox" id="reveal-history" checked> Slide History</label>
|
||||||
|
<label><input type="checkbox" id="reveal-center" checked> Center Content</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
<label>Bibliography:</label>
|
<label>Bibliography:</label>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -902,6 +951,7 @@
|
|||||||
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">×</button>
|
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body pdf-editor-body">
|
<div class="modal-body pdf-editor-body">
|
||||||
|
<div class="pdf-editor-left-panel">
|
||||||
<!-- Merge PDFs Section -->
|
<!-- Merge PDFs Section -->
|
||||||
<div id="pdf-merge-section" class="pdf-operation-section hidden">
|
<div id="pdf-merge-section" class="pdf-operation-section hidden">
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
@@ -1284,6 +1334,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel: Scrollable Visual Page Sidebar -->
|
||||||
|
<div id="pdf-thumbnail-sidebar" class="pdf-editor-right-panel hidden">
|
||||||
|
<h4>Page Preview & Editor</h4>
|
||||||
|
<p class="sidebar-help">Select a PDF to view pages, check to delete, click ↻ to rotate, and use ◀ / ▶ to reorder.</p>
|
||||||
|
<div id="pdf-thumbnail-grid" class="pdf-thumbnail-grid">
|
||||||
|
<!-- Page thumbnail cards will be rendered here dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
||||||
<button id="pdf-editor-process" class="btn btn-primary">Process</button>
|
<button id="pdf-editor-process" class="btn btn-primary">Process</button>
|
||||||
@@ -1602,6 +1662,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="utils/ModalManager.js"></script>
|
<script src="utils/ModalManager.js"></script>
|
||||||
<script type="module" src="./renderer.js"></script>
|
<script src="renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+113
-4
@@ -242,7 +242,9 @@ function runPandoc(args, callback) {
|
|||||||
function runPandocCmd(cmdString, callback) {
|
function runPandocCmd(cmdString, callback) {
|
||||||
const parsed = parseCommand(cmdString);
|
const parsed = parseCommand(cmdString);
|
||||||
// Skip 'pandoc' if it's the first element (command itself)
|
// Skip 'pandoc' if it's the first element (command itself)
|
||||||
const args = parsed.command === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
// Use path.basename to handle full paths like bin/linux/pandoc
|
||||||
|
const cmdBase = path.basename(parsed.command).replace(/\.exe$/i, '');
|
||||||
|
const args = cmdBase === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
||||||
const pandocPath = getPandocPath();
|
const pandocPath = getPandocPath();
|
||||||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||||||
}
|
}
|
||||||
@@ -849,6 +851,20 @@ function createMenu() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Custom Preview CSS',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Load Custom Preview CSS...',
|
||||||
|
click: () => mainWindow.webContents.send('load-custom-css')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clear Custom Preview CSS',
|
||||||
|
click: () => mainWindow.webContents.send('clear-custom-css')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
||||||
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
@@ -2297,8 +2313,32 @@ function performExportWithOptions(format, options) {
|
|||||||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
|
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
|
||||||
}
|
}
|
||||||
} else if (format === 'revealjs') {
|
} else if (format === 'revealjs') {
|
||||||
pandocCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
|
let revealCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
|
||||||
exportWithPandoc(pandocCmd, outputFile, format);
|
if (options) {
|
||||||
|
if (options.revealTheme) revealCmd += ` -V theme="${options.revealTheme}"`;
|
||||||
|
if (options.revealTransition) revealCmd += ` -V transition="${options.revealTransition}"`;
|
||||||
|
if (options.revealTransitionSpeed) revealCmd += ` -V transitionSpeed="${options.revealTransitionSpeed}"`;
|
||||||
|
if (options.revealControls !== undefined) revealCmd += ` -V controls="${options.revealControls}"`;
|
||||||
|
if (options.revealSlideNumber !== undefined) revealCmd += ` -V slideNumber="${options.revealSlideNumber}"`;
|
||||||
|
if (options.revealProgress !== undefined) revealCmd += ` -V progress="${options.revealProgress}"`;
|
||||||
|
if (options.revealHistory !== undefined) revealCmd += ` -V history="${options.revealHistory}"`;
|
||||||
|
if (options.revealCenter !== undefined) revealCmd += ` -V center="${options.revealCenter}"`;
|
||||||
|
|
||||||
|
// Support for templates, metadata, bibliography
|
||||||
|
if (options.template && options.template !== 'default') {
|
||||||
|
revealCmd += ` --template="${options.template}"`;
|
||||||
|
}
|
||||||
|
if (options.metadata) {
|
||||||
|
for (const [key, value] of Object.entries(options.metadata)) {
|
||||||
|
if (value.trim()) {
|
||||||
|
revealCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.bibliography) revealCmd += ` --bibliography="${options.bibliography}"`;
|
||||||
|
if (options.csl) revealCmd += ` --csl="${options.csl}"`;
|
||||||
|
}
|
||||||
|
exportWithPandoc(revealCmd, outputFile, format);
|
||||||
} else if (format === 'beamer') {
|
} else if (format === 'beamer') {
|
||||||
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
|
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
|
||||||
exportWithPandoc(pandocCmd, outputFile, format);
|
exportWithPandoc(pandocCmd, outputFile, format);
|
||||||
@@ -2999,6 +3039,55 @@ ipcMain.on('select-folder', (event, type) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List directory contents as FileEntry[]
|
||||||
|
ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
return entries.map((dirent) => {
|
||||||
|
const fullPath = path.join(dirPath, dirent.name);
|
||||||
|
let size;
|
||||||
|
let modifiedAt;
|
||||||
|
if (!dirent.isDirectory()) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
size = stat.size;
|
||||||
|
modifiedAt = stat.mtime.toISOString();
|
||||||
|
} catch {
|
||||||
|
// ignore stat errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: dirent.name,
|
||||||
|
path: fullPath,
|
||||||
|
isDirectory: dirent.isDirectory(),
|
||||||
|
size,
|
||||||
|
modifiedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show folder picker dialog and return selected path or null
|
||||||
|
ipcMain.handle('pick-folder', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory'],
|
||||||
|
});
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
|
return result.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show file picker dialog for markdown files and return selected path or null
|
||||||
|
ipcMain.handle('pick-file', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }],
|
||||||
|
});
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
|
return result.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
||||||
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
||||||
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
||||||
@@ -3759,7 +3848,7 @@ app.on('open-file', (event, filePath) => {
|
|||||||
// Handle file opening from command line or file association
|
// Handle file opening from command line or file association
|
||||||
function openFileFromPath(filePath) {
|
function openFileFromPath(filePath) {
|
||||||
console.log('[MAIN] openFileFromPath called with:', filePath);
|
console.log('[MAIN] openFileFromPath called with:', filePath);
|
||||||
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow);
|
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow, 'webContents exists:', !!(mainWindow?.webContents));
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
if (stats.size > MAX_FILE_SIZE) {
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
@@ -3769,6 +3858,7 @@ function openFileFromPath(filePath) {
|
|||||||
currentFile = filePath;
|
currentFile = filePath;
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
console.log('[MAIN] File read successfully, content length:', content.length);
|
console.log('[MAIN] File read successfully, content length:', content.length);
|
||||||
|
console.log('[MAIN] About to send file-opened. mainWindow:', !!mainWindow, 'webContents:', !!(mainWindow?.webContents), 'rendererReady:', rendererReady);
|
||||||
if (mainWindow && mainWindow.webContents && rendererReady) {
|
if (mainWindow && mainWindow.webContents && rendererReady) {
|
||||||
// Send file immediately - renderer-ready means UI is initialized
|
// Send file immediately - renderer-ready means UI is initialized
|
||||||
console.log('[MAIN] Sending file-opened to renderer');
|
console.log('[MAIN] Sending file-opened to renderer');
|
||||||
@@ -3990,6 +4080,25 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('select-custom-css', async (event) => {
|
||||||
|
const result = dialog.showOpenDialogSync(mainWindow, {
|
||||||
|
title: 'Select Custom Preview CSS',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'CSS Stylesheets', extensions: ['css'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result && result[0]) {
|
||||||
|
const filePath = result[0];
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { path: filePath, content };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ipcMain.handle('read-file', async (event, filePath) => {
|
ipcMain.handle('read-file', async (event, filePath) => {
|
||||||
const validation = validatePath(filePath);
|
const validation = validatePath(filePath);
|
||||||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ async function pdfSplit(data) {
|
|||||||
const pdf = await PDFDocument.load(pdfBytes);
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
const totalPages = pdf.getPageCount();
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
let splits = [];
|
const splits = [];
|
||||||
|
|
||||||
if (data.splitMode === 'pages') {
|
if (data.splitMode === 'pages') {
|
||||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||||
for (let i = 0; i < ranges.length; i++) {
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
const range = ranges[i];
|
const range = ranges[i];
|
||||||
let pages = [];
|
const pages = [];
|
||||||
|
|
||||||
if (range.includes('-')) {
|
if (range.includes('-')) {
|
||||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
|
|||||||
+14
-3
@@ -9,7 +9,7 @@
|
|||||||
* - All IPC channels are explicitly whitelisted
|
* - All IPC channels are explicitly whitelisted
|
||||||
* - Prevents XSS from escalating to full system access
|
* - Prevents XSS from escalating to full system access
|
||||||
*
|
*
|
||||||
* @version 4.3.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
@@ -23,6 +23,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'save-recent-files',
|
'save-recent-files',
|
||||||
'clear-recent-files',
|
'clear-recent-files',
|
||||||
'renderer-ready',
|
'renderer-ready',
|
||||||
|
'select-custom-css',
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
'get-theme',
|
'get-theme',
|
||||||
@@ -150,6 +151,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'get-content-for-save',
|
'get-content-for-save',
|
||||||
'get-content-for-spreadsheet',
|
'get-content-for-spreadsheet',
|
||||||
'recent-files-cleared',
|
'recent-files-cleared',
|
||||||
|
'load-custom-css',
|
||||||
|
'clear-custom-css',
|
||||||
|
|
||||||
// UI toggles
|
// UI toggles
|
||||||
'toggle-preview',
|
'toggle-preview',
|
||||||
@@ -234,7 +237,12 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-command-palette',
|
'toggle-command-palette',
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel'
|
'toggle-bottom-panel',
|
||||||
|
|
||||||
|
// File dialog / directory listing
|
||||||
|
'list-directory',
|
||||||
|
'pick-folder',
|
||||||
|
'pick-file'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -341,7 +349,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
|
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
|
||||||
|
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
|
||||||
|
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
||||||
|
pickFile: () => ipcRenderer.invoke('pick-file')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Theme Operations
|
// Theme Operations
|
||||||
|
|||||||
+539
-84
@@ -1,10 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* MarkdownConverter Renderer Process
|
* MarkdownConverter Renderer Process
|
||||||
* @version 4.3.0
|
* @version 4.4.2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Electron removed - using tauri-commands.js
|
const { ipcRenderer } = require('electron');
|
||||||
import { file, events, app, exportDoc, git, pdf, dialog } from './tauri-commands.js';
|
|
||||||
|
// Shim window.electronAPI for main window which uses nodeIntegration
|
||||||
|
// without preload script (window.electronAPI is normally set by preload.js).
|
||||||
|
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||||
|
window.electronAPI = {
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
invoke: (channel, data) => ipcRenderer.invoke(channel, data),
|
||||||
|
on: (channel, callback) => ipcRenderer.on(channel, (event, ...args) => callback(...args))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const marked = require('marked');
|
const marked = require('marked');
|
||||||
const { markedHighlight } = require('marked-highlight');
|
const { markedHighlight } = require('marked-highlight');
|
||||||
const createDOMPurify = require('dompurify');
|
const createDOMPurify = require('dompurify');
|
||||||
@@ -12,7 +22,13 @@ const DOMPurify = createDOMPurify(window);
|
|||||||
const hljs = require('highlight.js');
|
const hljs = require('highlight.js');
|
||||||
const { createEditor } = require('./editor/codemirror-setup');
|
const { createEditor } = require('./editor/codemirror-setup');
|
||||||
const { undo, redo } = require('@codemirror/commands');
|
const { undo, redo } = require('@codemirror/commands');
|
||||||
const { ModalManager } = require('./utils/ModalManager');
|
// ModalManager is loaded via <script src="utils/ModalManager.js"> in index.html.
|
||||||
|
// It already exists in the global scope — re-declaring with let/const causes
|
||||||
|
// SyntaxError: "Identifier 'ModalManager' has already been declared".
|
||||||
|
if (typeof ModalManager === 'undefined') {
|
||||||
|
const result = require('./utils/ModalManager');
|
||||||
|
ModalManager = result.ModalManager || result;
|
||||||
|
}
|
||||||
// Lazy-loaded modules — defer heavy imports until first use
|
// Lazy-loaded modules — defer heavy imports until first use
|
||||||
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
|
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
|
||||||
let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent;
|
let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent;
|
||||||
@@ -103,19 +119,24 @@ marked.use({
|
|||||||
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
||||||
tokenizer(src) {
|
tokenizer(src) {
|
||||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||||
if (match) {
|
if (match && match.index === 0) {
|
||||||
|
const admonitionType = match[1];
|
||||||
|
const text = match[2].trim();
|
||||||
|
const tokens = [];
|
||||||
|
this.lexer.blockTokens(text, tokens);
|
||||||
return {
|
return {
|
||||||
type: 'admonition',
|
type: 'admonition',
|
||||||
raw: match[0],
|
raw: match[0],
|
||||||
admonitionType: match[1],
|
admonitionType,
|
||||||
text: match[2].trim()
|
text,
|
||||||
|
tokens
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer(token) {
|
renderer(token) {
|
||||||
const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' };
|
const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' };
|
||||||
const icon = icons[token.admonitionType] || '\u2139';
|
const icon = icons[token.admonitionType] || '\u2139';
|
||||||
const inner = this.parser.parse(token.text);
|
const inner = this.parser.parse(token.tokens || []);
|
||||||
return `<div class="admonition admonition-${token.admonitionType}">
|
return `<div class="admonition admonition-${token.admonitionType}">
|
||||||
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
||||||
<div class="admonition-content">${inner}</div>
|
<div class="admonition-content">${inner}</div>
|
||||||
@@ -132,8 +153,20 @@ function plantumlEncode(text) {
|
|||||||
return '~h' + hex;
|
return '~h' + hex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scopes user custom CSS by prefixing all selectors to protect application UI
|
||||||
|
function scopeCSS(cssText, scopeSelector) {
|
||||||
|
if (!cssText) return '';
|
||||||
|
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
|
||||||
|
const trimmed = selector.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Module-level reference set by DOMContentLoaded after sidebar registration
|
// Module-level reference set by DOMContentLoaded after sidebar registration
|
||||||
let outlinePanelContainer = null;
|
const outlinePanelContainer = null;
|
||||||
|
|
||||||
// Tab Management
|
// Tab Management
|
||||||
class TabManager {
|
class TabManager {
|
||||||
@@ -148,6 +181,7 @@ class TabManager {
|
|||||||
this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
||||||
this.previewDebounceTimers = new Map(); // Debounce timers per tab
|
this.previewDebounceTimers = new Map(); // Debounce timers per tab
|
||||||
this.previewDebounceDelay = 300; // 300ms debounce
|
this.previewDebounceDelay = 300; // 300ms debounce
|
||||||
|
this.previewCache = new Map(); // Performance optimization render cache
|
||||||
|
|
||||||
// Initialize first tab
|
// Initialize first tab
|
||||||
this.tabs.set(1, {
|
this.tabs.set(1, {
|
||||||
@@ -231,7 +265,7 @@ class TabManager {
|
|||||||
editorView: null,
|
editorView: null,
|
||||||
findMatches: [],
|
findMatches: [],
|
||||||
currentMatchIndex: -1,
|
currentMatchIndex: -1,
|
||||||
type: type,
|
type: type, // 'markdown' or 'pdf'
|
||||||
// PDF-specific state
|
// PDF-specific state
|
||||||
pdfDoc: null,
|
pdfDoc: null,
|
||||||
pdfCurrentPage: 1,
|
pdfCurrentPage: 1,
|
||||||
@@ -244,6 +278,7 @@ class TabManager {
|
|||||||
this.switchToTab(newTabId);
|
this.switchToTab(newTabId);
|
||||||
this.startAutoSave();
|
this.startAutoSave();
|
||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
|
console.log('createNewTab: tab created with type:', type);
|
||||||
}
|
}
|
||||||
|
|
||||||
createPdfTab(filePath) {
|
createPdfTab(filePath) {
|
||||||
@@ -455,27 +490,7 @@ class TabManager {
|
|||||||
document.querySelector('.editor-container').appendChild(tabContent);
|
document.querySelector('.editor-container').appendChild(tabContent);
|
||||||
|
|
||||||
// Initialize CodeMirror editor
|
// Initialize CodeMirror editor
|
||||||
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
|
this.ensureEditor(tab);
|
||||||
if (editorContainer) {
|
|
||||||
const isDark = document.body.className.includes('dark');
|
|
||||||
tab.editorView = createEditor(editorContainer, {
|
|
||||||
content: tab.content,
|
|
||||||
onChange: (newContent) => {
|
|
||||||
tab.content = newContent;
|
|
||||||
tab.isDirty = true;
|
|
||||||
this.updatePreview(tab.id);
|
|
||||||
this.updateWordCount();
|
|
||||||
this.updateTabBar();
|
|
||||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
|
||||||
},
|
|
||||||
onUpdate: (view) => {
|
|
||||||
this.updateCursorPosition(view);
|
|
||||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
|
||||||
},
|
|
||||||
isDark,
|
|
||||||
showLineNumbers: this.showLineNumbers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToTab(tabId) {
|
switchToTab(tabId) {
|
||||||
@@ -501,7 +516,7 @@ class TabManager {
|
|||||||
|
|
||||||
// Notify main process about current file for exports
|
// Notify main process about current file for exports
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
const result = await file.write(path, content);
|
ipcRenderer.send('set-current-file', tab?.filePath || null);
|
||||||
|
|
||||||
// Refresh outline panel for new tab content
|
// Refresh outline panel for new tab content
|
||||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||||
@@ -661,6 +676,11 @@ class TabManager {
|
|||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
if (!tab || tab.type === 'pdf') return;
|
if (!tab || tab.type === 'pdf') return;
|
||||||
|
|
||||||
|
// Guard against auto-renders in large file mode
|
||||||
|
if (tab.largeFileMode && !immediate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing debounce timer for this tab
|
// Clear existing debounce timer for this tab
|
||||||
if (this.previewDebounceTimers.has(tabId)) {
|
if (this.previewDebounceTimers.has(tabId)) {
|
||||||
clearTimeout(this.previewDebounceTimers.get(tabId));
|
clearTimeout(this.previewDebounceTimers.get(tabId));
|
||||||
@@ -679,20 +699,55 @@ class TabManager {
|
|||||||
}, this.previewDebounceDelay));
|
}, this.previewDebounceDelay));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_hash(str) {
|
||||||
|
if (!str) return 0;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||||
|
hash |= 0; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
_renderPreview(tabId) {
|
_renderPreview(tabId) {
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
const preview = document.getElementById(`preview-${tabId}`);
|
const preview = document.getElementById(`preview-${tabId}`);
|
||||||
|
console.log('[_renderPreview] tabId:', tabId, 'tab exists:', !!tab, 'preview exists:', !!preview, 'content length:', tab?.content?.length);
|
||||||
|
|
||||||
if (!tab || !preview) return;
|
if (!tab || !preview) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if libraries are available
|
// Check if libraries are available
|
||||||
if (!marked || !DOMPurify) {
|
if (!marked || !DOMPurify) {
|
||||||
|
console.error('[_renderPreview] Libraries not available:', { marked: !!marked, DOMPurify: !!DOMPurify });
|
||||||
preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>';
|
preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const html = marked.parse(tab.content);
|
|
||||||
let sanitizedHtml = DOMPurify.sanitize(html);
|
// Cache lookup
|
||||||
|
const contentHash = this._hash(tab.content);
|
||||||
|
let sanitizedHtml;
|
||||||
|
if (this.previewCache.has(contentHash)) {
|
||||||
|
console.log('[_renderPreview] Cache hit for hash:', contentHash);
|
||||||
|
sanitizedHtml = this.previewCache.get(contentHash);
|
||||||
|
} else {
|
||||||
|
console.log('[_renderPreview] Cache miss. About to call marked.parse, content length:', tab.content.length);
|
||||||
|
const html = marked.parse(tab.content);
|
||||||
|
console.log('[_renderPreview] marked.parse returned type:', typeof html, 'isPromise:', html instanceof Promise);
|
||||||
|
if (html instanceof Promise) {
|
||||||
|
console.error('[_renderPreview] marked.parse returned a Promise! Need to await it.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sanitizedHtml = DOMPurify.sanitize(html);
|
||||||
|
console.log('[_renderPreview] DOMPurify.sanitize returned, length:', sanitizedHtml.length);
|
||||||
|
|
||||||
|
// Add to cache and maintain cache size limit (100 entries)
|
||||||
|
this.previewCache.set(contentHash, sanitizedHtml);
|
||||||
|
if (this.previewCache.size > 100) {
|
||||||
|
const oldestKey = this.previewCache.keys().next().value;
|
||||||
|
this.previewCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TOC generation
|
// TOC generation
|
||||||
if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) {
|
if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) {
|
||||||
@@ -924,7 +979,7 @@ class TabManager {
|
|||||||
|
|
||||||
// Only auto-save if content has changed since last save
|
// Only auto-save if content has changed since last save
|
||||||
if (tab.lastSavedContent !== tab.content) {
|
if (tab.lastSavedContent !== tab.content) {
|
||||||
await file.write(tab.filePath, tab.content);
|
ipcRenderer.send('save-file', { path: tab.filePath, content: tab.content });
|
||||||
tab.lastSavedContent = tab.content;
|
tab.lastSavedContent = tab.content;
|
||||||
|
|
||||||
// Show brief auto-save indicator
|
// Show brief auto-save indicator
|
||||||
@@ -963,7 +1018,7 @@ class TabManager {
|
|||||||
|
|
||||||
// Save to localStorage and sync with main process
|
// Save to localStorage and sync with main process
|
||||||
localStorage.setItem('recentFiles', JSON.stringify(this.recentFiles));
|
localStorage.setItem('recentFiles', JSON.stringify(this.recentFiles));
|
||||||
await app.saveRecentFiles(this.recentFiles);
|
ipcRenderer.send('save-recent-files', this.recentFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRecentFiles() {
|
getRecentFiles() {
|
||||||
@@ -1328,10 +1383,22 @@ class TabManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkForLargeFile(tab, content) {
|
||||||
|
if (content && content.length > 1024 * 1024) { // >1MB
|
||||||
|
tab.largeFileMode = true;
|
||||||
|
this.isPreviewVisible = false;
|
||||||
|
this.updatePreviewVisibility();
|
||||||
|
notifyUser('Large file detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.', 'warning');
|
||||||
|
} else {
|
||||||
|
tab.largeFileMode = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// File operations
|
// File operations
|
||||||
openFile(filePath, content) {
|
openFile(filePath, content) {
|
||||||
console.log('openFile called with:', filePath, 'content length:', content.length);
|
console.log('openFile called with:', filePath, 'content length:', content.length);
|
||||||
let tab = this.tabs.get(this.activeTabId);
|
let tab = this.tabs.get(this.activeTabId);
|
||||||
|
console.log('openFile tab state before:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length, filePath: tab?.filePath, isDirty: tab?.isDirty });
|
||||||
|
|
||||||
// Handle both forward and back slashes for cross-platform compatibility
|
// Handle both forward and back slashes for cross-platform compatibility
|
||||||
const fileName = filePath.split(/[\\/]/).pop();
|
const fileName = filePath.split(/[\\/]/).pop();
|
||||||
@@ -1345,24 +1412,31 @@ class TabManager {
|
|||||||
tab.originalContent = content;
|
tab.originalContent = content;
|
||||||
tab.isDirty = false;
|
tab.isDirty = false;
|
||||||
|
|
||||||
|
this.checkForLargeFile(tab, content);
|
||||||
|
|
||||||
// Update the editor and preview
|
// Update the editor and preview
|
||||||
this.setEditorContent(this.activeTabId, content);
|
this.setEditorContent(this.activeTabId, content);
|
||||||
this.updatePreview(this.activeTabId);
|
console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
|
||||||
|
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
|
||||||
this.updateWordCount();
|
this.updateWordCount();
|
||||||
} else {
|
} else {
|
||||||
// Create new tab for the file
|
// Create new tab for the file
|
||||||
console.log('Creating new tab for file');
|
console.log('Creating new tab for file');
|
||||||
this.createNewTab();
|
this.createNewTab();
|
||||||
tab = this.tabs.get(this.activeTabId);
|
tab = this.tabs.get(this.activeTabId);
|
||||||
|
console.log('openFile after createNewTab, tab:', { id: tab.id, hasEditorView: !!tab.editorView });
|
||||||
tab.filePath = filePath;
|
tab.filePath = filePath;
|
||||||
tab.title = fileName;
|
tab.title = fileName;
|
||||||
tab.content = content;
|
tab.content = content;
|
||||||
tab.originalContent = content;
|
tab.originalContent = content;
|
||||||
tab.isDirty = false;
|
tab.isDirty = false;
|
||||||
|
|
||||||
|
this.checkForLargeFile(tab, content);
|
||||||
|
|
||||||
// Set content in the CodeMirror editor
|
// Set content in the CodeMirror editor
|
||||||
this.setEditorContent(this.activeTabId, content);
|
this.setEditorContent(this.activeTabId, content);
|
||||||
this.updatePreview(this.activeTabId);
|
console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
|
||||||
|
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
|
||||||
this.updateWordCount();
|
this.updateWordCount();
|
||||||
}
|
}
|
||||||
this.startAutoSave();
|
this.startAutoSave();
|
||||||
@@ -1370,9 +1444,10 @@ class TabManager {
|
|||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
this.updateFilePath();
|
this.updateFilePath();
|
||||||
this.updateBreadcrumb();
|
this.updateBreadcrumb();
|
||||||
|
this.updateUI(); // Ensure tab content is visible
|
||||||
|
|
||||||
// Notify main process about current file for exports
|
// Notify main process about current file for exports
|
||||||
// handled via event
|
ipcRenderer.send('set-current-file', filePath);
|
||||||
|
|
||||||
console.log('File opened successfully');
|
console.log('File opened successfully');
|
||||||
}
|
}
|
||||||
@@ -1386,9 +1461,47 @@ class TabManager {
|
|||||||
return tab?.content || '';
|
return tab?.content || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure a tab has a CodeMirror editor (lazy creation fallback)
|
||||||
|
ensureEditor(tab) {
|
||||||
|
if (tab.editorView) return true;
|
||||||
|
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
|
||||||
|
if (!editorContainer) {
|
||||||
|
console.error(`[ensureEditor] editor-cm-${tab.id} not found in DOM`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isDark = document.body.className.includes('dark');
|
||||||
|
tab.editorView = createEditor(editorContainer, {
|
||||||
|
content: tab.content,
|
||||||
|
onChange: (newContent) => {
|
||||||
|
tab.content = newContent;
|
||||||
|
tab.isDirty = true;
|
||||||
|
this.updatePreview(tab.id);
|
||||||
|
this.updateWordCount();
|
||||||
|
this.updateTabBar();
|
||||||
|
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||||
|
},
|
||||||
|
onUpdate: (view) => {
|
||||||
|
this.updateCursorPosition(view);
|
||||||
|
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||||
|
},
|
||||||
|
isDark,
|
||||||
|
showLineNumbers: this.showLineNumbers,
|
||||||
|
});
|
||||||
|
console.log(`[ensureEditor] Created editor for tab ${tab.id}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ensureEditor] Failed to create editor for tab ${tab.id}:`, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set content in editor
|
// Set content in editor
|
||||||
setEditorContent(tabId, content) {
|
setEditorContent(tabId, content) {
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
|
if (!tab?.editorView) {
|
||||||
|
this.ensureEditor(tab);
|
||||||
|
}
|
||||||
if (tab?.editorView) {
|
if (tab?.editorView) {
|
||||||
tab.editorView.dispatch({
|
tab.editorView.dispatch({
|
||||||
changes: { from: 0, to: tab.editorView.state.doc.length, insert: content }
|
changes: { from: 0, to: tab.editorView.state.doc.length, insert: content }
|
||||||
@@ -1446,6 +1559,13 @@ let replPanel;
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
tabManager = new TabManager();
|
tabManager = new TabManager();
|
||||||
|
|
||||||
|
// Load saved Custom Preview CSS if present
|
||||||
|
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
|
||||||
|
if (savedCSSContent) {
|
||||||
|
applyCustomPreviewCSS(savedCSSContent);
|
||||||
|
}
|
||||||
|
|
||||||
const ReplPanel = getReplPanel();
|
const ReplPanel = getReplPanel();
|
||||||
replPanel = new ReplPanel();
|
replPanel = new ReplPanel();
|
||||||
|
|
||||||
@@ -1484,12 +1604,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Initialize sidebar
|
// Initialize sidebar
|
||||||
const SidebarManager = getSidebarManager();
|
const SidebarManager = getSidebarManager();
|
||||||
const sidebarManager = new SidebarManager();
|
const sidebarManager = new SidebarManager();
|
||||||
let explorerCurrentDir = null;
|
const explorerCurrentDir = null;
|
||||||
|
|
||||||
sidebarManager.registerPanel('explorer', {
|
sidebarManager.registerPanel('explorer', {
|
||||||
title: 'Explorer',
|
title: 'Explorer',
|
||||||
render: (container) => getRenderExplorerPanel()(container, {
|
render: (container) => getRenderExplorerPanel()(container, {
|
||||||
listDirectory: (dir) => file.listDirectory(dir),
|
listDirectory: (dir) => ipcRenderer.invoke('list-directory', dir),
|
||||||
onFileOpen: (filePath) => ipcRenderer.send('open-file-path', filePath),
|
onFileOpen: (filePath) => ipcRenderer.send('open-file-path', filePath),
|
||||||
currentDir: explorerCurrentDir,
|
currentDir: explorerCurrentDir,
|
||||||
})
|
})
|
||||||
@@ -1497,11 +1617,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
sidebarManager.registerPanel('git', {
|
sidebarManager.registerPanel('git', {
|
||||||
title: 'Git',
|
title: 'Git',
|
||||||
render: (container) => getRenderGitPanel()(container, {
|
render: (container) => getRenderGitPanel()(container, {
|
||||||
gitStatus: () => git.status(tab?.filePath ? path.dirname(tab.filePath) : null),
|
gitStatus: () => ipcRenderer.invoke('git-status'),
|
||||||
gitDiff: (file) => git.diff(path.dirname(file) || null, file),
|
gitDiff: (file) => ipcRenderer.invoke('git-diff', { file }),
|
||||||
gitStage: (files) => git.stage(path.dirname(files[0]) || null, files),
|
gitStage: (files) => ipcRenderer.invoke('git-stage', { files }),
|
||||||
gitCommit: (message) => git.commit(getGitRepoPath(), message),
|
gitCommit: (message) => ipcRenderer.invoke('git-commit', { message }),
|
||||||
gitLog: () => git.log(getGitRepoPath()),
|
gitLog: () => ipcRenderer.invoke('git-log'),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
sidebarManager.registerPanel('snippets', {
|
sidebarManager.registerPanel('snippets', {
|
||||||
@@ -1516,7 +1636,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
sidebarManager.registerPanel('templates', {
|
sidebarManager.registerPanel('templates', {
|
||||||
title: 'Templates',
|
title: 'Templates',
|
||||||
render: (container) => getRenderTemplatesPanel()(container, async (file) => {
|
render: (container) => getRenderTemplatesPanel()(container, async (file) => {
|
||||||
const templateContent = await ipcRenderer.invoke('load-template', file),
|
const templateContent = await ipcRenderer.invoke('load-template', file);
|
||||||
if (templateContent) {
|
if (templateContent) {
|
||||||
const content = templateContent.replace(/\{\{DATE\}\}/g, new Date().toISOString().split('T')[0]);
|
const content = templateContent.replace(/\{\{DATE\}\}/g, new Date().toISOString().split('T')[0]);
|
||||||
tabManager.createNewTab();
|
tabManager.createNewTab();
|
||||||
@@ -1625,7 +1745,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
|
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
|
||||||
|
|
||||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
if (tab) {
|
// Only show welcome if no file was opened while we were awaiting
|
||||||
|
if (tab && !tab.filePath && tab.content === '') {
|
||||||
tab.title = 'Welcome';
|
tab.title = 'Welcome';
|
||||||
tab.content = '';
|
tab.content = '';
|
||||||
const preview = document.getElementById(`preview-${tab.id}`);
|
const preview = document.getElementById(`preview-${tab.id}`);
|
||||||
@@ -1767,6 +1888,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection(''));
|
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection(''));
|
||||||
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
|
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
|
||||||
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
|
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
|
||||||
|
commandPalette.register('Load Custom Preview CSS', '', triggerLoadCustomCSS);
|
||||||
|
commandPalette.register('Clear Custom Preview CSS', '', triggerClearCustomCSS);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
@@ -1792,27 +1915,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize CodeMirror for the initial tab (tab 1)
|
// Initialize CodeMirror for the initial tab (tab 1)
|
||||||
const initialEditorContainer = document.getElementById('editor-cm-1');
|
const initialTab = tabManager.tabs.get(1);
|
||||||
if (initialEditorContainer) {
|
if (initialTab) {
|
||||||
const tab = tabManager.tabs.get(1);
|
tabManager.ensureEditor(initialTab);
|
||||||
const isDark = document.body.className.includes('dark');
|
|
||||||
tab.editorView = createEditor(initialEditorContainer, {
|
|
||||||
content: tab.content,
|
|
||||||
onChange: (newContent) => {
|
|
||||||
tab.content = newContent;
|
|
||||||
tab.isDirty = true;
|
|
||||||
tabManager.updatePreview(tab.id);
|
|
||||||
tabManager.updateWordCount();
|
|
||||||
tabManager.updateTabBar();
|
|
||||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
|
||||||
},
|
|
||||||
onUpdate: (view) => {
|
|
||||||
tabManager.updateCursorPosition(view);
|
|
||||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
|
||||||
},
|
|
||||||
isDark,
|
|
||||||
showLineNumbers: tabManager.showLineNumbers,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request current theme
|
// Request current theme
|
||||||
@@ -1837,20 +1942,33 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// IPC event listeners
|
// IPC event listeners
|
||||||
events.onFileNew(() => {
|
// IPC event listeners
|
||||||
|
ipcRenderer.on('file-new', () => {
|
||||||
tabManager.createNewTab();
|
tabManager.createNewTab();
|
||||||
});
|
});
|
||||||
|
|
||||||
events.onFileOpened((event, data) => {
|
ipcRenderer.on('file-opened', (event, data) => {
|
||||||
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
|
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
|
||||||
|
console.log('[RENDERER] activeTabId before openFile:', tabManager.activeTabId);
|
||||||
|
// Diagnostic: check library state
|
||||||
|
console.log('[RENDERER] Library state:', {
|
||||||
|
marked: typeof marked,
|
||||||
|
DOMPurify: typeof DOMPurify,
|
||||||
|
markedParse: typeof marked?.parse,
|
||||||
|
dompurifySanitize: typeof DOMPurify?.sanitize,
|
||||||
|
tabManagerExists: !!tabManager
|
||||||
|
});
|
||||||
if (tabManager) {
|
if (tabManager) {
|
||||||
tabManager.openFile(data.path, data.content);
|
tabManager.openFile(data.path, data.content);
|
||||||
|
console.log('[RENDERER] activeTabId after openFile:', tabManager.activeTabId);
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
console.log('[RENDERER] tab state:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length });
|
||||||
} else {
|
} else {
|
||||||
console.error('[RENDERER] tabManager not initialized!');
|
console.error('[RENDERER] tabManager not initialized!');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
events.onFileSave(() => {
|
ipcRenderer.on('file-save', () => {
|
||||||
const currentContent = tabManager.getCurrentContent();
|
const currentContent = tabManager.getCurrentContent();
|
||||||
const currentFilePath = tabManager.getCurrentFilePath();
|
const currentFilePath = tabManager.getCurrentFilePath();
|
||||||
// send to main process which will save or trigger save-as dialog
|
// send to main process which will save or trigger save-as dialog
|
||||||
@@ -1878,12 +1996,12 @@ ipcRenderer.on('get-content-for-spreadsheet', (event, format) => {
|
|||||||
ipcRenderer.send('export-spreadsheet', { content: currentContent, format });
|
ipcRenderer.send('export-spreadsheet', { content: currentContent, format });
|
||||||
});
|
});
|
||||||
|
|
||||||
events.onTogglePreview(() => {
|
ipcRenderer.on('toggle-preview', () => {
|
||||||
tabManager.isPreviewVisible = !tabManager.isPreviewVisible;
|
tabManager.isPreviewVisible = !tabManager.isPreviewVisible;
|
||||||
tabManager.updatePreviewVisibility();
|
tabManager.updatePreviewVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
events.onToggleFind(() => {
|
ipcRenderer.on('toggle-find', () => {
|
||||||
if (window.modals.findModal.isOpen()) {
|
if (window.modals.findModal.isOpen()) {
|
||||||
window.modals.findModal.close();
|
window.modals.findModal.close();
|
||||||
} else {
|
} else {
|
||||||
@@ -1892,7 +2010,7 @@ events.onToggleFind(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
events.onThemeChanged((event, theme) => {
|
ipcRenderer.on('theme-changed', (event, theme) => {
|
||||||
console.log('[RENDERER] Theme changed to:', theme);
|
console.log('[RENDERER] Theme changed to:', theme);
|
||||||
document.body.className = `theme-${theme}`;
|
document.body.className = `theme-${theme}`;
|
||||||
|
|
||||||
@@ -1905,7 +2023,7 @@ events.onThemeChanged((event, theme) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Undo/Redo handlers — delegate to CodeMirror's built-in history
|
// Undo/Redo handlers — delegate to CodeMirror's built-in history
|
||||||
events.onUndo(() => {
|
ipcRenderer.on('undo', () => {
|
||||||
if (tabManager) {
|
if (tabManager) {
|
||||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
if (tab?.editorView) {
|
if (tab?.editorView) {
|
||||||
@@ -1914,7 +2032,7 @@ events.onUndo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
events.onRedo(() => {
|
ipcRenderer.on('redo', () => {
|
||||||
if (tabManager) {
|
if (tabManager) {
|
||||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
if (tab?.editorView) {
|
if (tab?.editorView) {
|
||||||
@@ -1923,6 +2041,49 @@ events.onRedo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom Preview CSS event handlers and trigger helpers
|
||||||
|
function applyCustomPreviewCSS(cssContent) {
|
||||||
|
let styleTag = document.getElementById('custom-preview-style');
|
||||||
|
if (!styleTag) {
|
||||||
|
styleTag = document.createElement('style');
|
||||||
|
styleTag.id = 'custom-preview-style';
|
||||||
|
document.head.appendChild(styleTag);
|
||||||
|
}
|
||||||
|
const scopedCSS = scopeCSS(cssContent, '.preview-content');
|
||||||
|
styleTag.textContent = scopedCSS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerLoadCustomCSS() {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.invoke('select-custom-css');
|
||||||
|
if (result) {
|
||||||
|
console.log('[RENDERER] Custom CSS loaded from:', result.path);
|
||||||
|
localStorage.setItem('customPreviewCSSPath', result.path);
|
||||||
|
localStorage.setItem('customPreviewCSSContent', result.content);
|
||||||
|
applyCustomPreviewCSS(result.content);
|
||||||
|
notifyUser('Custom preview stylesheet applied successfully.', 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RENDERER] Error loading custom CSS:', err);
|
||||||
|
notifyUser('Failed to load custom CSS stylesheet.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerClearCustomCSS() {
|
||||||
|
localStorage.removeItem('customPreviewCSSPath');
|
||||||
|
localStorage.removeItem('customPreviewCSSContent');
|
||||||
|
const styleTag = document.getElementById('custom-preview-style');
|
||||||
|
if (styleTag) {
|
||||||
|
styleTag.remove();
|
||||||
|
}
|
||||||
|
notifyUser('Custom preview stylesheet cleared.', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.on('load-custom-css', triggerLoadCustomCSS);
|
||||||
|
ipcRenderer.on('clear-custom-css', triggerClearCustomCSS);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Font size adjustment
|
// Font size adjustment
|
||||||
let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15;
|
let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15;
|
||||||
|
|
||||||
@@ -2042,13 +2203,39 @@ function initializeExportForm(format) {
|
|||||||
document.getElementById('export-citeproc').checked = false;
|
document.getElementById('export-citeproc').checked = false;
|
||||||
document.getElementById('export-toc-depth').value = 3;
|
document.getElementById('export-toc-depth').value = 3;
|
||||||
|
|
||||||
// PDF-specific fields
|
// Toggle and reset PDF-specific fields
|
||||||
|
const pdfOnly = document.querySelector('.pdf-only');
|
||||||
|
if (pdfOnly) {
|
||||||
|
pdfOnly.style.display = (format === 'pdf') ? 'block' : 'none';
|
||||||
|
}
|
||||||
if (format === 'pdf') {
|
if (format === 'pdf') {
|
||||||
document.getElementById('pdf-engine').value = 'xelatex';
|
document.getElementById('pdf-engine').value = 'xelatex';
|
||||||
document.getElementById('pdf-geometry').value = 'margin=1in';
|
document.getElementById('pdf-geometry').value = 'margin=1in';
|
||||||
document.getElementById('custom-geometry').style.display = 'none';
|
document.getElementById('custom-geometry').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle and reset Reveal.js-specific fields
|
||||||
|
const revealjsOnly = document.querySelector('.revealjs-only');
|
||||||
|
if (revealjsOnly) {
|
||||||
|
revealjsOnly.style.display = (format === 'revealjs') ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
const revealTheme = document.getElementById('reveal-theme');
|
||||||
|
if (revealTheme) revealTheme.value = 'black';
|
||||||
|
const revealTransition = document.getElementById('reveal-transition');
|
||||||
|
if (revealTransition) revealTransition.value = 'slide';
|
||||||
|
const revealSpeed = document.getElementById('reveal-speed');
|
||||||
|
if (revealSpeed) revealSpeed.value = 'default';
|
||||||
|
const revealSlideNumber = document.getElementById('reveal-slide-number');
|
||||||
|
if (revealSlideNumber) revealSlideNumber.checked = false;
|
||||||
|
const revealControls = document.getElementById('reveal-controls');
|
||||||
|
if (revealControls) revealControls.checked = true;
|
||||||
|
const revealProgress = document.getElementById('reveal-progress');
|
||||||
|
if (revealProgress) revealProgress.checked = true;
|
||||||
|
const revealHistory = document.getElementById('reveal-history');
|
||||||
|
if (revealHistory) revealHistory.checked = true;
|
||||||
|
const revealCenter = document.getElementById('reveal-center');
|
||||||
|
if (revealCenter) revealCenter.checked = true;
|
||||||
|
|
||||||
// Clear bibliography fields
|
// Clear bibliography fields
|
||||||
document.getElementById('bibliography-file').value = '';
|
document.getElementById('bibliography-file').value = '';
|
||||||
document.getElementById('csl-file').value = '';
|
document.getElementById('csl-file').value = '';
|
||||||
@@ -2108,6 +2295,18 @@ function collectExportOptions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reveal.js-specific options
|
||||||
|
if (currentExportFormat === 'revealjs') {
|
||||||
|
options.revealTheme = document.getElementById('reveal-theme').value;
|
||||||
|
options.revealTransition = document.getElementById('reveal-transition').value;
|
||||||
|
options.revealTransitionSpeed = document.getElementById('reveal-speed').value;
|
||||||
|
options.revealSlideNumber = document.getElementById('reveal-slide-number').checked;
|
||||||
|
options.revealControls = document.getElementById('reveal-controls').checked;
|
||||||
|
options.revealProgress = document.getElementById('reveal-progress').checked;
|
||||||
|
options.revealHistory = document.getElementById('reveal-history').checked;
|
||||||
|
options.revealCenter = document.getElementById('reveal-center').checked;
|
||||||
|
}
|
||||||
|
|
||||||
// Bibliography
|
// Bibliography
|
||||||
const bibFile = document.getElementById('bibliography-file').value.trim();
|
const bibFile = document.getElementById('bibliography-file').value.trim();
|
||||||
const cslFile = document.getElementById('csl-file').value.trim();
|
const cslFile = document.getElementById('csl-file').value.trim();
|
||||||
@@ -2118,6 +2317,15 @@ function collectExportOptions() {
|
|||||||
if (currentExportFormat === 'pdf') {
|
if (currentExportFormat === 'pdf') {
|
||||||
options.pdfEngine = 'xelatex';
|
options.pdfEngine = 'xelatex';
|
||||||
options.geometry = 'margin=1in';
|
options.geometry = 'margin=1in';
|
||||||
|
} else if (currentExportFormat === 'revealjs') {
|
||||||
|
options.revealTheme = 'black';
|
||||||
|
options.revealTransition = 'slide';
|
||||||
|
options.revealTransitionSpeed = 'default';
|
||||||
|
options.revealSlideNumber = false;
|
||||||
|
options.revealControls = true;
|
||||||
|
options.revealProgress = true;
|
||||||
|
options.revealHistory = true;
|
||||||
|
options.revealCenter = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3035,6 +3243,18 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
section.classList.add('hidden');
|
section.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide visual thumbnail sidebar by default
|
||||||
|
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.classList.add('hidden');
|
||||||
|
}
|
||||||
|
const body = document.querySelector('.pdf-editor-body');
|
||||||
|
if (body) {
|
||||||
|
body.classList.remove('side-by-side');
|
||||||
|
}
|
||||||
|
pdfDocInstance = null;
|
||||||
|
pdfPreviewState = { pages: [], filePath: '' };
|
||||||
|
|
||||||
// Show the appropriate section and set title
|
// Show the appropriate section and set title
|
||||||
let sectionId, titleText;
|
let sectionId, titleText;
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
@@ -3068,7 +3288,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
titleText = 'Rotate Pages';
|
titleText = 'Rotate Pages';
|
||||||
if (openedFilePath) {
|
if (openedFilePath) {
|
||||||
const rotateInput = document.getElementById('rotate-input-path');
|
const rotateInput = document.getElementById('rotate-input-path');
|
||||||
if (rotateInput) rotateInput.value = openedFilePath;
|
if (rotateInput) {
|
||||||
|
rotateInput.value = openedFilePath;
|
||||||
|
// Trigger thumbnail generation
|
||||||
|
setTimeout(() => onPDFFileSelected('rotate-input-path', openedFilePath), 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
@@ -3076,7 +3300,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
titleText = 'Delete Pages';
|
titleText = 'Delete Pages';
|
||||||
if (openedFilePath) {
|
if (openedFilePath) {
|
||||||
const deleteInput = document.getElementById('delete-input-path');
|
const deleteInput = document.getElementById('delete-input-path');
|
||||||
if (deleteInput) deleteInput.value = openedFilePath;
|
if (deleteInput) {
|
||||||
|
deleteInput.value = openedFilePath;
|
||||||
|
// Trigger thumbnail generation
|
||||||
|
setTimeout(() => onPDFFileSelected('delete-input-path', openedFilePath), 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'reorder':
|
case 'reorder':
|
||||||
@@ -3084,7 +3312,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
titleText = 'Reorder Pages';
|
titleText = 'Reorder Pages';
|
||||||
if (openedFilePath) {
|
if (openedFilePath) {
|
||||||
const reorderInput = document.getElementById('reorder-input-path');
|
const reorderInput = document.getElementById('reorder-input-path');
|
||||||
if (reorderInput) reorderInput.value = openedFilePath;
|
if (reorderInput) {
|
||||||
|
reorderInput.value = openedFilePath;
|
||||||
|
// Trigger thumbnail generation
|
||||||
|
setTimeout(() => onPDFFileSelected('reorder-input-path', openedFilePath), 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'watermark':
|
case 'watermark':
|
||||||
@@ -3245,6 +3477,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
document.getElementById(button.inputId).value = file.path;
|
document.getElementById(button.inputId).value = file.path;
|
||||||
|
onPDFFileSelected(button.inputId, file.path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
@@ -3254,6 +3487,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
document.getElementById(button.inputId).value = file.path;
|
document.getElementById(button.inputId).value = file.path;
|
||||||
|
onPDFFileSelected(button.inputId, file.path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
@@ -3398,7 +3632,7 @@ function showPDFValidationMessage(message, focusSelector = null) {
|
|||||||
// Process PDF Operation
|
// Process PDF Operation
|
||||||
function processPDFOperation() {
|
function processPDFOperation() {
|
||||||
const operation = currentPDFOperation;
|
const operation = currentPDFOperation;
|
||||||
let operationData = { operation };
|
const operationData = { operation };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
@@ -4188,7 +4422,7 @@ function textToASCII(text, style) {
|
|||||||
lines[i] = '';
|
lines[i] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let char of upperText) {
|
for (const char of upperText) {
|
||||||
const charLines = font.chars[char] || font.chars[' '];
|
const charLines = font.chars[char] || font.chars[' '];
|
||||||
if (charLines) {
|
if (charLines) {
|
||||||
for (let i = 0; i < font.height; i++) {
|
for (let i = 0; i < font.height; i++) {
|
||||||
@@ -4226,7 +4460,7 @@ function createASCIIBox(text, style, padding) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Content lines
|
// Content lines
|
||||||
for (let line of lines) {
|
for (const line of lines) {
|
||||||
const paddedLine = line.padEnd(maxLength, ' ');
|
const paddedLine = line.padEnd(maxLength, ' ');
|
||||||
result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n';
|
result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n';
|
||||||
}
|
}
|
||||||
@@ -4862,3 +5096,224 @@ ipcRenderer.on('show-video-tool', (event, tool) => {
|
|||||||
{ duration: 5000 }
|
{ duration: 5000 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PDF EDITOR VISUAL PAGE SIDEBAR HANDLERS
|
||||||
|
// ============================================
|
||||||
|
let pdfDocInstance = null;
|
||||||
|
let pdfPreviewState = { pages: [], filePath: '' };
|
||||||
|
|
||||||
|
function onPDFFileSelected(inputId, filePath) {
|
||||||
|
const eligibleInputs = ['rotate-input-path', 'delete-input-path', 'reorder-input-path'];
|
||||||
|
if (!eligibleInputs.includes(inputId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
const body = document.querySelector('.pdf-editor-body');
|
||||||
|
if (body) {
|
||||||
|
body.classList.add('side-by-side');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPDFThumbnails(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPDFThumbnails(filePath) {
|
||||||
|
const grid = document.getElementById('pdf-thumbnail-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
// Reset previous inputs
|
||||||
|
const rotateInput = document.getElementById('rotate-pages');
|
||||||
|
if (rotateInput) rotateInput.value = '';
|
||||||
|
const deleteInput = document.getElementById('delete-pages');
|
||||||
|
if (deleteInput) deleteInput.value = '';
|
||||||
|
const reorderInput = document.getElementById('reorder-pages');
|
||||||
|
if (reorderInput) reorderInput.value = '';
|
||||||
|
|
||||||
|
grid.innerHTML = '<div class="loading-thumbnails">Loading page thumbnails...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfjs = getPdfjsLib();
|
||||||
|
const loadingTask = pdfjs.getDocument(filePath);
|
||||||
|
pdfDocInstance = await loadingTask.promise;
|
||||||
|
|
||||||
|
pdfPreviewState.filePath = filePath;
|
||||||
|
pdfPreviewState.pages = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= pdfDocInstance.numPages; i++) {
|
||||||
|
pdfPreviewState.pages.push({
|
||||||
|
originalPageNum: i,
|
||||||
|
rotation: 0,
|
||||||
|
isDeleted: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderThumbnailGrid();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading PDF thumbnails:', err);
|
||||||
|
grid.innerHTML = '<div class="thumbnail-error">Failed to load PDF thumbnails.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThumbnailGrid() {
|
||||||
|
const grid = document.getElementById('pdf-thumbnail-grid');
|
||||||
|
if (!grid || !pdfDocInstance) return;
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
pdfPreviewState.pages.forEach((pageInfo, index) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'pdf-thumbnail-card';
|
||||||
|
if (pageInfo.isDeleted) {
|
||||||
|
card.classList.add('marked-delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'pdf-thumbnail-header';
|
||||||
|
heading.innerHTML = `<span class="badge">Page ${pageInfo.originalPageNum}</span>`;
|
||||||
|
|
||||||
|
const canvasContainer = document.createElement('div');
|
||||||
|
canvasContainer.className = 'canvas-container';
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'pdf-thumbnail-canvas';
|
||||||
|
if (pageInfo.rotation > 0) {
|
||||||
|
canvas.classList.add('rot' + pageInfo.rotation);
|
||||||
|
}
|
||||||
|
canvasContainer.appendChild(canvas);
|
||||||
|
|
||||||
|
// Render thumbnail canvas
|
||||||
|
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
|
||||||
|
|
||||||
|
const controlsRow = document.createElement('div');
|
||||||
|
controlsRow.className = 'pdf-thumbnail-controls';
|
||||||
|
|
||||||
|
// Rotate Button
|
||||||
|
const rotateBtn = document.createElement('button');
|
||||||
|
rotateBtn.type = 'button';
|
||||||
|
rotateBtn.className = 'btn-rotate-thumbnail';
|
||||||
|
rotateBtn.innerHTML = '↻ Rotate 90°';
|
||||||
|
rotateBtn.addEventListener('click', () => {
|
||||||
|
pageInfo.rotation = (pageInfo.rotation + 90) % 360;
|
||||||
|
canvas.className = 'pdf-thumbnail-canvas';
|
||||||
|
if (pageInfo.rotation > 0) {
|
||||||
|
canvas.classList.add('rot' + pageInfo.rotation);
|
||||||
|
}
|
||||||
|
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
|
||||||
|
syncRotateInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Checkbox
|
||||||
|
const deleteLabel = document.createElement('label');
|
||||||
|
deleteLabel.className = 'delete-thumbnail-checkbox';
|
||||||
|
|
||||||
|
const deleteChk = document.createElement('input');
|
||||||
|
deleteChk.type = 'checkbox';
|
||||||
|
deleteChk.checked = pageInfo.isDeleted;
|
||||||
|
deleteChk.addEventListener('change', (e) => {
|
||||||
|
pageInfo.isDeleted = e.target.checked;
|
||||||
|
card.classList.toggle('marked-delete', pageInfo.isDeleted);
|
||||||
|
syncDeleteInput();
|
||||||
|
});
|
||||||
|
deleteLabel.appendChild(deleteChk);
|
||||||
|
deleteLabel.appendChild(document.createTextNode(' Delete'));
|
||||||
|
|
||||||
|
// Reorder buttons
|
||||||
|
const reorderDiv = document.createElement('div');
|
||||||
|
reorderDiv.className = 'pdf-thumbnail-reorder';
|
||||||
|
|
||||||
|
const leftBtn = document.createElement('button');
|
||||||
|
leftBtn.type = 'button';
|
||||||
|
leftBtn.className = 'btn-reorder-left';
|
||||||
|
leftBtn.innerHTML = '◀';
|
||||||
|
leftBtn.disabled = (index === 0);
|
||||||
|
leftBtn.addEventListener('click', () => {
|
||||||
|
const temp = pdfPreviewState.pages[index];
|
||||||
|
pdfPreviewState.pages[index] = pdfPreviewState.pages[index - 1];
|
||||||
|
pdfPreviewState.pages[index - 1] = temp;
|
||||||
|
renderThumbnailGrid();
|
||||||
|
syncReorderInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightBtn = document.createElement('button');
|
||||||
|
rightBtn.type = 'button';
|
||||||
|
rightBtn.className = 'btn-reorder-right';
|
||||||
|
rightBtn.innerHTML = '▶';
|
||||||
|
rightBtn.disabled = (index === pdfPreviewState.pages.length - 1);
|
||||||
|
rightBtn.addEventListener('click', () => {
|
||||||
|
const temp = pdfPreviewState.pages[index];
|
||||||
|
pdfPreviewState.pages[index] = pdfPreviewState.pages[index + 1];
|
||||||
|
pdfPreviewState.pages[index + 1] = temp;
|
||||||
|
renderThumbnailGrid();
|
||||||
|
syncReorderInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
reorderDiv.appendChild(leftBtn);
|
||||||
|
reorderDiv.appendChild(rightBtn);
|
||||||
|
|
||||||
|
controlsRow.appendChild(rotateBtn);
|
||||||
|
controlsRow.appendChild(deleteLabel);
|
||||||
|
controlsRow.appendChild(reorderDiv);
|
||||||
|
|
||||||
|
card.appendChild(heading);
|
||||||
|
card.appendChild(canvasContainer);
|
||||||
|
card.appendChild(controlsRow);
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderThumbnail(pdfDoc, originalPageNum, canvasElement, rotation) {
|
||||||
|
try {
|
||||||
|
const page = await pdfDoc.getPage(originalPageNum);
|
||||||
|
const ctx = canvasElement.getContext('2d');
|
||||||
|
|
||||||
|
// Render at a fixed width of 100px
|
||||||
|
const unscaledViewport = page.getViewport({ scale: 1, rotation: 0 });
|
||||||
|
const scale = 100 / unscaledViewport.width;
|
||||||
|
const viewport = page.getViewport({ scale: scale, rotation: 0 });
|
||||||
|
|
||||||
|
canvasElement.width = viewport.width;
|
||||||
|
canvasElement.height = viewport.height;
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport: viewport
|
||||||
|
}).promise;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error rendering page canvas:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRotateInput() {
|
||||||
|
const rotatedPages = pdfPreviewState.pages
|
||||||
|
.filter(p => p.rotation > 0)
|
||||||
|
.map(p => p.originalPageNum);
|
||||||
|
|
||||||
|
const rotateInput = document.getElementById('rotate-pages');
|
||||||
|
if (rotateInput) {
|
||||||
|
rotateInput.value = rotatedPages.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDeleteInput() {
|
||||||
|
const deletedPages = pdfPreviewState.pages
|
||||||
|
.filter(p => p.isDeleted)
|
||||||
|
.map(p => p.originalPageNum);
|
||||||
|
|
||||||
|
const deleteInput = document.getElementById('delete-pages');
|
||||||
|
if (deleteInput) {
|
||||||
|
deleteInput.value = deletedPages.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReorderInput() {
|
||||||
|
const order = pdfPreviewState.pages.map(p => p.originalPageNum);
|
||||||
|
const reorderInput = document.getElementById('reorder-pages');
|
||||||
|
if (reorderInput) {
|
||||||
|
reorderInput.value = order.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { AppShell } from './components/layout/AppShell';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <AppShell />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
|
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { lightTheme, lightHighlight } from './themes/light';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bufferId: string;
|
||||||
|
initialContent: string;
|
||||||
|
onChange?: (content: string) => void;
|
||||||
|
onCursorChange?: (line: number, column: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const themeCompartment = useRef(new Compartment());
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const updateContent = useEditorStore((s) => s.updateContent);
|
||||||
|
const setCursor = useEditorStore((s) => s.setCursor);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: initialContent,
|
||||||
|
extensions: [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||||
|
autocompletion(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
|
||||||
|
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.updateListener.of((v) => {
|
||||||
|
if (v.docChanged) {
|
||||||
|
const content = v.state.doc.toString();
|
||||||
|
updateContent(bufferId, content);
|
||||||
|
onChange?.(content);
|
||||||
|
}
|
||||||
|
if (v.selectionSet || v.docChanged) {
|
||||||
|
const head = v.state.selection.main.head;
|
||||||
|
const line = v.state.doc.lineAt(head);
|
||||||
|
const lineNo = line.number;
|
||||||
|
const col = head - line.from + 1;
|
||||||
|
setCursor(bufferId, lineNo, col);
|
||||||
|
onCursorChange?.(lineNo, col);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const view = new EditorView({ state, parent: ref.current });
|
||||||
|
viewRef.current = view;
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bufferId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.current.reconfigure(
|
||||||
|
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
return <div ref={ref} className="h-full overflow-hidden" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { usePreviewStore } from '@/stores/preview-store';
|
||||||
|
|
||||||
|
export function EditorPane() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
const setPreviewSource = usePreviewStore((s) => s.setSource);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (buf) setPreviewSource(buf.content);
|
||||||
|
}, [buf?.id, buf?.content, buf, setPreviewSource]);
|
||||||
|
|
||||||
|
if (!buf) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
|
||||||
|
<p>No file open. Use File → Open to start.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<CodeMirrorEditor
|
||||||
|
key={buf.id}
|
||||||
|
bufferId={buf.id}
|
||||||
|
initialContent={buf.content}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#0d0b09',
|
||||||
|
cursor: '#e5461f',
|
||||||
|
selection: 'rgba(229, 70, 31, 0.15)',
|
||||||
|
gutterBackground: '#fafbfc',
|
||||||
|
gutterForeground: '#7a7878',
|
||||||
|
lineHighlight: 'rgba(0, 0, 0, 0.04)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
color: colors.foreground,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: colors.cursor,
|
||||||
|
fontFamily: 'JetBrains Mono, Fira Code, monospace',
|
||||||
|
fontSize: '13.5px',
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: colors.selection,
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: colors.gutterBackground,
|
||||||
|
color: colors.gutterForeground,
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: colors.lineHighlight },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' },
|
||||||
|
},
|
||||||
|
{ dark: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.heading1, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading2, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading3, color: '#464646', fontWeight: '600' },
|
||||||
|
{ tag: t.link, color: '#e5461f', textDecoration: 'underline' },
|
||||||
|
{ tag: t.url, color: '#e5461f' },
|
||||||
|
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||||
|
{ tag: t.strong, fontWeight: '700' },
|
||||||
|
{ tag: t.monospace, color: '#c93a18' },
|
||||||
|
{ tag: t.list, color: '#0ea5e9' },
|
||||||
|
{ tag: t.quote, color: '#7a7878', fontStyle: 'italic' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const lightHighlight = syntaxHighlighting(highlightStyle);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { PanelLeft, PanelRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { sidebarVisible, previewVisible, toggleSidebar, togglePreview } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
|
||||||
|
aria-label="MarkdownConverter logo"
|
||||||
|
/>
|
||||||
|
<h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
aria-pressed={sidebarVisible}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle preview"
|
||||||
|
aria-pressed={previewVisible}
|
||||||
|
onClick={togglePreview}
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { AppHeader } from './AppHeader';
|
||||||
|
import { TabBar } from './TabBar';
|
||||||
|
import { Toolbar } from './Toolbar';
|
||||||
|
import { Breadcrumb } from './Breadcrumb';
|
||||||
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||||
|
import { Sidebar } from '@/components/sidebar/Sidebar';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
||||||
|
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
useFileShortcuts();
|
||||||
|
useRestoreLastFolder();
|
||||||
|
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
<AppHeader />
|
||||||
|
<TabBar />
|
||||||
|
<Toolbar />
|
||||||
|
<Breadcrumb />
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
|
||||||
|
>
|
||||||
|
{sidebarVisible && (
|
||||||
|
<>
|
||||||
|
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
||||||
|
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
||||||
|
<Sidebar />
|
||||||
|
</aside>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
||||||
|
<EditorPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
{previewVisible && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
||||||
|
<PreviewPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</main>
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function Breadcrumb() {
|
||||||
|
return (
|
||||||
|
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No file selected</span>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
const wordCount = buf ? countWords(buf.content) : 0;
|
||||||
|
const cursor = buf?.cursor ?? { line: 1, column: 1 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>{wordCount} words</span>
|
||||||
|
<span>UTF-8</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
||||||
|
<span>Markdown</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { useFileStore, type OpenTab } from '@/stores/file-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function SortableTab({
|
||||||
|
tab,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
tab: OpenTab;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: tab.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
data-testid={`tab-${tab.id}`}
|
||||||
|
className={cn(
|
||||||
|
'group flex h-full cursor-pointer select-none items-center gap-1 border-r border-border px-3 text-xs transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.dirty && (
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
|
||||||
|
aria-label="Unsaved changes"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[120px] truncate">{tab.title}</span>
|
||||||
|
<button
|
||||||
|
aria-label={`Close ${tab.title}`}
|
||||||
|
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose(tab.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabBar() {
|
||||||
|
const openTabs = useFileStore((s) => s.openTabs);
|
||||||
|
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||||
|
const setActiveTab = useFileStore((s) => s.setActiveTab);
|
||||||
|
const closeTab = useFileStore((s) => s.closeTab);
|
||||||
|
const reorderTabs = useFileStore((s) => s.reorderTabs);
|
||||||
|
|
||||||
|
// Require a small drag distance before activating — so a click on the tab
|
||||||
|
// body still triggers `onClick` (dnd-kit's PointerSensor default is 0).
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
|
if (openTabs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No files open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e: DragEndEvent) {
|
||||||
|
const { active, over } = e;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const fromIndex = openTabs.findIndex((t) => t.id === active.id);
|
||||||
|
const toIndex = openTabs.findIndex((t) => t.id === over.id);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return;
|
||||||
|
reorderTabs(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={openTabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
|
||||||
|
<div className="flex h-full items-center px-1">
|
||||||
|
{openTabs.map((tab) => (
|
||||||
|
<SortableTab
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
isActive={tab.id === activeTabId}
|
||||||
|
onSelect={setActiveTab}
|
||||||
|
onClose={closeTab}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function Toolbar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3">
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Bold"><Bold className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Italic"><Italic className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Unordered list"><List className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Ordered list"><ListOrdered className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Code"><Code className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Link"><LinkIcon className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { renderMarkdown } from '@/lib/markdown';
|
||||||
|
import { MermaidLazy } from './MermaidLazy';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
||||||
|
|
||||||
|
function extractMermaidCodes(source: string): string[] {
|
||||||
|
const codes: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = MERMAID_RE.exec(source)) !== null) {
|
||||||
|
codes.push(match[1].trim());
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ source }: Props) {
|
||||||
|
const html = renderMarkdown(source);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [mermaidCodes, setMermaidCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Re-derive mermaid codes from the original source. The renderMarkdown function
|
||||||
|
// replaces mermaid blocks with placeholders (data-mermaid-source="${idx}"), but
|
||||||
|
// it does not return the actual codes. Rather than thread them through the API,
|
||||||
|
// we re-extract from the source. Source is small (single buffer content) so this
|
||||||
|
// cost is negligible.
|
||||||
|
useEffect(() => {
|
||||||
|
setMermaidCodes(extractMermaidCodes(source));
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
{mermaidCodes.map((code, i) => (
|
||||||
|
<div key={i} className="my-4">
|
||||||
|
<MermaidLazy code={code} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mermaidModule: typeof import('mermaid').default | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
async function getMermaid() {
|
||||||
|
if (!mermaidModule) {
|
||||||
|
const mod = await import('mermaid');
|
||||||
|
mermaidModule = mod.default;
|
||||||
|
}
|
||||||
|
if (!initialized) {
|
||||||
|
mermaidModule.initialize({ startOnLoad: false, theme: 'default' });
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
return mermaidModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MermaidLazy({ code }: Props) {
|
||||||
|
const [svg, setSvg] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getMermaid()
|
||||||
|
.then((m) => m.render(idRef.current, code))
|
||||||
|
.then((rendered) => {
|
||||||
|
if (!cancelled) setSvg(rendered);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!cancelled) setError(err.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
|
||||||
|
if (!svg) return <div className="text-xs text-muted-foreground">Loading diagram…</div>;
|
||||||
|
return <div data-testid="mermaid-output" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
import { usePreviewStore } from '@/stores/preview-store';
|
||||||
|
import { useScrollSync } from '@/hooks/use-scroll-sync';
|
||||||
|
|
||||||
|
export function PreviewPane() {
|
||||||
|
const { source, setScrollRatio } = usePreviewStore();
|
||||||
|
const { handlePreviewScroll } = useScrollSync({ onPreviewScroll: setScrollRatio });
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<p>Nothing to preview. Start typing in the editor.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto bg-card/10" onScroll={handlePreviewScroll}>
|
||||||
|
<MarkdownRenderer source={source} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ChevronRight, Folder, FolderOpen, File, FileText } from 'lucide-react';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function FileTreeNode({ node, depth }: { node: import('@/stores/file-store').FileNode; depth: number }) {
|
||||||
|
const { expanded, activeTabId, loadChildren, toggleExpanded, openFile } = useFileStore();
|
||||||
|
const isExpanded = expanded.has(node.path);
|
||||||
|
const isActive = activeTabId === node.path;
|
||||||
|
|
||||||
|
if (node.isDirectory) {
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!node.loaded) {
|
||||||
|
await loadChildren(node.path);
|
||||||
|
}
|
||||||
|
toggleExpanded(node.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
|
||||||
|
isActive && 'bg-accent'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
className={cn('transition-transform duration-150', isExpanded && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
{isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />}
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && node.loaded && node.children && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => openFile(node.path)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
|
||||||
|
isActive && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileTree() {
|
||||||
|
const tree = useFileStore((s) => s.tree);
|
||||||
|
|
||||||
|
if (!tree) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1">
|
||||||
|
{tree.children?.map((child) => (
|
||||||
|
<FileTreeNode key={child.path} node={child} depth={0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
|
||||||
|
|
||||||
|
interface HeadingItem {
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
line: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHeadings(content: string): HeadingItem[] {
|
||||||
|
const headings: HeadingItem[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const re = new RegExp(HEADING_RE.source, 'gm');
|
||||||
|
let lineNum = 1;
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
match = re.exec(line);
|
||||||
|
if (match) {
|
||||||
|
headings.push({
|
||||||
|
level: match[1].length,
|
||||||
|
text: match[2].trim(),
|
||||||
|
line: lineNum,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lineNum++;
|
||||||
|
}
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outline() {
|
||||||
|
const activeId = useEditorStore((s) => s.activeId);
|
||||||
|
const buffers = useEditorStore((s) => s.buffers);
|
||||||
|
|
||||||
|
if (!activeId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
||||||
|
<span>No file open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = buffers.get(activeId);
|
||||||
|
if (!buffer) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
||||||
|
<span>No file open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headings = extractHeadings(buffer.content);
|
||||||
|
|
||||||
|
if (headings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
||||||
|
<span>No headings</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
|
{headings.map((h, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {}}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-0.5 text-left text-xs hover:bg-accent"
|
||||||
|
style={{ paddingLeft: `${(h.level - 1) * 16 + 8}px` }}
|
||||||
|
>
|
||||||
|
<span className="truncate text-muted-foreground">#{h.level}</span>
|
||||||
|
<span className="truncate">{h.text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChevronRight, FolderOpen } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { FileTree } from './FileTree';
|
||||||
|
import { Outline } from './Outline';
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const tree = useFileStore((s) => s.tree);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
{/* Files section */}
|
||||||
|
<Collapsible defaultOpen>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
||||||
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
|
Files
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||||
|
{!tree ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||||
|
<span>No folder opened</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => useFileStore.getState().openFolderDialog()}>
|
||||||
|
<FolderOpen className="mr-1" /> Open Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileTree />
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Outline section */}
|
||||||
|
<Collapsible defaultOpen>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
||||||
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
|
Outline
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||||
|
<Outline />
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Toggle theme" className="opacity-0">
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-4 w-4 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanelGroup.displayName = "ResizablePanelGroup"
|
||||||
|
|
||||||
|
const ResizablePanel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Panel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Panel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Panel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanel.displayName = "ResizablePanel"
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
)
|
||||||
|
ResizableHandle.displayName = "ResizableHandle"
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global keyboard shortcuts for file operations.
|
||||||
|
*
|
||||||
|
* - Cmd/Ctrl+O: open file
|
||||||
|
* - Cmd/Ctrl+Shift+O: open folder
|
||||||
|
* - Cmd/Ctrl+S: save active buffer
|
||||||
|
* - Cmd/Ctrl+W: close active tab
|
||||||
|
* - Cmd/Ctrl+Tab / Cmd/Ctrl+Shift+Tab: next/previous tab (wraps)
|
||||||
|
*
|
||||||
|
* Suppressed when an <input>, <textarea>, or contentEditable element has focus.
|
||||||
|
*/
|
||||||
|
export function useFileShortcuts(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(e: KeyboardEvent): void {
|
||||||
|
const mod = e.metaKey || e.ctrlKey;
|
||||||
|
if (!mod) return;
|
||||||
|
if (isTypingTarget(e.target)) return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
|
||||||
|
// Order matters: more specific (Shift) variants first so they don't get
|
||||||
|
// shadowed by the plain modifier-only branch.
|
||||||
|
if (e.shiftKey && key === 'o') {
|
||||||
|
e.preventDefault();
|
||||||
|
void useFileStore.getState().openFolderDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && key === 'o') {
|
||||||
|
e.preventDefault();
|
||||||
|
void useFileStore.getState().openFileDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
void useFileStore.getState().saveActiveBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && key === 'w') {
|
||||||
|
e.preventDefault();
|
||||||
|
const { activeTabId, closeTab } = useFileStore.getState();
|
||||||
|
if (activeTabId) closeTab(activeTabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
||||||
|
if (openTabs.length === 0) return;
|
||||||
|
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
|
||||||
|
const safeIdx = idx === -1 ? 0 : idx;
|
||||||
|
const nextIdx = e.shiftKey
|
||||||
|
? (safeIdx <= 0 ? openTabs.length - 1 : safeIdx - 1)
|
||||||
|
: (safeIdx >= openTabs.length - 1 ? 0 : safeIdx + 1);
|
||||||
|
setActiveTab(openTabs[nextIdx].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handle);
|
||||||
|
return () => window.removeEventListener('keydown', handle);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On mount, re-opens the last folder if one was persisted via the file store's
|
||||||
|
* zustand persist middleware. Idempotent — re-running with the same path is a
|
||||||
|
* no-op (openFolder's list IPC call would just re-fetch, which is acceptable).
|
||||||
|
*/
|
||||||
|
export function useRestoreLastFolder(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const { rootPath, openFolder, tree } = useFileStore.getState();
|
||||||
|
if (rootPath && !tree) {
|
||||||
|
void openFolder(rootPath);
|
||||||
|
}
|
||||||
|
// Run once on mount only. Persist hydration happens before AppShell renders.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
onEditorScroll?: (ratio: number) => void;
|
||||||
|
onPreviewScroll?: (ratio: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollSync(opts: Options) {
|
||||||
|
const FRAME_MS = 1000 / 60;
|
||||||
|
const lastTick = useRef(-FRAME_MS);
|
||||||
|
|
||||||
|
const handleEditorScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
||||||
|
const target = evt.currentTarget;
|
||||||
|
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastTick.current < FRAME_MS) return;
|
||||||
|
lastTick.current = now;
|
||||||
|
opts.onEditorScroll?.(ratio);
|
||||||
|
}, [opts]);
|
||||||
|
|
||||||
|
const handlePreviewScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
||||||
|
const target = evt.currentTarget;
|
||||||
|
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
||||||
|
opts.onPreviewScroll?.(ratio);
|
||||||
|
}, [opts]);
|
||||||
|
|
||||||
|
return { handleEditorScroll, handlePreviewScroll };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MarkdownConverter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="font-sans antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
IpcResult,
|
||||||
|
FileResult,
|
||||||
|
FileEntry,
|
||||||
|
PdfOptions,
|
||||||
|
DocxOptions,
|
||||||
|
HtmlOptions,
|
||||||
|
ExportResult,
|
||||||
|
BatchItem,
|
||||||
|
BatchOptions,
|
||||||
|
BatchResult,
|
||||||
|
} from '@/types/ipc';
|
||||||
|
|
||||||
|
type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string };
|
||||||
|
|
||||||
|
function wrap<T>(fn: () => Promise<T>): Promise<IpcResult<T | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fn().then(
|
||||||
|
(data) => ({ ok: true as const, data }),
|
||||||
|
(err: Error) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: { code: err.name || 'IPC_ERROR', message: err.message || String(err) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCall<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
channel: string,
|
||||||
|
method: string,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): Promise<IpcResult<Awaited<ReturnType<T>> | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = (window.electronAPI as any)[channel]?.[method];
|
||||||
|
if (!target) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Missing channel: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof target !== 'function') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Not a function: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wrap(() => target(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'open'),
|
||||||
|
read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'read', path),
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'write', path, content),
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'list', dir),
|
||||||
|
pickFolder: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'pickFolder'),
|
||||||
|
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'pickFile'),
|
||||||
|
onChange: (cb: (path: string) => void): (() => void) => {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
return window.electronAPI.file.onChange(cb);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'pdf', opts),
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'docx', opts),
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'html', opts),
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'batch', items, opts),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'getVersion'),
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'openExternal', url),
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'showItemInFolder', path),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
||||||
|
|
||||||
|
export function renderMarkdown(source: string): string {
|
||||||
|
// Mark mermaid blocks with a placeholder we can replace client-side.
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => {
|
||||||
|
const idx = placeholders.length;
|
||||||
|
placeholders.push(code.trim());
|
||||||
|
return `<div class="mermaid-block" data-mermaid-source="${idx}"></div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawHtml = marked.parse(withPlaceholders, { async: false }) as string;
|
||||||
|
const clean = DOMPurify.sanitize(rawHtml, {
|
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The sanitized HTML still has placeholders; we leave the actual mermaid
|
||||||
|
// rendering to the React layer (MermaidLazy component) so the heavy
|
||||||
|
// mermaid library only loads when needed.
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Transition, Variants } from 'motion/react';
|
||||||
|
|
||||||
|
export const fadeIn: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slideInRight: Variants = {
|
||||||
|
x: '100%',
|
||||||
|
initial: { x: '100%', opacity: 0 },
|
||||||
|
animate: { x: 0, opacity: 1, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] } },
|
||||||
|
exit: { x: '100%', opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modalPop: Variants = {
|
||||||
|
scale: 0.96,
|
||||||
|
opacity: 0,
|
||||||
|
initial: { scale: 0.96, opacity: 0 },
|
||||||
|
animate: { scale: 1, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } },
|
||||||
|
exit: { scale: 0.96, opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastSpring: Transition = {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sidebarToggle: Transition = {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
width: {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tabSwitch: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import { ThemeProvider } from './components/theme-provider';
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface PaneSizes {
|
||||||
|
sidebar: number;
|
||||||
|
editor: number;
|
||||||
|
preview: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
sidebarVisible: boolean;
|
||||||
|
previewVisible: boolean;
|
||||||
|
zenMode: boolean;
|
||||||
|
paneSizes: PaneSizes;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
togglePreview: () => void;
|
||||||
|
setZenMode: (value: boolean) => void;
|
||||||
|
setPaneSizes: (sizes: PaneSizes) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
|
||||||
|
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
|
||||||
|
setZenMode: (value) => set({ zenMode: value }),
|
||||||
|
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
|
||||||
|
}),
|
||||||
|
{ name: 'mc-app-store' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
|
export interface Buffer {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
dirty: boolean;
|
||||||
|
cursor?: { line: number; column: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
buffers: Map<string, Buffer>;
|
||||||
|
activeId: string | null;
|
||||||
|
openBuffer: (id: string, path: string, content: string) => void;
|
||||||
|
updateContent: (id: string, content: string) => void;
|
||||||
|
markSaved: (id: string) => void;
|
||||||
|
setCursor: (id: string, line: number, column: number) => void;
|
||||||
|
closeBuffer: (id: string) => void;
|
||||||
|
setActive: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>()(
|
||||||
|
immer((set) => ({
|
||||||
|
buffers: new Map(),
|
||||||
|
activeId: null,
|
||||||
|
openBuffer: (id, path, content) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.set(id, { id, path, content, dirty: false });
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
updateContent: (id, content) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) {
|
||||||
|
buf.content = content;
|
||||||
|
buf.dirty = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
markSaved: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.dirty = false;
|
||||||
|
}),
|
||||||
|
setCursor: (id, line, column) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.cursor = { line, column };
|
||||||
|
}),
|
||||||
|
closeBuffer: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.delete(id);
|
||||||
|
if (s.activeId === id) s.activeId = null;
|
||||||
|
}),
|
||||||
|
setActive: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import type { FileEntry } from '@/types/ipc';
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
children: FileNode[] | null;
|
||||||
|
expanded?: boolean;
|
||||||
|
loaded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenTab {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileState {
|
||||||
|
tree: FileNode | null;
|
||||||
|
rootPath: string | null;
|
||||||
|
expanded: Set<string>;
|
||||||
|
openTabs: OpenTab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
|
||||||
|
openFolder: (path: string) => Promise<void>;
|
||||||
|
loadChildren: (dirPath: string) => Promise<void>;
|
||||||
|
toggleExpanded: (dirPath: string) => void;
|
||||||
|
openFile: (filePath: string) => Promise<void>;
|
||||||
|
closeTab: (id: string) => void;
|
||||||
|
setActiveTab: (id: string) => void;
|
||||||
|
markTabClean: (id: string) => void;
|
||||||
|
markTabDirty: (id: string) => void;
|
||||||
|
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||||
|
openFolderDialog: () => Promise<void>;
|
||||||
|
openFileDialog: () => Promise<void>;
|
||||||
|
saveActiveBuffer: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToNode(entry: FileEntry): FileNode {
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
isDirectory: entry.isDirectory,
|
||||||
|
children: null, // lazy: start unloaded; loadChildren populates this
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively find and update a directory node by path using immer's produce
|
||||||
|
function updateNode(tree: FileNode, dirPath: string, updater: (node: FileNode) => void): boolean {
|
||||||
|
if (tree.path === dirPath && tree.isDirectory) {
|
||||||
|
updater(tree);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tree.children) {
|
||||||
|
for (const child of tree.children) {
|
||||||
|
if (updateNode(child, dirPath, updater)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFileStore = create<FileState>()(
|
||||||
|
persist(
|
||||||
|
immer((set) => ({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set<string>(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
|
||||||
|
openFolder: async (path) => {
|
||||||
|
const result = await ipc.file.list(path);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const children: FileNode[] = result.data!.map(entryToNode);
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
s.tree = {
|
||||||
|
name: path.split('/').filter(Boolean).pop() ?? path,
|
||||||
|
path,
|
||||||
|
isDirectory: true,
|
||||||
|
children,
|
||||||
|
loaded: true,
|
||||||
|
};
|
||||||
|
s.rootPath = path;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadChildren: async (dirPath) => {
|
||||||
|
// Find the node first
|
||||||
|
const tree = useFileStore.getState().tree;
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
updateNode(tree, dirPath, (node) => {
|
||||||
|
if (node.loaded === true) return;
|
||||||
|
found = true;
|
||||||
|
});
|
||||||
|
if (!found) return;
|
||||||
|
|
||||||
|
const result = await ipc.file.list(dirPath);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
updateNode(s.tree!, dirPath, (node) => {
|
||||||
|
node.children = result.data!.map(entryToNode);
|
||||||
|
node.loaded = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleExpanded: (dirPath) => {
|
||||||
|
set((s) => {
|
||||||
|
if (s.expanded.has(dirPath)) {
|
||||||
|
s.expanded.delete(dirPath);
|
||||||
|
} else {
|
||||||
|
s.expanded.add(dirPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openFile: async (filePath) => {
|
||||||
|
// If already open, sync dirty flag from editor buffer and activate
|
||||||
|
const existing = useFileStore.getState().openTabs.find((t) => t.id === filePath);
|
||||||
|
if (existing) {
|
||||||
|
const buffer = useEditorStore.getState().buffers.get(filePath);
|
||||||
|
set((s) => {
|
||||||
|
s.activeTabId = filePath;
|
||||||
|
if (buffer) {
|
||||||
|
const tab = s.openTabs.find((t) => t.id === filePath);
|
||||||
|
if (tab) tab.dirty = buffer.dirty;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ipc.file.read(filePath);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const content = result.data!;
|
||||||
|
const title = filePath.split('/').pop() ?? filePath;
|
||||||
|
|
||||||
|
// Open in editor store
|
||||||
|
useEditorStore.getState().openBuffer(filePath, filePath, content);
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
s.openTabs.push({ id: filePath, path: filePath, title, dirty: false });
|
||||||
|
s.activeTabId = filePath;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTab: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
const idx = s.openTabs.findIndex((t) => t.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
s.openTabs.splice(idx, 1);
|
||||||
|
if (s.activeTabId === id) {
|
||||||
|
s.activeTabId = idx > 0 ? s.openTabs[idx - 1]?.id ?? null : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveTab: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
s.activeTabId = id;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markTabClean: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
const tab = s.openTabs.find((t) => t.id === id);
|
||||||
|
if (tab) tab.dirty = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markTabDirty: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
const tab = s.openTabs.find((t) => t.id === id);
|
||||||
|
if (tab) tab.dirty = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderTabs: (fromIndex, toIndex) => {
|
||||||
|
set((s) => {
|
||||||
|
const tabs = s.openTabs;
|
||||||
|
const [moved] = tabs.splice(fromIndex, 1);
|
||||||
|
tabs.splice(toIndex, 0, moved);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openFolderDialog: async () => {
|
||||||
|
const result = await ipc.file.pickFolder();
|
||||||
|
if (!result.ok || result.data === null) return;
|
||||||
|
await useFileStore.getState().openFolder(result.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
openFileDialog: async () => {
|
||||||
|
const result = await ipc.file.pickFile();
|
||||||
|
if (!result.ok || result.data === null) return;
|
||||||
|
await useFileStore.getState().openFile(result.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveActiveBuffer: async () => {
|
||||||
|
const { activeTabId } = useFileStore.getState();
|
||||||
|
if (!activeTabId) return false;
|
||||||
|
|
||||||
|
const buffer = useEditorStore.getState().buffers.get(activeTabId);
|
||||||
|
if (!buffer) return false;
|
||||||
|
|
||||||
|
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
||||||
|
if (!writeResult.ok) return false;
|
||||||
|
|
||||||
|
useEditorStore.getState().markSaved(activeTabId);
|
||||||
|
useFileStore.getState().markTabClean(activeTabId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'mc-file-store',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
// Only persist the folder path; tree, expanded Set, and openTabs are
|
||||||
|
// rebuilt on demand. The app re-opens the folder on startup.
|
||||||
|
partialize: (state) => ({ rootPath: state.rootPath }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
source: string;
|
||||||
|
scrollRatio: number;
|
||||||
|
setSource: (s: string) => void;
|
||||||
|
setScrollRatio: (r: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pending: string = '';
|
||||||
|
|
||||||
|
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||||
|
source: '',
|
||||||
|
scrollRatio: 0,
|
||||||
|
setSource: (s) => {
|
||||||
|
pending = s;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
set({ source: pending });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
setScrollRatio: (r) => set({ scrollRatio: r }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 9%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 0 0% 96%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 90%;
|
||||||
|
--input: 0 0% 90%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 30 14% 4%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 30 14% 6%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 30 14% 6%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 30 6% 15%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 30 6% 15%;
|
||||||
|
--muted-foreground: 0 0% 63%;
|
||||||
|
--accent: 30 6% 15%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62% 30%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 30 6% 15%;
|
||||||
|
--input: 30 6% 15%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror integration */
|
||||||
|
.cm-editor {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
.cm-editor .cm-scroller {
|
||||||
|
@apply font-mono text-sm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// Mock window.electronAPI for tests
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||||
|
window.electronAPI = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock matchMedia for next-themes (jsdom doesn't have it)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
|
||||||
|
}
|
||||||
Vendored
+90
@@ -0,0 +1,90 @@
|
|||||||
|
export interface ElectronAPI {
|
||||||
|
// App info
|
||||||
|
getAppVersion: () => Promise<string>;
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>;
|
||||||
|
saveFile: (data: { path: string; content: string }) => Promise<void>;
|
||||||
|
readFile: (path: string) => Promise<string>;
|
||||||
|
setCurrentFile: (path: string | null) => void;
|
||||||
|
|
||||||
|
// Export
|
||||||
|
exportDocument: (format: string, options?: Record<string, any>) => Promise<void>;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
getTheme: () => void;
|
||||||
|
onThemeChanged: (callback: (theme: string) => void) => () => void;
|
||||||
|
|
||||||
|
// File events
|
||||||
|
onFileOpened: (callback: (data: { path: string; content: string }) => void) => () => void;
|
||||||
|
onFileNew: (callback: () => void) => () => void;
|
||||||
|
onFileSave: (callback: () => void) => () => void;
|
||||||
|
|
||||||
|
// Conversion status
|
||||||
|
onConversionStatus: (callback: (message: string) => void) => () => void;
|
||||||
|
onConversionComplete: (callback: (data: { format: string; outputPath: string }) => void) => () => void;
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
showExportDialog: (format: string) => void;
|
||||||
|
showBatchDialog: () => void;
|
||||||
|
showUniversalConverterDialog: () => void;
|
||||||
|
showTableGenerator: () => void;
|
||||||
|
showPdfEditorDialog: () => void;
|
||||||
|
showHeaderFooterDialog: () => void;
|
||||||
|
showFieldPickerDialog: () => void;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: (key: string) => Promise<any>;
|
||||||
|
setSettings: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Plugin settings
|
||||||
|
getPluginSetting: (key: string) => Promise<any>;
|
||||||
|
setPluginSetting: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
toggleBottomPanel: () => void;
|
||||||
|
|
||||||
|
// Print
|
||||||
|
doPrint: (options?: any) => void;
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
openPdfViewer: (filePath: string) => void;
|
||||||
|
processPdfOperation: (data: any) => void;
|
||||||
|
|
||||||
|
// Images
|
||||||
|
selectFolder: () => Promise<string | null>;
|
||||||
|
savePastedImage: (data: { base64: string; ext: string }) => Promise<{ relativePath: string } | null>;
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
loadTemplate: (file: string) => Promise<string>;
|
||||||
|
getSnippets: () => Promise<any[]>;
|
||||||
|
saveSnippet: (snippet: any) => Promise<void>;
|
||||||
|
deleteSnippet: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Git
|
||||||
|
gitStatus: () => Promise<any>;
|
||||||
|
gitDiff: (file: string) => Promise<any>;
|
||||||
|
gitStage: (files: string[]) => Promise<void>;
|
||||||
|
gitCommit: (message: string) => Promise<void>;
|
||||||
|
gitLog: () => Promise<any[]>;
|
||||||
|
|
||||||
|
// Directory
|
||||||
|
listDirectory: (dir: string) => Promise<any[] | null>;
|
||||||
|
openFilePath: (path: string) => void;
|
||||||
|
|
||||||
|
// Custom CSS
|
||||||
|
selectCustomCSS: () => Promise<string | null>;
|
||||||
|
loadCustomCSS: () => void;
|
||||||
|
clearCustomCSS: () => void;
|
||||||
|
|
||||||
|
// Generic invoke/on
|
||||||
|
invoke: (channel: string, data?: any) => Promise<any>;
|
||||||
|
on: (channel: string, callback: (...args: any[]) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size?: number;
|
||||||
|
modifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileResult {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
format?: 'letter' | 'a4' | 'legal';
|
||||||
|
margins?: { top: number; right: number; bottom: number; left: number };
|
||||||
|
toc?: boolean;
|
||||||
|
embedFonts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
template?: string;
|
||||||
|
referenceDoc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmlOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
highlightStyle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
outputPath: string;
|
||||||
|
bytes: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchItem {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOptions {
|
||||||
|
format: 'pdf' | 'docx' | 'html' | 'png';
|
||||||
|
concurrency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchResult {
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ item: BatchItem; ok: boolean; error?: string }>;
|
||||||
|
}
|
||||||
@@ -2372,3 +2372,254 @@ body[class*="dark"] .breadcrumb-bar {
|
|||||||
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
|
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
|
||||||
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
|
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
|
||||||
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
|
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Interactive PDF Thumbnail Sidebar Styles
|
||||||
|
======================================== */
|
||||||
|
.pdf-editor-body.side-by-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
height: 550px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-left-panel {
|
||||||
|
flex: 1.2;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel {
|
||||||
|
flex: 0.8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
padding-left: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel h4 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel .sidebar-help {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 8px;
|
||||||
|
transition: all var(--transition-speed, 0.3s) var(--transition-ease);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--primary-light, #8b9aff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card.marked-delete {
|
||||||
|
border-color: var(--error, #ef4444);
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card.marked-delete::after {
|
||||||
|
content: "DELETE";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-15deg);
|
||||||
|
background: rgba(239, 68, 68, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-header .badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
height: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Rotations */
|
||||||
|
.rot90 {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.rot180 {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.rot270 {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-controls button,
|
||||||
|
.pdf-thumbnail-controls label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-rotate-thumbnail {
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-rotate-thumbnail:hover {
|
||||||
|
background: var(--primary-light, #8b9aff);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-thumbnail-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-thumbnail-checkbox input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-reorder {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left,
|
||||||
|
.btn-reorder-right {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left:hover:not(:disabled),
|
||||||
|
.btn-reorder-right:hover:not(:disabled) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left:disabled,
|
||||||
|
.btn-reorder-right:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-thumbnails,
|
||||||
|
.thumbnail-error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-error {
|
||||||
|
color: var(--error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
body[class*="dark"] .pdf-thumbnail-card {
|
||||||
|
background: var(--gray-800, #1f2937);
|
||||||
|
border-color: var(--gray-700, #374151);
|
||||||
|
}
|
||||||
|
body[class*="dark"] .pdf-thumbnail-header .badge {
|
||||||
|
background: var(--gray-700, #374151);
|
||||||
|
color: var(--gray-200);
|
||||||
|
}
|
||||||
|
body[class*="dark"] .canvas-container {
|
||||||
|
background: var(--gray-900, #111827);
|
||||||
|
border-color: var(--gray-700);
|
||||||
|
}
|
||||||
|
body[class*="dark"] .btn-rotate-thumbnail,
|
||||||
|
body[class*="dark"] .btn-reorder-left,
|
||||||
|
body[class*="dark"] .btn-reorder-right {
|
||||||
|
background: var(--gray-700, #374151);
|
||||||
|
border-color: var(--gray-600);
|
||||||
|
color: var(--gray-200);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
// Tauri Commands Bridge for MarkdownConverter
|
|
||||||
// Replaces preload.js — uses Tauri's invoke() instead of ipcRenderer
|
|
||||||
|
|
||||||
const { invoke } = window.__TAURI__.core;
|
|
||||||
const { listen } = window.__TAURI__.event;
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// FILE OPERATIONS
|
|
||||||
// ============================================
|
|
||||||
export const file = {
|
|
||||||
read: (path) => invoke('read_file', { path }),
|
|
||||||
write: (path, content) => invoke('write_file', { path, content }),
|
|
||||||
delete: (path) => invoke('delete_file', { path }),
|
|
||||||
exists: (path) => invoke('path_exists', { path }),
|
|
||||||
isDirectory: (path) => invoke('is_directory', { path }),
|
|
||||||
listDirectory: (path) => invoke('list_directory', { path }),
|
|
||||||
ensureDir: (path) => invoke('ensure_directory', { path }),
|
|
||||||
copy: (source, destination) => invoke('copy_path', { source, destination }),
|
|
||||||
move: (source, destination) => invoke('move_path', { source, destination }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// EXPORT OPERATIONS
|
|
||||||
// ============================================
|
|
||||||
export const exportDoc = {
|
|
||||||
markdown: (inputPath, outputPath, format, options) =>
|
|
||||||
invoke('export_markdown', { inputPath, outputPath, format, options }),
|
|
||||||
checkPandoc: () => invoke('check_pandoc_available', {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// GIT OPERATIONS
|
|
||||||
// ============================================
|
|
||||||
export const git = {
|
|
||||||
status: (repoPath) => invoke('git_status', { repoPath }),
|
|
||||||
stage: (repoPath, path) => invoke('git_stage', { repoPath, path }),
|
|
||||||
commit: (repoPath, message) => invoke('git_commit', { repoPath, message }),
|
|
||||||
log: (repoPath, limit) => invoke('git_log', { repoPath, limit }),
|
|
||||||
diff: (repoPath, path) => invoke('git_diff', { repoPath, path }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// APP OPERATIONS
|
|
||||||
// ============================================
|
|
||||||
export const app = {
|
|
||||||
getVersion: () => invoke('get_app_version'),
|
|
||||||
getRecentFiles: () => invoke('get_recent_files'),
|
|
||||||
saveRecentFiles: (files) => invoke('save_recent_files', { files }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PDF OPERATIONS
|
|
||||||
// ============================================
|
|
||||||
export const pdf = {
|
|
||||||
getPageCount: (path) => invoke('get_pdf_page_count', { path }),
|
|
||||||
merge: (paths, output) => invoke('merge_pdfs', { paths, output }),
|
|
||||||
split: (input, outputDir, pagesPerSplit) => invoke('split_pdf', { input, outputDir, pagesPerSplit }),
|
|
||||||
rotate: (input, output, degrees) => invoke('rotate_pdf', { input, output, degrees }),
|
|
||||||
deletePages: (input, output, pages) => invoke('delete_pdf_pages', { input, output, pages }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// DIALOG OPERATIONS
|
|
||||||
// ============================================
|
|
||||||
export const dialog = {
|
|
||||||
openFile: () => invoke('open_file_dialog', {}),
|
|
||||||
saveFile: (defaultName) => invoke('save_file_dialog', { defaultName }),
|
|
||||||
selectFolder: () => invoke('select_folder_dialog', {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// EVENT LISTENERS
|
|
||||||
// ============================================
|
|
||||||
export const events = {
|
|
||||||
onFileNew: (callback) => listen('file-new', callback),
|
|
||||||
onFileOpened: (callback) => listen('file-opened', callback),
|
|
||||||
onFileSave: (callback) => listen('file-save', callback),
|
|
||||||
onTogglePreview: (callback) => listen('toggle-preview', callback),
|
|
||||||
onToggleFind: (callback) => listen('toggle-find', callback),
|
|
||||||
onUndo: (callback) => listen('undo', callback),
|
|
||||||
onRedo: (callback) => listen('redo', callback),
|
|
||||||
onThemeChanged: (callback) => listen('theme-changed', callback),
|
|
||||||
onToggleCommandPalette: (callback) => listen('toggle-command-palette', callback),
|
|
||||||
onToggleSidebarPanel: (callback) => listen('toggle-sidebar-panel', callback),
|
|
||||||
onToggleBottomPanel: (callback) => listen('toggle-bottom-panel', callback),
|
|
||||||
onConversionComplete: (callback) => listen('conversion-complete', callback),
|
|
||||||
onConversionStatus: (callback) => listen('conversion-status', callback),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PLUGIN OPERATIONS
|
|
||||||
// ============================================
|
|
||||||
export const plugins = {
|
|
||||||
list: () => invoke('list_plugins', {}),
|
|
||||||
get: (name) => invoke('get_plugin', { name }),
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ModalManager - Unified modal system with accessibility support
|
* ModalManager - Unified modal system with accessibility support
|
||||||
* @version 4.3.0
|
* @version 4.4.2
|
||||||
*/
|
*/
|
||||||
class ModalManager {
|
class ModalManager {
|
||||||
#modal;
|
#modal;
|
||||||
@@ -237,8 +237,8 @@ class ModalManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in renderer
|
// Export for use in renderer - avoid duplicate declaration
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined' && !window.ModalManager) {
|
||||||
window.ModalManager = ModalManager;
|
window.ModalManager = ModalManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
root: '.',
|
|
||||||
base: './',
|
|
||||||
build: {
|
|
||||||
outDir: '../dist',
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 1420,
|
|
||||||
strictPort: true,
|
|
||||||
},
|
|
||||||
clearScreen: false,
|
|
||||||
});
|
|
||||||
@@ -655,7 +655,7 @@ class WordTemplateExporter {
|
|||||||
*/
|
*/
|
||||||
parseInlineFormatting(text) {
|
parseInlineFormatting(text) {
|
||||||
let xml = '';
|
let xml = '';
|
||||||
let pos = 0;
|
const pos = 0;
|
||||||
|
|
||||||
// Patterns for inline formatting
|
// Patterns for inline formatting
|
||||||
const patterns = [
|
const patterns = [
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ class ZenMode {
|
|||||||
_applyTypewriterBehavior(view) {
|
_applyTypewriterBehavior(view) {
|
||||||
// We use a custom update listener that scrolls the cursor to center.
|
// We use a custom update listener that scrolls the cursor to center.
|
||||||
// This is stored on the instance so we can clean it up.
|
// This is stored on the instance so we can clean it up.
|
||||||
let scrollTimeout = null;
|
const scrollTimeout = null;
|
||||||
|
|
||||||
const scrollFn = () => {
|
const scrollFn = () => {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const animate = require('tailwindcss-animate')
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./src/renderer/index.html',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: { '2xl': '1400px' },
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
DEFAULT: '#e5461f',
|
||||||
|
dark: '#c93a18',
|
||||||
|
light: '#ff6b47',
|
||||||
|
},
|
||||||
|
success: '#1a7a56',
|
||||||
|
warning: 'hsl(45 93% 47%)',
|
||||||
|
danger: 'hsl(0 84% 60%)',
|
||||||
|
info: 'hsl(199 89% 48%)',
|
||||||
|
warm: {
|
||||||
|
50: '#fafbfc', 100: '#f5f5f5', 200: '#e3e3e3', 300: '#d1d1d1',
|
||||||
|
400: '#b0b0b0', 500: '#9a9696', 600: '#7a7878', 700: '#5a5858',
|
||||||
|
800: '#4e4e4e', 900: '#464646', 950: '#0d0b09',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
||||||
|
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [animate],
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { CodeMirrorEditor } from '@/components/editor/CodeMirrorEditor';
|
||||||
|
|
||||||
|
vi.mock('@/components/editor/CodeMirrorEditor', () => ({
|
||||||
|
CodeMirrorEditor: ({ initialContent }: { initialContent: string }) => (
|
||||||
|
<div data-testid="codemirror-mock">{initialContent}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('EditorPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state when no buffer is open', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the editor when a buffer is open', () => {
|
||||||
|
useEditorStore.getState().openBuffer('b1', '/x.md', '# hello');
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toHaveTextContent('# hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('AppHeader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the app title', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sidebar when sidebar button clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
|
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
|
||||||
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
|
||||||
|
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AppShell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all shell surfaces when sidebar and preview are visible', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no file selected/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sidebar when sidebarVisible is false', () => {
|
||||||
|
useAppStore.setState({ sidebarVisible: false });
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { StatusBar } from '@/components/layout/StatusBar';
|
||||||
|
|
||||||
|
describe('StatusBar', () => {
|
||||||
|
it('renders zero word count when no file is open', () => {
|
||||||
|
render(<StatusBar />);
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
import { TabBar } from '@/components/layout/TabBar';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
describe('TabBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useFileStore.setState({ openTabs: [], activeTabId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "No files open" when openTabs is empty', () => {
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a tab for each OpenTab', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByText('foo.md')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('bar.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights the active tab with aria-current', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
const fooTab = screen.getByText('foo.md').closest('[role="tab"]');
|
||||||
|
expect(fooTab).toHaveAttribute('aria-current', 'page');
|
||||||
|
const barTab = screen.getByText('bar.md').closest('[role="tab"]');
|
||||||
|
expect(barTab).not.toHaveAttribute('aria-current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a tab calls setActiveTab', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByText('bar.md').closest('[role="tab"]')!.click();
|
||||||
|
});
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/tmp/bar.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the close button calls closeTab and does not call setActiveTab separately', async () => {
|
||||||
|
// Use a non-active tab to test that closeTab doesn't change activeTabId
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/bar.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
|
||||||
|
const fooTab = screen.getByText('foo.md');
|
||||||
|
const closeBtn = fooTab.closest('[role="tab"]')!.querySelector('button')!;
|
||||||
|
await act(async () => {
|
||||||
|
closeBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useFileStore.getState();
|
||||||
|
expect(state.openTabs.find((t) => t.id === '/tmp/foo.md')).toBeUndefined();
|
||||||
|
// activeTabId stays on bar since we closed a non-active tab
|
||||||
|
expect(state.activeTabId).toBe('/tmp/bar.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a dirty indicator when dirty === true', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: true },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
const dirtyDot = screen.getByLabelText('Unsaved changes');
|
||||||
|
expect(dirtyDot).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders tabs when reorderTabs is called (drag end handler integration)', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/a.md', path: '/tmp/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/tmp/b.md', path: '/tmp/b.md', title: 'b.md', dirty: false },
|
||||||
|
{ id: '/tmp/c.md', path: '/tmp/c.md', title: 'c.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/a.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
// Initial DOM order
|
||||||
|
const initialOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||||
|
expect(initialOrder).toEqual(['tab-/tmp/a.md', 'tab-/tmp/b.md', 'tab-/tmp/c.md']);
|
||||||
|
|
||||||
|
// Simulate what dnd-kit's onDragEnd would do: move a.md (idx 0) to idx 2
|
||||||
|
act(() => {
|
||||||
|
useFileStore.getState().reorderTabs(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||||
|
expect(newOrder).toEqual(['tab-/tmp/b.md', 'tab-/tmp/c.md', 'tab-/tmp/a.md']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Toolbar } from '@/components/layout/Toolbar';
|
||||||
|
|
||||||
|
describe('Toolbar', () => {
|
||||||
|
it('renders formatting buttons', () => {
|
||||||
|
render(<Toolbar />);
|
||||||
|
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MermaidLazy } from '@/components/preview/MermaidLazy';
|
||||||
|
|
||||||
|
vi.mock('mermaid', () => ({
|
||||||
|
default: {
|
||||||
|
initialize: vi.fn(),
|
||||||
|
render: vi.fn().mockResolvedValue('<svg></svg>'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('MermaidLazy', () => {
|
||||||
|
it('renders an svg after mermaid resolves', async () => {
|
||||||
|
render(<MermaidLazy code="graph TD; A-->B" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mermaid-output').innerHTML).toContain('<svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error message if mermaid throws', async () => {
|
||||||
|
const mermaid = (await import('mermaid')).default as any;
|
||||||
|
mermaid.render.mockRejectedValueOnce(new Error('mermaid failed'));
|
||||||
|
render(<MermaidLazy code="bad code" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/mermaid failed/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||||
|
import { usePreviewStore } from '@/stores/preview-store';
|
||||||
|
|
||||||
|
describe('PreviewPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
usePreviewStore.setState({ scrollRatio: 0, source: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no source', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<PreviewPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/nothing to preview/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders markdown when source is set', () => {
|
||||||
|
usePreviewStore.setState({ source: '# Hello' });
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<PreviewPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: /hello/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { FileTree } from '@/components/sidebar/FileTree';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
list: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
|
read: vi.fn().mockResolvedValue({ ok: true, data: '' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FileTree', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when tree is null', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders root children after openFolder', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'src', path: '/project/src', isDirectory: true, children: null, loaded: false },
|
||||||
|
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('src')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows folder as expanded after toggle and reveals children', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'src',
|
||||||
|
path: '/project/src',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'index.ts', path: '/project/src/index.ts', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
expanded: new Set(['/project/src']),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('index.ts')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights the active tab path', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
activeTabId: '/project/README.md',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /README.md/i });
|
||||||
|
expect(btn).toHaveClass('bg-accent');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { Outline } from '@/components/sidebar/Outline';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
describe('Outline', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "No file open" when no active buffer', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Outline />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders H1-H6 headings from the active buffer content', () => {
|
||||||
|
const buffers = new Map([
|
||||||
|
[
|
||||||
|
'b1',
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
path: '/x.md',
|
||||||
|
content: '# Hello\n## World\n### Foo\n#### Bar\n##### Baz\n###### Qux',
|
||||||
|
dirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
useEditorStore.setState({ buffers, activeId: 'b1' });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Outline />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('World')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Foo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Bar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Baz')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Qux')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders headings with level-based indent', () => {
|
||||||
|
const buffers = new Map([
|
||||||
|
[
|
||||||
|
'b1',
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
path: '/x.md',
|
||||||
|
content: '# Title\n## Subtitle\n### Section',
|
||||||
|
dirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
useEditorStore.setState({ buffers, activeId: 'b1' });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Outline />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
// H1 has paddingLeft 8px, H2 has24px, H3 has 40px
|
||||||
|
expect(buttons[0]).toHaveStyle('padding-left: 8px');
|
||||||
|
expect(buttons[1]).toHaveStyle('padding-left: 24px');
|
||||||
|
expect(buttons[2]).toHaveStyle('padding-left: 40px');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { Sidebar } from '@/components/sidebar/Sidebar';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
read: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without crashing with empty state in both sections', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Outline')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no folder opened/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders FileTree and Outline sections', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null }],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Outline')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Open Folder" button when no folder is open', () => {
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/no folder opened/i)).toBeInTheDocument();
|
||||||
|
const btn = screen.getByRole('button', { name: /open folder/i });
|
||||||
|
expect(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking "Open Folder" button calls openFolderDialog', async () => {
|
||||||
|
const { ipc } = await import('@/lib/ipc');
|
||||||
|
const spy = vi.spyOn(useFileStore.getState(), 'openFolderDialog');
|
||||||
|
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /open folder/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove('dark', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles between light and dark when clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="light" attribute="class">
|
||||||
|
<ThemeToggle />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).toHaveAccessibleName(/toggle theme/i);
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when clicked', async () => {
|
||||||
|
let count = 0;
|
||||||
|
render(<Button onClick={() => count++}>+</Button>);
|
||||||
|
await userEvent.click(screen.getByRole('button'));
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the brand variant with primary styling', () => {
|
||||||
|
render(<Button variant="default">Brand</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.className).toContain('bg-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Git Operations Utilities Test Suite
|
||||||
|
* Tests for async patterns and error handling
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('GitOperations Utilities', () => {
|
||||||
|
describe('error handling patterns', () => {
|
||||||
|
it('should handle git errors gracefully', () => {
|
||||||
|
const errorResponse = { error: 'Not a git repository' };
|
||||||
|
|
||||||
|
expect(errorResponse).toHaveProperty('error');
|
||||||
|
expect(errorResponse.error).toContain('repository');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error object on failure', () => {
|
||||||
|
const failureResult = { error: 'Failed to commit' };
|
||||||
|
|
||||||
|
expect(failureResult).toBeDefined();
|
||||||
|
expect(failureResult.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('async patterns', () => {
|
||||||
|
it('should handle async operations', async () => {
|
||||||
|
const asyncFn = async () => {
|
||||||
|
return { status: 'success' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await asyncFn();
|
||||||
|
expect(result).toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle async errors', async () => {
|
||||||
|
const asyncFnWithError = async () => {
|
||||||
|
throw new Error('Git operation failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await asyncFnWithError();
|
||||||
|
expect(true).toBe(false); // Should not reach here
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.message).toContain('Git');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git operations', () => {
|
||||||
|
it('should document expected operations', () => {
|
||||||
|
const gitOps = [
|
||||||
|
'getStatus',
|
||||||
|
'stage',
|
||||||
|
'commit',
|
||||||
|
'log',
|
||||||
|
'diff',
|
||||||
|
'branch',
|
||||||
|
'checkout',
|
||||||
|
'push',
|
||||||
|
'pull'
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(gitOps.length).toBeGreaterThan(0);
|
||||||
|
gitOps.forEach(op => {
|
||||||
|
expect(typeof op).toBe('string');
|
||||||
|
expect(op.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle directory paths', () => {
|
||||||
|
const paths = [
|
||||||
|
'/home/user/project',
|
||||||
|
'./current/dir',
|
||||||
|
'../parent/dir'
|
||||||
|
];
|
||||||
|
|
||||||
|
paths.forEach(pathStr => {
|
||||||
|
expect(typeof pathStr).toBe('string');
|
||||||
|
expect(pathStr.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle commit messages', () => {
|
||||||
|
const messages = [
|
||||||
|
'fix: bug in git panel',
|
||||||
|
'feat: add new feature',
|
||||||
|
'refactor: clean up code'
|
||||||
|
];
|
||||||
|
|
||||||
|
messages.forEach(msg => {
|
||||||
|
expect(typeof msg).toBe('string');
|
||||||
|
expect(msg.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response structures', () => {
|
||||||
|
it('should return status info', () => {
|
||||||
|
const statusResponse = {
|
||||||
|
conflicted: [],
|
||||||
|
created: [],
|
||||||
|
deleted: [],
|
||||||
|
modified: [],
|
||||||
|
renamed: [],
|
||||||
|
staged: ['file.md']
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(statusResponse).toHaveProperty('staged');
|
||||||
|
expect(Array.isArray(statusResponse.staged)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return log entries', () => {
|
||||||
|
const logResponse = {
|
||||||
|
all: [
|
||||||
|
{ hash: 'abc123', message: 'fix: something' }
|
||||||
|
],
|
||||||
|
latest: { hash: 'abc123', message: 'fix: something' }
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(logResponse).toHaveProperty('all');
|
||||||
|
expect(Array.isArray(logResponse.all)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom (same pattern as AppShell.test.tsx)
|
||||||
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
|
ResizablePanelGroup: ({ children, direction }: any) => (
|
||||||
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizablePanel: ({ children, defaultSize }: any) => (
|
||||||
|
<div data-testid="resizable-panel" data-size={defaultSize}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
|
||||||
|
read: vi.fn().mockResolvedValue({ ok: true, data: '# hello' }),
|
||||||
|
write: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ name: 'README.md', path: '/root/README.md', isDirectory: false },
|
||||||
|
{ name: 'src', path: '/root/src', isDirectory: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||||
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||||
|
onChange: vi.fn(() => () => {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Phase 5 integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
useAppStore.setState({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AppShell renders Sidebar (Files + Outline sections) and TabBar', () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Outline')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening a folder via the Open Folder button populates the tree', async () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
const openBtn = screen.getByRole('button', { name: /open folder/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(openBtn);
|
||||||
|
});
|
||||||
|
// After openFolder, the tree should have children
|
||||||
|
const state = useFileStore.getState();
|
||||||
|
expect(state.tree).not.toBeNull();
|
||||||
|
expect(state.tree?.children).toHaveLength(2);
|
||||||
|
expect(state.rootPath).toBe('/root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a file in the tree opens it as a tab and populates the editor buffer', async () => {
|
||||||
|
// Seed the tree directly
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'root',
|
||||||
|
path: '/root',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'README.md', path: '/root/README.md', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/root',
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AppShell />);
|
||||||
|
const fileButton = screen.getByText('README.md').closest('button')!;
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(fileButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useFileStore.getState();
|
||||||
|
expect(state.openTabs).toHaveLength(1);
|
||||||
|
expect(state.openTabs[0].id).toBe('/root/README.md');
|
||||||
|
expect(state.activeTabId).toBe('/root/README.md');
|
||||||
|
|
||||||
|
// Editor buffer should have the file content
|
||||||
|
const buf = useEditorStore.getState().buffers.get('/root/README.md');
|
||||||
|
expect(buf?.content).toBe('# hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TabBar shows the open tab and switches active on click', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
render(<AppShell />);
|
||||||
|
const aTab = screen.getByText('a.md').closest('[role="tab"]')!;
|
||||||
|
const bTab = screen.getByText('b.md').closest('[role="tab"]')!;
|
||||||
|
expect(aTab).toHaveAttribute('aria-current', 'page');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(bTab);
|
||||||
|
});
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keyboard shortcut Ctrl+S triggers saveActiveBuffer (no-op when no active tab)', () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||||
|
// No assertion on side effect, just confirms it doesn't throw
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drag-reorder updates the openTabs order', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
{ id: '/c.md', path: '/c.md', title: 'c.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
render(<AppShell />);
|
||||||
|
act(() => {
|
||||||
|
useFileStore.getState().reorderTabs(0, 2);
|
||||||
|
});
|
||||||
|
const order = useFileStore.getState().openTabs.map((t) => t.id);
|
||||||
|
expect(order).toEqual(['/b.md', '/c.md', '/a.md']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user