mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c1d0c11a | ||
|
|
e0d6315204 | ||
|
|
68ff7d076a | ||
|
|
67b57eae78 | ||
|
|
301c51ac84 | ||
|
|
904ffbdd5b | ||
|
|
ea383783ec | ||
|
|
e758a81c4c | ||
|
|
ea0f3f93bf | ||
|
|
188e73d7c1 | ||
|
|
8265e4ff88 | ||
|
|
8a8671b128 | ||
|
|
3646dc29c6 | ||
|
|
a999f598cc | ||
|
|
7a396f64a6 | ||
|
|
88e9a5290d | ||
|
|
fff15d8d3e | ||
|
|
c574d77c20 | ||
|
|
272215f9af | ||
|
|
0192590567 |
@@ -1,103 +0,0 @@
|
||||
# CLAUDE.md — MarkdownConverter (master)
|
||||
|
||||
> General code-quality, JavaScript, git, security, and testing standards are in the **global CLAUDE.md**. This file holds project- and branch-specific notes.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Electron desktop app for Markdown editing and universal file conversion powered by Pandoc. Cross-platform (Win/macOS/Linux). Features: multi-tab editor with live preview, 25+ themes, PDF viewer/editor (merge/split/compress/rotate/watermark/password), export to 20+ formats (PDF/DOCX/ODT/EPUB/HTML/LaTeX/RTF/PPTX), batch conversion, syntax highlighting, diagram support (Mermaid), Git integration, and a plugin system.
|
||||
|
||||
- **Version:** 4.4.3
|
||||
- **License:** MIT
|
||||
- **App ID:** `com.concreteinfo.markdownconverter`
|
||||
|
||||
## Branch Specifics
|
||||
|
||||
This is the **primary/release branch** — a vanilla JavaScript Electron app with no bundler or framework in the renderer. The renderer is a single large `renderer.js` (5,300+ lines) loaded directly via `src/index.html`. All UI is hand-rolled DOM manipulation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Process (`src/main.js` — 4,260 lines)
|
||||
Monolithic main process file. Contains all IPC handlers, Pandoc invocation, file operations, menu definitions (600+ lines), and window lifecycle. Key modules extracted:
|
||||
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib` (merge, split, compress, rotate, delete, reorder, watermark, encrypt, decrypt, permissions)
|
||||
- `src/main/GitOperations.js` — Git status/stage/commit/log via `simple-git`
|
||||
|
||||
### Renderer (`src/renderer.js` — 5,361 lines)
|
||||
Vanilla JS, no framework. Directly manipulates DOM. Loads CodeMirror 6 via `src/editor/codemirror-setup.js`. Uses `marked` + `highlight.js` + `DOMPurify` + `mermaid` for rendering. Lazy-loads sidebar panels, REPL, command palette, zen mode.
|
||||
|
||||
### Preload (`src/preload.js` — 448 lines)
|
||||
Exists as IPC bridge, but **`contextIsolation: false` and `nodeIntegration: true`** — the renderer has full Node access. Preload is effectively a thin passthrough.
|
||||
|
||||
### Security Model
|
||||
- `contextIsolation: false` + `nodeIntegration: true` (legacy; the react-electron branch fixes this)
|
||||
- Pandoc invoked via `execFile` (not `exec`) to prevent shell injection
|
||||
- Path traversal protection: `validatePath()`, `resolveWritablePath()`, blocks sensitive system dirs
|
||||
- Permission handler only allows `clipboard-read`/`clipboard-write`
|
||||
- Rate limiter on conversions (2-second minimum interval)
|
||||
- File size limit: 50MB
|
||||
- Error message sanitization strips absolute paths
|
||||
|
||||
### Plugin System (`src/plugins/`)
|
||||
Manifest-based discovery (`manifest.json`). Built-in `writing-studio` plugin with sprint/goal/snapshot management. Plugin API exposed via `src/plugins/plugin-api.js`.
|
||||
|
||||
### Settings
|
||||
Custom JSON file store at `<userData>/settings.json` (NOT `electron-store` despite the dependency). Recent files at `<userData>/recent-files.json`.
|
||||
|
||||
## System Dependencies
|
||||
|
||||
| Dependency | Required | Notes |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x |
|
||||
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. Must be present for DOCX/ODT/EPUB/LaTeX/PPTX export. |
|
||||
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` for packaged builds |
|
||||
| **MiKTeX / TeX Live** | Optional | For LaTeX PDF export; MiKTeX PATH injected on Windows automatically |
|
||||
| **ImageMagick** | Optional | Linux image conversion; listed as deb dependency |
|
||||
| **LibreOffice** | Optional | Enhanced document conversion; listed as deb dependency |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm start # Launch Electron app (dev mode)
|
||||
npm test # Jest test suite
|
||||
npm test:watch # Jest in watch mode
|
||||
npm test:coverage # Jest with coverage report
|
||||
npm run lint # ESLint check (src + tests)
|
||||
npm run lint:fix # ESLint auto-fix
|
||||
npm run format # Prettier write
|
||||
npm run format:check # Prettier check only
|
||||
npm run download-tools # Download Pandoc binaries to bin/
|
||||
npm run generate-icons # Generate app icons via sharp
|
||||
```
|
||||
|
||||
## Build & Package
|
||||
|
||||
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json` (no separate config file).
|
||||
|
||||
| Target | Platforms |
|
||||
|---|---|
|
||||
| `npm run build` | electron-builder (default platform) |
|
||||
| `npm run build:win` | Windows: NSIS installer + portable + zip (x64) |
|
||||
| `npm run build:mac` | macOS: default dmg |
|
||||
| `npm run build:linux` | Linux: deb + AppImage + snap |
|
||||
| `npm run dist` | Build without publish |
|
||||
| `npm run dist:all` | Build for all platforms |
|
||||
|
||||
**Bundled with builds:** Pandoc binary per platform. FFmpeg via `ffmpeg-static` (asarUnpacked). NSIS installer uses custom script at `scripts/nsis-installer.nsh`.
|
||||
|
||||
**Output:** `dist/` directory.
|
||||
|
||||
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
|
||||
|
||||
## Project Conventions / Gotchas
|
||||
|
||||
- **No bundler/transpilation.** The app uses vanilla CommonJS JavaScript. `src/main.js` is loaded directly by Electron. No webpack, no Vite, no TypeScript, no Babel.
|
||||
- **Monolithic files.** `main.js` (4,260 lines) and `renderer.js` (5,361 lines) contain most logic. Not ideal but is the current state of this branch.
|
||||
- **CodeMirror 6** for the editor, configured in `src/editor/codemirror-setup.js`.
|
||||
- **PDF rendering** uses `pdfjs-dist`; **PDF manipulation** uses `pdf-lib` in the main process.
|
||||
- **Renderer security is weak** — full Node access in renderer. Do NOT introduce new privileged renderer code without understanding this.
|
||||
- **Pandoc is external.** Must be installed separately or downloaded via `npm run download-tools`. HTML and built-in PDF export work without Pandoc; other formats require it.
|
||||
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
|
||||
- **ESLint flat config** (`eslint.config.js`) with ECMAScript 2022. Prettier with 2-space indent, single quotes, semicolons, 100-char width.
|
||||
- **Tests:** Jest with jsdom environment, 15% coverage threshold. 24 test files in `tests/`.
|
||||
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
|
||||
- **Single instance lock** enforced via `app.requestSingleInstanceLock()`.
|
||||
- **Adapters layer** (`src/adapters/`) abstracts file system operations for potential future non-Electron targets.
|
||||
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"
|
||||
}
|
||||
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
|
||||
Generated
+3234
-144
File diff suppressed because it is too large
Load Diff
+43
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "markdown-converter",
|
||||
"version": "4.4.3",
|
||||
"version": "4.4.2",
|
||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,9 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:renderer": "vitest run",
|
||||
"test:renderer:watch": "vitest",
|
||||
"test:renderer:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src tests",
|
||||
"lint:fix": "eslint src tests --fix",
|
||||
"format": "prettier --write src tests",
|
||||
@@ -43,7 +46,19 @@
|
||||
"url": "https://github.com/amitwh/markdown-converter"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@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",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
@@ -52,8 +67,17 @@
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"jest": "^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",
|
||||
"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": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
@@ -70,6 +94,12 @@
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@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-slot": "^1.2.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"core-util-is": "^1.0.3",
|
||||
"docx": "^9.6.0",
|
||||
@@ -79,16 +109,26 @@
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"immer": "^11.1.8",
|
||||
"marked": "^17.0.3",
|
||||
"marked-footnote": "^1.4.0",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"mermaid": "^11.12.3",
|
||||
"motion": "^12.40.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pdfkit": "^0.17.2",
|
||||
"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",
|
||||
"tslib": "^2.8.1"
|
||||
"sonner": "^2.0.7",
|
||||
"tslib": "^2.8.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"overrides": {
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<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'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
|
||||
<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">
|
||||
<title>MarkdownConverter</title>
|
||||
<!-- Design tokens - loaded first for CSS variable availability -->
|
||||
|
||||
+3
-1
@@ -242,7 +242,9 @@ function runPandoc(args, callback) {
|
||||
function runPandocCmd(cmdString, callback) {
|
||||
const parsed = parseCommand(cmdString);
|
||||
// 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();
|
||||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||||
}
|
||||
|
||||
+65
-107
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* MarkdownConverter Renderer Process
|
||||
* @version 4.4.3
|
||||
* @version 4.4.2
|
||||
*/
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
// 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 { markedHighlight } = require('marked-highlight');
|
||||
const createDOMPurify = require('dompurify');
|
||||
@@ -11,53 +22,12 @@ const DOMPurify = createDOMPurify(window);
|
||||
const hljs = require('highlight.js');
|
||||
const { createEditor } = require('./editor/codemirror-setup');
|
||||
const { undo, redo } = require('@codemirror/commands');
|
||||
|
||||
// Define a fallback window.electronAPI object for when contextIsolation is disabled
|
||||
// and the preload script is not loaded in the main window context.
|
||||
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||
window.electronAPI = {
|
||||
send: (channel, data) => {
|
||||
ipcRenderer.send(channel, data);
|
||||
},
|
||||
invoke: async (channel, data) => {
|
||||
return await ipcRenderer.invoke(channel, data);
|
||||
},
|
||||
on: (channel, callback) => {
|
||||
const subscription = (event, ...args) => callback(...args);
|
||||
ipcRenderer.on(channel, subscription);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, subscription);
|
||||
};
|
||||
},
|
||||
once: (channel, callback) => {
|
||||
ipcRenderer.once(channel, (event, ...args) => callback(...args));
|
||||
},
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
file: {
|
||||
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
|
||||
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
|
||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Use window.ModalManager if already set by script tag, otherwise require it.
|
||||
// This prevents "Identifier 'ModalManager' has already been declared" when
|
||||
// both the script tag in index.html and CommonJS require() declare it.
|
||||
let _ModalManager;
|
||||
if (typeof window !== 'undefined' && window.ModalManager) {
|
||||
_ModalManager = window.ModalManager;
|
||||
} else {
|
||||
// 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;
|
||||
ModalManager = result.ModalManager || result;
|
||||
}
|
||||
// Lazy-loaded modules — defer heavy imports until first use
|
||||
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
|
||||
@@ -520,38 +490,7 @@ class TabManager {
|
||||
document.querySelector('.editor-container').appendChild(tabContent);
|
||||
|
||||
// Initialize CodeMirror editor
|
||||
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
|
||||
if (editorContainer) {
|
||||
const isDark = document.body.className.includes('dark');
|
||||
tab.editorView = createEditor(editorContainer, {
|
||||
content: tab.content,
|
||||
onChange: (newContent) => {
|
||||
tab.content = newContent;
|
||||
tab.isDirty = true;
|
||||
// Dynamically enable/disable Large File Mode on edit
|
||||
if (newContent.length > 1024 * 1024) {
|
||||
if (!tab.largeFileMode) {
|
||||
tab.largeFileMode = true;
|
||||
this.isPreviewVisible = false;
|
||||
this.updatePreviewVisibility();
|
||||
notifyUser('Large content detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.', 'warning');
|
||||
}
|
||||
} else {
|
||||
tab.largeFileMode = false;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
this.ensureEditor(tab);
|
||||
}
|
||||
|
||||
switchToTab(tabId) {
|
||||
@@ -1522,9 +1461,47 @@ class TabManager {
|
||||
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
|
||||
setEditorContent(tabId, content) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab?.editorView) {
|
||||
this.ensureEditor(tab);
|
||||
}
|
||||
if (tab?.editorView) {
|
||||
tab.editorView.dispatch({
|
||||
changes: { from: 0, to: tab.editorView.state.doc.length, insert: content }
|
||||
@@ -1581,7 +1558,6 @@ let tabManager;
|
||||
let replPanel;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ModalManager = _ModalManager;
|
||||
tabManager = new TabManager();
|
||||
|
||||
// Load saved Custom Preview CSS if present
|
||||
@@ -1714,11 +1690,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
const statusBarRight = document.querySelector('.status-bar-right');
|
||||
|
||||
// Initialize command palette
|
||||
const CommandPalette = getCommandPalette();
|
||||
const commandPalette = new CommandPalette();
|
||||
|
||||
const pluginRegistry = new PluginRegistry({
|
||||
sidebar: sidebarManager,
|
||||
commands: commandPalette,
|
||||
@@ -1774,7 +1745,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
|
||||
|
||||
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.content = '';
|
||||
const preview = document.getElementById(`preview-${tab.id}`);
|
||||
@@ -1855,6 +1827,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize command palette
|
||||
const CommandPalette = getCommandPalette();
|
||||
const commandPalette = new CommandPalette();
|
||||
|
||||
// Initialize print preview
|
||||
const PrintPreview = getPrintPreview();
|
||||
const printPreview = new PrintPreview();
|
||||
@@ -1939,27 +1915,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
// Initialize CodeMirror for the initial tab (tab 1)
|
||||
const initialEditorContainer = document.getElementById('editor-cm-1');
|
||||
if (initialEditorContainer) {
|
||||
const tab = tabManager.tabs.get(1);
|
||||
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,
|
||||
});
|
||||
const initialTab = tabManager.tabs.get(1);
|
||||
if (initialTab) {
|
||||
tabManager.ensureEditor(initialTab);
|
||||
}
|
||||
|
||||
// Request current theme
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ThemeToggle } from './components/theme-toggle';
|
||||
import { Button } from './components/ui/button';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<header className="flex h-14 items-center justify-between border-b border-border px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-7 w-7 rounded-md bg-brand" aria-label="MarkdownConverter logo" />
|
||||
<h1 className="text-sm font-semibold">MarkdownConverter</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm">File</Button>
|
||||
<Button variant="ghost" size="sm">Edit</Button>
|
||||
<Button variant="ghost" size="sm">View</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||
<p>Shell skeleton — implementation phases in progress.</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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,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,94 @@
|
||||
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),
|
||||
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,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,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,12 @@
|
||||
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 = {};
|
||||
}
|
||||
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 }>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ModalManager - Unified modal system with accessibility support
|
||||
* @version 4.4.3
|
||||
* @version 4.4.2
|
||||
*/
|
||||
class ModalManager {
|
||||
#modal;
|
||||
|
||||
@@ -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,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,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
describe('cn', () => {
|
||||
it('merges class names', () => {
|
||||
expect(cn('a', 'b')).toBe('a b');
|
||||
});
|
||||
|
||||
it('deduplicates conflicting tailwind classes', () => {
|
||||
expect(cn('p-2', 'p-4')).toBe('p-4');
|
||||
});
|
||||
|
||||
it('ignores falsy values', () => {
|
||||
expect(cn('a', false, null, undefined, '', 'b')).toBe('a b');
|
||||
});
|
||||
|
||||
it('accepts conditional class objects', () => {
|
||||
expect(cn('base', { active: true, disabled: false })).toBe('base active');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import type { FileResult } from '@/types/ipc';
|
||||
|
||||
describe('ipc wrapper', () => {
|
||||
beforeEach(() => {
|
||||
window.electronAPI = {
|
||||
file: {
|
||||
read: vi.fn().mockResolvedValue('# hello'),
|
||||
write: vi.fn().mockResolvedValue(undefined),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('file.read returns ok result on success', async () => {
|
||||
const result = await ipc.file.read('/foo.md');
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe('# hello');
|
||||
}
|
||||
});
|
||||
|
||||
it('file.read returns err result when channel throws', async () => {
|
||||
window.electronAPI.file.read = vi.fn().mockRejectedValue(new Error('ENOENT'));
|
||||
const result = await ipc.file.read('/missing.md');
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('ENOENT');
|
||||
}
|
||||
});
|
||||
|
||||
it('file.read returns err result when channel missing', async () => {
|
||||
delete (window.electronAPI.file as any).read;
|
||||
const result = await ipc.file.read('/foo.md');
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe('CHANNEL_MISSING');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fadeIn,
|
||||
slideInRight,
|
||||
modalPop,
|
||||
toastSpring,
|
||||
sidebarToggle,
|
||||
} from '@/lib/motion';
|
||||
|
||||
describe('motion presets', () => {
|
||||
it('fadeIn is a valid transition object', () => {
|
||||
expect(fadeIn.duration).toBeGreaterThan(0);
|
||||
expect(fadeIn.ease).toBeDefined();
|
||||
});
|
||||
|
||||
it('slideInRight uses translateX transform', () => {
|
||||
expect(slideInRight.x).toBe('100%');
|
||||
});
|
||||
|
||||
it('modalPop uses scale and opacity', () => {
|
||||
expect(modalPop.scale).toBeDefined();
|
||||
expect(modalPop.opacity).toBeDefined();
|
||||
});
|
||||
|
||||
it('toastSpring has spring physics', () => {
|
||||
expect(toastSpring.type).toBe('spring');
|
||||
});
|
||||
|
||||
it('sidebarToggle animates width', () => {
|
||||
expect(sidebarToggle.width.duration).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: path.join(__dirname, 'src/renderer'),
|
||||
base: './',
|
||||
build: {
|
||||
outDir: path.join(__dirname, 'dist/renderer'),
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(__dirname, 'src/renderer'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/renderer/test/setup.ts'],
|
||||
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/renderer/**/*.{ts,tsx}'],
|
||||
exclude: ['src/renderer/test/**', 'src/renderer/**/*.test.*'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(__dirname, 'src/renderer'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user