Compare commits

..
52 changed files with 257 additions and 8564 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

-20
View File
@@ -1,20 +0,0 @@
{
"$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
@@ -1,361 +0,0 @@
# 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, 812 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, ~35 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
+144 -3234
View File
File diff suppressed because it is too large Load Diff
+3 -43
View File
@@ -1,6 +1,6 @@
{
"name": "markdown-converter",
"version": "4.4.2",
"version": "4.4.3",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js",
"scripts": {
@@ -8,9 +8,6 @@
"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",
@@ -46,19 +43,7 @@
"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",
@@ -67,17 +52,8 @@
"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",
"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"
"sharp": "^0.34.3"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -94,12 +70,6 @@
"@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",
@@ -109,26 +79,16 @@
"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",
"sonner": "^2.0.7",
"tslib": "^2.8.1",
"zod": "^4.4.3",
"zustand": "^5.0.14"
"tslib": "^2.8.1"
},
"overrides": {
"jszip": "^3.10.1",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1 -1
View File
@@ -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' 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 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 name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarkdownConverter</title>
<!-- Design tokens - loaded first for CSS variable availability -->
+1 -3
View File
@@ -242,9 +242,7 @@ function runPandoc(args, callback) {
function runPandocCmd(cmdString, callback) {
const parsed = parseCommand(cmdString);
// Skip 'pandoc' if it's the first element (command itself)
// 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 args = parsed.command === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
const pandocPath = getPandocPath();
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
}
+107 -65
View File
@@ -1,20 +1,9 @@
/**
* MarkdownConverter Renderer Process
* @version 4.4.2
* @version 4.4.3
*/
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');
@@ -22,12 +11,53 @@ const DOMPurify = createDOMPurify(window);
const hljs = require('highlight.js');
const { createEditor } = require('./editor/codemirror-setup');
const { undo, redo } = require('@codemirror/commands');
// 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') {
// 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 {
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;
@@ -490,7 +520,38 @@ class TabManager {
document.querySelector('.editor-container').appendChild(tabContent);
// Initialize CodeMirror editor
this.ensureEditor(tab);
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,
});
}
}
switchToTab(tabId) {
@@ -1461,47 +1522,9 @@ 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 }
@@ -1558,6 +1581,7 @@ let tabManager;
let replPanel;
document.addEventListener('DOMContentLoaded', async () => {
const ModalManager = _ModalManager;
tabManager = new TabManager();
// Load saved Custom Preview CSS if present
@@ -1690,6 +1714,11 @@ 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,
@@ -1745,8 +1774,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
const tab = tabManager.tabs.get(tabManager.activeTabId);
// Only show welcome if no file was opened while we were awaiting
if (tab && !tab.filePath && tab.content === '') {
if (tab) {
tab.title = 'Welcome';
tab.content = '';
const preview = document.getElementById(`preview-${tab.id}`);
@@ -1827,10 +1855,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}
});
// Initialize command palette
const CommandPalette = getCommandPalette();
const commandPalette = new CommandPalette();
// Initialize print preview
const PrintPreview = getPrintPreview();
const printPreview = new PrintPreview();
@@ -1915,9 +1939,27 @@ document.addEventListener('DOMContentLoaded', async () => {
});
// Initialize CodeMirror for the initial tab (tab 1)
const initialTab = tabManager.tabs.get(1);
if (initialTab) {
tabManager.ensureEditor(initialTab);
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,
});
}
// Request current theme
-7
View File
@@ -1,7 +0,0 @@
import { AppShell } from './components/layout/AppShell';
function App() {
return <AppShell />;
}
export default App;
@@ -1,80 +0,0 @@
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" />;
}
@@ -1,25 +0,0 @@
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { useEditorStore } from '@/stores/editor-store';
export function EditorPane() {
const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null;
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>
);
}
@@ -1,55 +0,0 @@
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);
@@ -1,41 +0,0 @@
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>
);
}
@@ -1,52 +0,0 @@
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 { useAppStore } from '@/stores/app-store';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
export function AppShell() {
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">
File tree placeholder
</aside>
</ResizablePanel>
<ResizableHandle />
</>
)}
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
<EditorPane />
</ResizablePanel>
{previewVisible && (
<>
<ResizableHandle />
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
<section className="h-full border-l border-border bg-card/10 p-4 text-sm text-muted-foreground">
Preview placeholder
</section>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</main>
<StatusBar />
</div>
);
}
@@ -1,7 +0,0 @@
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>
);
}
@@ -1,25 +0,0 @@
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>
);
}
@@ -1,7 +0,0 @@
export function TabBar() {
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>
);
}
@@ -1,15 +0,0 @@
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>
);
}
@@ -1,6 +0,0 @@
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>;
}
-30
View File
@@ -1,30 +0,0 @@
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>
);
}
-57
View File
@@ -1,57 +0,0 @@
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 }
-62
View File
@@ -1,62 +0,0 @@
"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 }
-16
View File
@@ -1,16 +0,0 @@
<!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>
-94
View File
@@ -1,94 +0,0 @@
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),
},
};
-41
View File
@@ -1,41 +0,0 @@
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',
};
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
-13
View File
@@ -1,13 +0,0 @@
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>
);
-35
View File
@@ -1,35 +0,0 @@
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' }
)
);
-63
View File
@@ -1,63 +0,0 @@
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;
}),
}))
);
-98
View File
@@ -1,98 +0,0 @@
@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;
}
-35
View File
@@ -1,35 +0,0 @@
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 });
}
-90
View File
@@ -1,90 +0,0 @@
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;
}
}
-62
View File
@@ -1,62 +0,0 @@
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 -1
View File
@@ -1,6 +1,6 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.4.2
* @version 4.4.3
*/
class ModalManager {
#modal;
-87
View File
@@ -1,87 +0,0 @@
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],
}
@@ -1,38 +0,0 @@
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');
});
});
-33
View File
@@ -1,33 +0,0 @@
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);
});
});
-49
View File
@@ -1,49 +0,0 @@
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();
});
});
-10
View File
@@ -1,10 +0,0 @@
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();
});
});
-10
View File
@@ -1,10 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TabBar } from '@/components/layout/TabBar';
describe('TabBar', () => {
it('renders an empty state when no tabs are open', () => {
render(<TabBar />);
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
});
});
-11
View File
@@ -1,11 +0,0 @@
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();
});
});
-40
View File
@@ -1,40 +0,0 @@
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);
});
});
-24
View File
@@ -1,24 +0,0 @@
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');
});
});
-20
View File
@@ -1,20 +0,0 @@
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');
});
});
-41
View File
@@ -1,41 +0,0 @@
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');
}
});
});
-32
View File
@@ -1,32 +0,0 @@
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);
});
});
-35
View File
@@ -1,35 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '@/stores/app-store';
describe('useAppStore', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.setState({
sidebarVisible: true,
previewVisible: true,
zenMode: false,
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
});
});
it('toggles sidebar visibility', () => {
expect(useAppStore.getState().sidebarVisible).toBe(true);
useAppStore.getState().toggleSidebar();
expect(useAppStore.getState().sidebarVisible).toBe(false);
});
it('toggles preview visibility', () => {
useAppStore.getState().togglePreview();
expect(useAppStore.getState().previewVisible).toBe(false);
});
it('updates pane sizes', () => {
useAppStore.getState().setPaneSizes({ sidebar: 25, editor: 50, preview: 25 });
expect(useAppStore.getState().paneSizes).toEqual({ sidebar: 25, editor: 50, preview: 25 });
});
it('enables and disables zen mode', () => {
useAppStore.getState().setZenMode(true);
expect(useAppStore.getState().zenMode).toBe(true);
});
});
-36
View File
@@ -1,36 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useEditorStore } from '@/stores/editor-store';
describe('useEditorStore', () => {
beforeEach(() => {
useEditorStore.setState({ buffers: new Map(), activeId: null });
});
it('creates a new buffer with the given content', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '# hello');
const buf = useEditorStore.getState().buffers.get('file-1');
expect(buf?.content).toBe('# hello');
expect(buf?.path).toBe('/foo.md');
});
it('updates content and marks dirty', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().updateContent('file-1', 'new content');
const buf = useEditorStore.getState().buffers.get('file-1');
expect(buf?.content).toBe('new content');
expect(buf?.dirty).toBe(true);
});
it('marks a buffer clean after save', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().updateContent('file-1', 'x');
useEditorStore.getState().markSaved('file-1');
expect(useEditorStore.getState().buffers.get('file-1')?.dirty).toBe(false);
});
it('closes a buffer and removes it', () => {
useEditorStore.getState().openBuffer('file-1', '/foo.md', '');
useEditorStore.getState().closeBuffer('file-1');
expect(useEditorStore.getState().buffers.has('file-1')).toBe(false);
});
});
-23
View File
@@ -1,23 +0,0 @@
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,
},
});
-23
View File
@@ -1,23 +0,0 @@
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'),
},
},
});