Compare commits

..
Author SHA1 Message Date
amitwh 3f0bf911a0 chore(repo-map): add auto-generated structural map
Generated with ~/.claude-shared/scripts/repo-map.sh (universal-ctags).
Signatures-only map of classes/functions/methods/interfaces/enums.

Amit Haridas
2026-07-18 07:23:25 +05:30
amitwh 2cac075c0e fix(word): harden DOCX preprocessing and temp-file cleanup
- Make the HTML preprocessor code-block and inline-code aware so code

  examples containing <style> / <div> / comments are preserved.

- Strip all raw <div> tags (not just alignment attributes) to avoid

  malformed output from unmatched closing tags.

- Handle uppercase tags and single/unquoted attributes.

- Create temporary DOCX input files inside private mkdtemp directories

  instead of predictable names in the shared temp directory.

- Wrap batch DOCX preprocessing in try/catch so one unreadable file

  does not abort the entire batch.

- Add regression tests for the edge cases above.
2026-06-30 19:57:40 +05:30
amitwh 94906a068a chore(release): bump version to 4.4.5 2026-06-30 13:57:34 +05:30
amitwh e72b863362 fix(word): strip HTML style blocks and alignment divs from DOCX input
Pre-process markdown before Word/DOCX export to remove raw HTML artifacts

(<style> blocks, HTML comments, and <div align=...> tags) that were

visible in the generated document. Applies to single and batch DOCX exports

via both Pandoc and WordTemplateExporter paths.
2026-06-30 13:57:29 +05:30
amitwh d705cfc30b fix(batch): resolve pandoc path handling and include-subfolders option
- Normalize pandoc command parsing with path.basename() to support bundled binary paths
- Use bundled pandoc binary in convertWithPandoc instead of relying on PATH
- Forward includeSubfolders checkbox state from renderer to main process
- Add pandoc availability check before batch conversion
- Re-enable Start button when batch conversion completes
- Clean up obsolete dist build artifact causing test snapshot warning
- Bump version to 4.4.4
2026-06-30 12:56:33 +05:30
amitwh 02e307f758 docs(claude-md): add tailored CLAUDE.md for master branch
Documents project architecture, Pandoc dependency resolution, build
pipeline (electron-builder, no bundler), security model notes
(contextIsolation: false on this branch), and development commands
extracted from actual package.json and source.

Amit Haridas
2026-06-19 23:18:00 +05:30
amitwh 5ad1d1d4b3 chore(release): bump version to 4.4.3 2026-06-11 21:17:37 +05:30
amitwh f480449301 fix(renderer): resolve syntax errors and undefined electronAPI on startup 2026-06-11 21:15:25 +05:30
146 changed files with 17336 additions and 21739 deletions
+103
View File
@@ -0,0 +1,103 @@
# 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.5
- **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.
+1 -1
View File
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v4.1.0 v4.4.5
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", "name": "markdown-converter",
"version": "4.4.2", "version": "4.4.5",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
@@ -8,9 +8,6 @@
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"test:renderer": "vitest run",
"test:renderer:watch": "vitest",
"test:renderer:coverage": "vitest run --coverage",
"lint": "eslint src tests", "lint": "eslint src tests",
"lint:fix": "eslint src tests --fix", "lint:fix": "eslint src tests --fix",
"format": "prettier --write src tests", "format": "prettier --write src tests",
@@ -46,19 +43,7 @@
"url": "https://github.com/amitwh/markdown-converter" "url": "https://github.com/amitwh/markdown-converter"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/ui": "^4.1.8",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
@@ -67,17 +52,8 @@
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"jsdom": "^29.1.1",
"lucide-react": "^1.17.0",
"postcss": "^8.5.15",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"sharp": "^0.34.3", "sharp": "^0.34.3"
"tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
@@ -94,12 +70,6 @@
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.16", "@codemirror/view": "^6.39.16",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-table": "^8.21.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"core-util-is": "^1.0.3", "core-util-is": "^1.0.3",
"docx": "^9.6.0", "docx": "^9.6.0",
@@ -109,26 +79,16 @@
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"immer": "^11.1.8",
"marked": "^17.0.3", "marked": "^17.0.3",
"marked-footnote": "^1.4.0", "marked-footnote": "^1.4.0",
"marked-highlight": "^2.2.3", "marked-highlight": "^2.2.3",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"motion": "^12.40.0",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.5.207", "pdfjs-dist": "^5.5.207",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"pizzip": "^3.2.0", "pizzip": "^3.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.77.0",
"react-resizable-panels": "^4.11.2",
"simple-git": "^3.32.3", "simple-git": "^3.32.3",
"sonner": "^2.0.7", "tslib": "^2.8.1"
"tslib": "^2.8.1",
"zod": "^4.4.3",
"zustand": "^5.0.14"
}, },
"overrides": { "overrides": {
"jszip": "^3.10.1", "jszip": "^3.10.1",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+604
View File
@@ -0,0 +1,604 @@
# Repository Structure Map
Auto-generated by `~/.claude-shared/scripts/repo-map.sh` (universal-ctags).
Signatures only — classes, functions, methods, interfaces, enums, types, namespaces, traits.
Regenerate after structural changes. Languages: `JavaScript,Sh`.
```
scripts/download-tools.js:
L22 method extract (PANDOC_CONFIG.linux)
L36 method extract (PANDOC_CONFIG.win32)
L51 method extract (PANDOC_CONFIG.darwin)
L63 function download
L70 function get (download)
L109 function downloadPandoc
scripts/generate-icons.js:
L17 function generateIcons
src/adapters/electron/fs.js:
L20 method readFile (electronFsAdapter)
L30 method writeFile (electronFsAdapter)
L39 method deleteFile (electronFsAdapter)
L48 method ensureDir (electronFsAdapter)
L57 method listDirectory (electronFsAdapter)
L77 method exists (electronFsAdapter)
L86 method isDirectory (electronFsAdapter)
L96 method copy (electronFsAdapter)
L106 method move (electronFsAdapter)
src/analytics/analytics-panel.js:
L7 function showAnalyticsModal
L105 function escHandler (showAnalyticsModal)
L116 function escapeHtml
src/analytics/writing-analytics.js:
L77 function countSyllables
L83 function extractWords
L87 function getReadabilityLabel
L95 function analyze
src/command-palette.js:
L1 class CommandPalette
L2 method constructor (CommandPalette)
L12 method register (CommandPalette)
L16 method open (CommandPalette)
L24 method close (CommandPalette)
L28 method isOpen (CommandPalette)
L32 method setupEventListeners (CommandPalette)
L61 method renderResults (CommandPalette)
L90 method highlightMatch (CommandPalette)
L96 method updateSelection (CommandPalette)
L105 method executeSelected (CommandPalette)
src/editor/codemirror-setup.js:
L46 function createEditor
L61 function onChange (createEditor)
L118 function getLanguageExtension
L120 method javascript (getLanguageExtension.loaders)
L124 method html (getLanguageExtension.loaders)
L128 method css (getLanguageExtension.loaders)
L132 method json (getLanguageExtension.loaders)
L136 method python (getLanguageExtension.loaders)
src/main.js:
L18 function getPandocPath
L36 function getFFmpegPath
L55 function sanitizeErrorMessage
L64 function createRateLimiter
L66 function canProceed (createRateLimiter)
L83 function validatePath
L136 function resolveWritablePath
L181 function isPathAccessible
L203 function convertDataToMarkdown
L225 function runPandocCmd
L247 function parseCommand
L282 method get (store)
L291 method set (store)
L488 function checkPandocAvailability
L500 function createWindow
L586 function buildRecentFilesMenu
L599 method click (buildRecentFilesMenu.anonymousObjectc5643c890c05)
L637 function getRecentFiles
L647 function createMenu
L872 method click (createMenu.anonymousObjectc5643c890e05.anonymousObjectc5643c894505)
L1118 method click (createMenu.anonymousObjectc5643c895205.anonymousObjectc5643c897e05)
L1279 method click (createMenu.anonymousObjectc5643c899305.anonymousObjectc5643c89a505)
L1351 function showAboutDialog
L1452 function showDependenciesDialog
L1564 function openPDFFile
L1583 function openFile
L1622 function openPdfFile
L1645 function saveAsFile
L1664 function exportFile
L1673 function showExportOptionsDialog
L1676 function showBatchConversionDialog
L1681 function selectWordTemplate
L1705 function showTemplateSettings
L1902 function processDynamicFields
L1921 function setDocxPageSize
L1980 function addHeaderFooterToDocx
L2111 function exportWordWithTemplate
L2153 function exportPDFViaWordTemplate
L2231 function showUniversalConverterDialog
L2236 function showPDFEditorDialog
L2246 function checkConverterAvailable
L2370 function collectFiles
L2464 function convertWithLibreOffice
L2498 function convertWithImageMagick
L2507 function convertWithFFmpeg
L2515 function convertWithPandoc
L2521 function performExportWithOptions
L2805 function tryPdfFallback
L2882 function showExportSuccess
L2892 function exportWithPandoc
L2964 function exportToHTML
L3080 function exportToPDFElectron
L3224 function exportSpreadsheet
L3233 function importDocument
L3355 function setTheme
L3536 function extractTablesFromMarkdown
L3574 function performBatchConversion
L3597 function findMarkdownFiles (performBatchConversion)
L3636 function processNextFile (performBatchConversion)
L3970 function handleCLIConversion
L3997 function showConversionDialog
L4064 function performCLIConversion
L4098 function buildPandocCommand
L4319 function openFileFromPath
L4394 function openAsciiGenerator
L4426 function openTableGenerator
L4724 function loadSnippets
L4734 function saveSnippetsFile
src/main/GitOperations.js:
L3 function getGitInstance
L7 function getStatus
L16 function stage
L26 function commit
L35 function log
src/main/PDFOperations.js:
L5 function parsePageRanges
L28 function hexToRgb
L39 function pdfMerge
L59 function pdfSplit
L126 function pdfCompress
L152 function pdfRotate
L182 function pdfDeletePages
L208 function pdfReorder
L233 function pdfWatermark
L319 function pdfEncrypt
L353 function pdfDecrypt
L370 function pdfSetPermissions
L404 function executeOperation
L431 function getPageCount
src/plugins/built-in/_sample/index.js:
L3 class SamplePlugin
L4 method init (SamplePlugin)
src/plugins/built-in/writing-studio/goal-tracker.js:
L3 class GoalTracker
L7 method constructor (GoalTracker)
L11 method _getHistory (GoalTracker)
L16 method _setHistory (GoalTracker)
L20 method _setHistoryDay (GoalTracker)
L26 method addWords (GoalTracker)
L37 method getDailyProgress (GoalTracker)
L44 method getStreak (GoalTracker)
L61 method getLast30Days (GoalTracker)
L74 method getWeeklyTotal (GoalTracker)
src/plugins/built-in/writing-studio/index.js:
L7 class WritingStudioPlugin
L8 method init (WritingStudioPlugin)
L34 method _registerCommands (WritingStudioPlugin)
L112 method _registerStatusBar (WritingStudioPlugin)
L123 method deactivate (WritingStudioPlugin)
L127 method getEngines (WritingStudioPlugin)
src/plugins/built-in/writing-studio/panels/goals-panel.js:
L1 function renderGoalsPanel
src/plugins/built-in/writing-studio/panels/manuscript-panel.js:
L1 function renderManuscriptPanel
src/plugins/built-in/writing-studio/panels/proofread-panel.js:
L1 function renderProofreadPanel
L41 method callback (anonymousObject39a0f0110105)
L50 function renderIssues
src/plugins/built-in/writing-studio/panels/snapshots-panel.js:
L1 function renderSnapshotsPanel
src/plugins/built-in/writing-studio/project-manager.js:
L1 class ProjectManager
L5 method constructor (ProjectManager)
L9 method createProject (ProjectManager)
L21 method loadProject (ProjectManager)
L27 method _saveProject (ProjectManager)
L31 method addChapter (ProjectManager)
L38 method updateChapter (ProjectManager)
L45 method compileManuscript (ProjectManager)
L56 method getStats (ProjectManager)
src/plugins/built-in/writing-studio/snapshot-manager.js:
L1 class SnapshotManager
L6 method constructor (SnapshotManager)
L11 method _getAll (SnapshotManager)
L16 method _saveAll (SnapshotManager)
L20 method create (SnapshotManager)
L34 method list (SnapshotManager)
L38 method getById (SnapshotManager)
L42 method restore (SnapshotManager)
L48 method delete (SnapshotManager)
L53 method diff (SnapshotManager)
L71 method prune (SnapshotManager)
src/plugins/built-in/writing-studio/sprint-engine.js:
L1 class SprintEngine
L6 method constructor (SprintEngine)
L14 method start (SprintEngine)
L22 method stop (SprintEngine)
L33 method tick (SprintEngine)
L43 method isActive (SprintEngine)
L47 method getRemaining (SprintEngine)
src/plugins/event-bus.js:
L1 class EventBus
L2 method constructor (EventBus)
L6 method on (EventBus)
L13 method off (EventBus)
L25 method emit (EventBus)
L37 method hasHandler (EventBus)
src/plugins/plugin-api.js:
L1 class PluginAPI
L7 method init (PluginAPI)
L12 method activate (PluginAPI)
L15 method deactivate (PluginAPI)
L18 method getManifest (PluginAPI)
src/plugins/plugin-context.js:
L1 class PluginContext
L14 method constructor (PluginContext)
L23 method register (PluginContext.constructor.commands)
L24 function safeHandler (PluginContext.constructor.commands.register)
L65 method registerPreHook (PluginContext.constructor.exports)
L68 method registerPostHook (PluginContext.constructor.exports)
src/plugins/plugin-loader.js:
L4 class PluginLoader
L8 method constructor (PluginLoader)
L17 method discoverPlugins (PluginLoader)
L66 method validateManifest (PluginLoader)
src/plugins/plugin-registry.js:
L4 class PluginRegistry
L5 method constructor (PluginRegistry)
L15 method register (PluginRegistry)
L49 method getPlugin (PluginRegistry)
L53 method getAll (PluginRegistry)
L57 method activate (PluginRegistry)
L68 method deactivate (PluginRegistry)
src/plugins/settings-store.js:
L1 class SettingsStore
L5 method constructor (SettingsStore)
L9 method get (SettingsStore)
L13 method set (SettingsStore)
L17 method onChanged (SettingsStore)
src/preload.js:
L257 method send (anonymousObjectb0724fcb0105)
L271 method invoke (anonymousObjectb0724fcb0105)
L290 method on (anonymousObjectb0724fcb0105)
L292 function subscription (anonymousObjectb0724fcb0105.on)
L310 method once (anonymousObjectb0724fcb0105)
L322 method removeAllListeners (anonymousObjectb0724fcb0105)
src/print-preview.js:
L1 class PrintPreview
L2 method constructor (PrintPreview)
L9 method open (PrintPreview)
L20 method close (PrintPreview)
L28 method setupEventListeners (PrintPreview)
L53 method updateScaleLabel (PrintPreview)
L59 method updatePreview (PrintPreview)
L111 method refreshPreview (PrintPreview)
L117 method getOptions (PrintPreview)
L130 method executePrint (PrintPreview)
src/renderer.js:
L19 method send (window.electronAPI)
L22 method invoke (window.electronAPI)
L25 method on (window.electronAPI)
L26 function subscription (window.electronAPI.on)
L32 method once (window.electronAPI)
L35 method removeAllListeners (window.electronAPI)
L81 function getSidebarManager
L85 function getRenderTemplatesPanel
L90 function getRenderExplorerPanel
L95 function getRenderGitPanel
L99 function getRenderSnippetsPanel
L105 function getRenderOutlinePanel
L110 function getReplPanel
L114 function getCommandPalette
L118 function getPrintPreview
L122 function getCreateWelcomeContent
L127 function getZenMode
L132 function getShowAnalyticsModal
L137 function ensureToastContainer
L147 function notifyUser
L159 function dismiss (notifyUser)
L173 method highlight (anonymousObject71555c7b0405)
L202 method start (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
L205 method tokenizer (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
L221 method renderer (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
L241 function plantumlEncode (notifyUser)
L249 function scopeCSS (notifyUser)
L271 class TabManager (notifyUser)
L272 method constructor (notifyUser.TabManager)
L306 method setupEventListeners (notifyUser.TabManager)
L354 method createNewTab (notifyUser.TabManager)
L379 method createPdfTab (notifyUser.TabManager)
L405 method createPdfTabElements (notifyUser.TabManager)
L436 method setupPdfTabEvents (notifyUser.TabManager)
L503 method loadPdfInTab (notifyUser.TabManager)
L528 method renderPdfPageInTab (notifyUser.TabManager)
L555 method createTabElements (notifyUser.TabManager)
L580 method onChange (anonymousObject71555c7b0d05)
L602 method onUpdate (anonymousObject71555c7b0d05)
L614 method switchToTab (notifyUser.TabManager)
L641 method switchToNextTab (notifyUser.TabManager)
L647 method closeTab (notifyUser.TabManager)
L692 method updateTabBar (notifyUser.TabManager)
L718 method updateUI (notifyUser.TabManager)
L748 method saveCurrentTabState (notifyUser.TabManager)
L756 method restoreTabState (notifyUser.TabManager)
L765 method focusActiveEditor (notifyUser.TabManager)
L771 method updatePreview (notifyUser.TabManager)
L800 method _hash (notifyUser.TabManager)
L809 method _renderPreview (notifyUser.TabManager)
L963 function onerror (img)
L997 method updatePreviewVisibility (notifyUser.TabManager)
L1010 method updateLineNumbers (notifyUser.TabManager)
L1015 method updateWordCount (notifyUser.TabManager)
L1031 method updateCursorPosition (notifyUser.TabManager)
L1038 method updateFilePath (notifyUser.TabManager)
L1046 method updateBreadcrumb (notifyUser.TabManager)
L1054 method setupEditorEvents (notifyUser.TabManager)
L1058 method handleEditorInput (notifyUser.TabManager)
L1073 method startAutoSave (notifyUser.TabManager)
L1081 method stopAutoSave (notifyUser.TabManager)
L1087 method performAutoSave (notifyUser.TabManager)
L1103 method showAutoSaveIndicator (notifyUser.TabManager)
L1119 method addToRecentFiles (notifyUser.TabManager)
L1135 method getRecentFiles (notifyUser.TabManager)
L1145 method setupToolbarEvents (notifyUser.TabManager)
L1215 method wrapSelection (notifyUser.TabManager)
L1233 method insertAtLineStart (notifyUser.TabManager)
L1249 method insertTable (notifyUser.TabManager)
L1259 method insertCodeBlock (notifyUser.TabManager)
L1277 method insertHorizontalRule (notifyUser.TabManager)
L1280 method setupFindEvents (notifyUser.TabManager)
L1350 method performFind (notifyUser.TabManager)
L1384 method findNext (notifyUser.TabManager)
L1390 method findPrevious (notifyUser.TabManager)
L1399 method highlightMatch (notifyUser.TabManager)
L1430 method replaceOne (notifyUser.TabManager)
L1454 method replaceAll (notifyUser.TabManager)
L1485 method clearFindHighlights (notifyUser.TabManager)
L1492 method checkForLargeFile (notifyUser.TabManager)
L1508 method openFile (notifyUser.TabManager)
L1555 method getEditorContent (notifyUser.TabManager)
L1564 method setEditorContent (notifyUser.TabManager)
L1579 method insertAtCursor (notifyUser.TabManager)
L1594 method getSelection (notifyUser.TabManager)
L1602 method replaceSelection (notifyUser.TabManager)
L1616 method getCurrentContent (notifyUser.TabManager)
L1620 method getCurrentFilePath (notifyUser.TabManager)
L1735 method render (anonymousObject71555c7b2d05)
L1788 method registerIndicator (anonymousObject71555c7b3105.statusBar)
L1881 function onload (reader)
L2032 method onChange (anonymousObject71555c7b3405)
L2040 method onUpdate (anonymousObject71555c7b3405)
L2164 function applyCustomPreviewCSS (notifyUser)
L2174 function triggerLoadCustomCSS (notifyUser)
L2188 function triggerClearCustomCSS (notifyUser)
L2202 function updateFontSizes (notifyUser)
L2234 function openPrintPreviewDialog (notifyUser)
L2255 function showExportDialog (notifyUser)
L2269 function hideExportDialog (notifyUser)
L2273 function initializeExportForm (notifyUser)
L2343 function collectExportOptions (notifyUser)
L2444 function loadExportProfiles (notifyUser)
L2456 function saveExportProfiles (notifyUser)
L2459 function populateProfileDropdown (notifyUser)
L2476 function saveCurrentProfile (notifyUser)
L2511 function loadProfile (notifyUser)
L2545 function deleteSelectedProfile (notifyUser)
L2679 function onchange (input)
L2776 function showBatchDialog (notifyUser)
L2796 function hideBatchDialog (notifyUser)
L2799 function updateBatchProgress (notifyUser)
L2816 function validateBatchForm (notifyUser)
L3375 function showUniversalConverterDialog (notifyUser)
L3383 function updateConverterFormats (notifyUser)
L3423 function updateConverterAdvancedOptions (notifyUser)
L3434 function collectConverterAdvancedOptions (notifyUser)
L3645 function showPDFEditorDialog (notifyUser)
L3769 function hidePDFEditorDialog (notifyUser)
L3780 function updateMergeFilesList (notifyUser)
L4107 function getPDFStatusElement (notifyUser)
L4110 function showPDFStatus (notifyUser)
L4117 function clearPDFStatus (notifyUser)
L4124 function showPDFValidationMessage (notifyUser)
L4135 function processPDFOperation (notifyUser)
L4379 function initMathSupport (notifyUser)
L4391 function onload (katexJS)
L4396 function onload (autoRenderJS)
L4417 function openHeaderFooterDialog (notifyUser)
L4425 function closeHeaderFooterDialog (notifyUser)
L4430 function openFieldPickerDialog (notifyUser)
L4436 function closeFieldPickerDialog (notifyUser)
L4477 function toggleConfigContent (notifyUser)
L4488 function saveHeaderFooterSettings (notifyUser)
L4509 function browseForLogo (notifyUser)
L4519 function clearLogo (notifyUser)
L4528 function insertDynamicField (notifyUser)
L4594 function showTableGenerator (notifyUser)
L4605 function hideTableGenerator (notifyUser)
L4608 function generateTablePreview (notifyUser)
L4616 function generateMarkdownTable (notifyUser)
L4685 function insertGeneratedTable (notifyUser)
L4742 function showASCIIGenerator (notifyUser)
L4753 function hideASCIIGenerator (notifyUser)
L4756 function switchASCIIMode (notifyUser)
L4784 function generateASCIIPreview (notifyUser)
L4802 function textToASCII (notifyUser)
L5238 function createASCIIBox (notifyUser)
L5312 function getASCIITemplate (notifyUser)
L5469 function loadASCIITemplate (notifyUser)
L5473 function insertASCIIArt (notifyUser)
L5523 function anonymousFunction71555c7bd300
L5603 function getPdfjsLib (notifyUser)
L5612 function openPdfFile (notifyUser)
L5627 function renderPdfPage (notifyUser)
L5733 function closePdfViewer (notifyUser)
L5783 function openPdfEditorDialog (notifyUser)
L5831 function initPaneResizer (notifyUser)
L5920 function onPDFFileSelected (notifyUser)
L5935 function loadPDFThumbnails (notifyUser)
L5966 function renderThumbnailGrid (notifyUser)
L6060 function renderThumbnail (notifyUser)
L6085 function syncRotateInput (notifyUser)
L6094 function syncDeleteInput (notifyUser)
L6103 function syncReorderInput (notifyUser)
src/repl/repl-panel.js:
L1 class ReplPanel
L2 method constructor (ReplPanel)
L8 method setupEventListeners (ReplPanel)
L13 method toggle (ReplPanel)
L19 method show (ReplPanel)
L25 method clear (ReplPanel)
L29 method appendOutput (ReplPanel)
L42 method escapeHtml (ReplPanel)
src/sidebar/explorer-panel.js:
L1 function renderExplorerPanel
L40 function renderTree
L79 function getFileIcon
src/sidebar/git-panel.js:
L1 function renderGitPanel
L24 function loadGitStatus (renderGitPanel)
src/sidebar/outline-panel.js:
L6 function renderOutlinePanel
L18 function parseHeadings (renderOutlinePanel)
L36 function findActiveHeading (renderOutlinePanel)
L48 function renderHeadings (renderOutlinePanel)
L89 function escapeHtml (renderOutlinePanel)
L95 function refresh (renderOutlinePanel)
L100 function setActiveHeading (renderOutlinePanel)
src/sidebar/sidebar-manager.js:
L1 class SidebarManager
L2 method constructor (SidebarManager)
L11 method setupEventListeners (SidebarManager)
L20 method registerPanel (SidebarManager)
L24 method togglePanel (SidebarManager)
L32 method expand (SidebarManager)
L45 method collapse (SidebarManager)
src/sidebar/snippets-panel.js:
L1 function renderSnippetsPanel
L14 function loadSnippets (renderSnippetsPanel)
L19 function renderList (renderSnippetsPanel)
src/sidebar/templates-panel.js:
L18 function renderTemplatesPanel
src/utils/ModalManager.js:
L5 class ModalManager
L16 method constructor (ModalManager)
L31 method init (ModalManager)
L47 method setupCloseTriggers (ModalManager)
L51 function handler (ModalManager.setupCloseTriggers)
L65 function handler
L74 method getFocusableElements (ModalManager)
L89 method trapFocus (ModalManager)
L111 method handleKeydown (ModalManager)
L119 method open (ModalManager)
L141 function keydownHandler (ModalManager.open)
L164 method close (ModalManager)
L182 function addHidden (ModalManager.close)
L188 function onTransitionEnd (ModalManager.close)
L223 method isOpen (ModalManager)
L227 method destroy (ModalManager)
src/welcome.js:
L1 function createWelcomeContent
src/wordTemplateExporter.js:
L11 class WordTemplateExporter
L12 method constructor (WordTemplateExporter)
L24 method preprocessMarkdownForWordExport (WordTemplateExporter)
L33 function flush (WordTemplateExporter.preprocessMarkdownForWordExport)
L59 function stripArtifacts (WordTemplateExporter.preprocessMarkdownForWordExport)
src/zen-mode.js:
L10 class ZenMode
L14 method constructor (ZenMode)
L23 method activate (ZenMode)
L65 method deactivate (ZenMode)
L101 method toggle (ZenMode)
L109 method _applyTypewriterBehavior (ZenMode)
L111 function scrollFn (ZenMode._applyTypewriterBehavior)
L151 method _createHUD (ZenMode)
L169 method _updateHUD (ZenMode)
L220 method constructor (anonymousObject3acf7ce30205)
L240 function getOpacity
L250 method constructor (anonymousObject3acf7ce30405)
tests/git-operations.test.js:
L26 function asyncFn
L35 function asyncFnWithError
tests/main-utils.test.js:
L7 function sanitizeErrorMessage
L49 function createRateLimiter
L51 function canProceed (createRateLimiter)
tests/markdown-extensions.test.js:
L83 method start (extension)
L86 method tokenizer (extension)
L102 method renderer (extension)
L151 function plantumlEncode
L176 function slugify
L203 function scopeCSS
tests/modal-manager.test.js:
L11 function createModalElement
tests/plugin-api.test.js:
L18 class MyPlugin
L19 method init (MyPlugin)
tests/plugin-context.test.js:
L39 function badHandler
tests/plugin-loader.test.js:
L17 function writeManifest
tests/plugin-registry.test.js:
L5 class TestPlugin
L6 method init (TestPlugin)
L10 method activate (TestPlugin)
L13 method deactivate (TestPlugin)
L69 class BadPlugin
L70 method init (BadPlugin)
tests/setup.js:
L96 function error (console)
tests/sidebar.test.js:
L41 method render (anonymousObjectfe2d35d70105)
L52 method render (anonymousObjectfe2d35d70205)
L62 method render (anonymousObjectfe2d35d70305)
L68 method render (anonymousObjectfe2d35d70405)
L80 method render (anonymousObjectfe2d35d70505)
L88 method render (anonymousObjectfe2d35d70605)
L96 method render (anonymousObjectfe2d35d70705)
L120 method render (anonymousObjectfe2d35d70905)
L130 method render (anonymousObjectfe2d35d70a05)
tests/utils.test.js:
L9 function parseCommand
L78 function hexToRgb
L121 function getExtension
L133 function replaceExtension
```
+2 -2
View File
@@ -65,7 +65,7 @@ const electronFsAdapter = {
isDir: entry.isDirectory, isDir: entry.isDirectory,
size: entry.size ?? 0, size: entry.size ?? 0,
modified: entry.modified ?? 0, modified: entry.modified ?? 0,
path: entry.path path: entry.path,
})); }));
}, },
@@ -105,7 +105,7 @@ const electronFsAdapter = {
*/ */
async move(source, dest) { async move(source, dest) {
return await window.electronAPI.file.move(source, dest); return await window.electronAPI.file.move(source, dest);
} },
}; };
module.exports = { electronFsAdapter }; module.exports = { electronFsAdapter };
+16 -6
View File
@@ -61,11 +61,15 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Avg Sentence</span> <span class="analytics-label">Avg Sentence</span>
<span class="analytics-value">${metrics.avgSentenceLength} words</span> <span class="analytics-value">${metrics.avgSentenceLength} words</span>
</div> </div>
${metrics.longestSentenceLength > 0 ? ` ${
metrics.longestSentenceLength > 0
? `
<div class="analytics-row analytics-longest"> <div class="analytics-row analytics-longest">
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span> <span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span> <span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
</div>` : ''} </div>`
: ''
}
</div> </div>
<div class="analytics-section"> <div class="analytics-section">
@@ -74,13 +78,19 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Unique</span> <span class="analytics-label">Unique</span>
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span> <span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
</div> </div>
${metrics.topWords.length > 0 ? ` ${
metrics.topWords.length > 0
? `
<div class="word-cloud"> <div class="word-cloud">
${metrics.topWords.map(w => { ${metrics.topWords
.map((w) => {
const scale = 13 + Math.round((w.count / maxCount) * 3); const scale = 13 + Math.round((w.count / maxCount) * 3);
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`; return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
}).join('')} })
</div>` : ''} .join('')}
</div>`
: ''
}
</div> </div>
</div> </div>
</div> </div>
+89 -16
View File
@@ -4,14 +4,74 @@
*/ */
const STOP_WORDS = new Set([ const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'the',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'a',
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with', 'an',
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it', 'is',
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'are',
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her', 'was',
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too', 'were',
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who' 'be',
'been',
'have',
'has',
'had',
'do',
'does',
'did',
'will',
'would',
'could',
'should',
'to',
'of',
'in',
'for',
'on',
'with',
'at',
'by',
'from',
'as',
'and',
'or',
'but',
'if',
'it',
'its',
'this',
'that',
'these',
'those',
'i',
'me',
'my',
'we',
'our',
'you',
'your',
'he',
'him',
'his',
'she',
'her',
'they',
'them',
'their',
'not',
'no',
'so',
'than',
'too',
'very',
'also',
'just',
'about',
'up',
'out',
'what',
'which',
'who',
]); ]);
function countSyllables(word) { function countSyllables(word) {
@@ -48,17 +108,23 @@ function analyze(text) {
avgSentenceLength: 0, avgSentenceLength: 0,
longestSentence: '', longestSentence: '',
longestSentenceLength: 0, longestSentenceLength: 0,
topWords: [] topWords: [],
}; };
} }
const words = extractWords(text); const words = extractWords(text);
const wordCount = words.length; const wordCount = words.length;
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean); const sentences = text
.split(/[.!?]+/)
.map((s) => s.trim())
.filter(Boolean);
const sentenceCount = Math.max(sentences.length, 1); const sentenceCount = Math.max(sentences.length, 1);
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean); const paragraphs = text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const paragraphCount = Math.max(paragraphs.length, 1); const paragraphCount = Math.max(paragraphs.length, 1);
let totalSyllables = 0; let totalSyllables = 0;
@@ -66,16 +132,23 @@ function analyze(text) {
totalSyllables += countSyllables(w); totalSyllables += countSyllables(w);
} }
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10; const fleschEase =
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10; Math.round(
(206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10
) / 10;
const fleschGrade =
Math.round(
(0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10
) / 10;
const readabilityLabel = getReadabilityLabel(fleschEase); const readabilityLabel = getReadabilityLabel(fleschEase);
const readingTime = Math.ceil(wordCount / 200); const readingTime = Math.ceil(wordCount / 200);
const speakingTime = Math.ceil(wordCount / 130); const speakingTime = Math.ceil(wordCount / 130);
const uniqueWords = new Set(words.map(w => w.toLowerCase())); const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
const uniqueWordCount = uniqueWords.size; const uniqueWordCount = uniqueWords.size;
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0; const lexicalDiversity =
wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10; const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
@@ -120,7 +193,7 @@ function analyze(text) {
avgSentenceLength, avgSentenceLength,
longestSentence, longestSentence,
longestSentenceLength, longestSentenceLength,
topWords topWords,
}; };
} }
+284 -135
View File
@@ -1,10 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Art Generator - MarkdownConverter</title> <title>ASCII Art Generator - MarkdownConverter</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style> <style>
:root { :root {
--ci-dark-gray: #464646; --ci-dark-gray: #464646;
@@ -103,7 +106,9 @@
color: var(--ci-dark-gray); color: var(--ci-dark-gray);
} }
.form-input, .form-select, .form-textarea { .form-input,
.form-select,
.form-textarea {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
border: 2px solid var(--ci-light-gray); border: 2px solid var(--ci-light-gray);
@@ -113,7 +118,9 @@
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.form-input:focus, .form-select:focus, .form-textarea:focus { .form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none; outline: none;
border-color: var(--ci-accent); border-color: var(--ci-accent);
} }
@@ -245,7 +252,13 @@
<div id="text-mode" class="mode-section active"> <div id="text-mode" class="mode-section active">
<div class="form-group"> <div class="form-group">
<label class="form-label">Text to Convert</label> <label class="form-label">Text to Convert</label>
<input type="text" id="text-input" class="form-input" placeholder="Enter your text..." maxlength="30"> <input
type="text"
id="text-input"
class="form-input"
placeholder="Enter your text..."
maxlength="30"
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Style</label> <label class="form-label">Style</label>
@@ -263,7 +276,11 @@
<div id="box-mode" class="mode-section"> <div id="box-mode" class="mode-section">
<div class="form-group"> <div class="form-group">
<label class="form-label">Text Content</label> <label class="form-label">Text Content</label>
<textarea id="box-text" class="form-textarea" placeholder="Enter text for the box..."></textarea> <textarea
id="box-text"
class="form-textarea"
placeholder="Enter text for the box..."
></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Box Style</label> <label class="form-label">Box Style</label>
@@ -277,7 +294,15 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Padding</label> <label class="form-label">Padding</label>
<input type="number" id="box-padding" class="form-input" min="0" max="10" value="2" style="width: 100px;"> <input
type="number"
id="box-padding"
class="form-input"
min="0"
max="10"
value="2"
style="width: 100px"
/>
</div> </div>
</div> </div>
@@ -341,138 +366,262 @@
standard: { standard: {
height: 5, height: 5,
chars: { chars: {
'A': [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'], A: [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
'B': ['|----\\', '| |', '|----/', '| \\', '|----/'], B: ['|----\\', '| |', '|----/', '| \\', '|----/'],
'C': ['/----\\', '| ', '| ', '| ', '\\----/'], C: ['/----\\', '| ', '| ', '| ', '\\----/'],
'D': ['|----\\', '| |', '| |', '| |', '|----/'], D: ['|----\\', '| |', '| |', '| |', '|----/'],
'E': ['|----', '| ', '|--- ', '| ', '|----'], E: ['|----', '| ', '|--- ', '| ', '|----'],
'F': ['|----', '| ', '|--- ', '| ', '| '], F: ['|----', '| ', '|--- ', '| ', '| '],
'G': ['/----\\', '| ', '| |--\\', '| |', '\\----/'], G: ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
'H': ['| |', '| |', '|----/', '| |', '| |'], H: ['| |', '| |', '|----/', '| |', '| |'],
'I': ['|---|', ' | ', ' | ', ' | ', '|---|'], I: ['|---|', ' | ', ' | ', ' | ', '|---|'],
'J': [' |', ' |', ' |', '| |', '\\---/'], J: [' |', ' |', ' |', '| |', '\\---/'],
'K': ['| /', '| / ', '|-- ', '| \\ ', '| \\'], K: ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
'L': ['| ', '| ', '| ', '| ', '|----'], L: ['| ', '| ', '| ', '| ', '|----'],
'M': ['|\\ /|', '| \\/ |', '| |', '| |', '| |'], M: ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
'N': ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'], N: ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
'O': ['/----\\', '| |', '| |', '| |', '\\----/'], O: ['/----\\', '| |', '| |', '| |', '\\----/'],
'P': ['|----\\', '| |', '|----/', '| ', '| '], P: ['|----\\', '| |', '|----/', '| ', '| '],
'Q': ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'], Q: ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
'R': ['|----\\', '| |', '|----/', '| \\ ', '| \\ '], R: ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
'S': ['/----\\', '| ', '\\----\\', ' |', '\\----/'], S: ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
'T': ['-----', ' | ', ' | ', ' | ', ' | '], T: ['-----', ' | ', ' | ', ' | ', ' | '],
'U': ['| |', '| |', '| |', '| |', '\\----/'], U: ['| |', '| |', '| |', '| |', '\\----/'],
'V': ['| |', '| |', ' \\ / ', ' \\/ ', ' '], V: ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
'W': ['| |', '| |', '| |', '| /\\ |', '|/ \\|'], W: ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
'X': ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '], X: ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
'Y': ['\\ /', ' \\ / ', ' | ', ' | ', ' | '], Y: ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
'Z': ['-----', ' / ', ' / ', ' / ', '-----'], Z: ['-----', ' / ', ' / ', ' / ', '-----'],
' ': [' ', ' ', ' ', ' ', ' '], ' ': [' ', ' ', ' ', ' ', ' '],
'0': ['/---\\', '| |', '| / |', '|/ |', '\\---/'], 0: ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
'1': [' /| ', ' / | ', ' | ', ' | ', ' ----'], 1: [' /| ', ' / | ', ' | ', ' | ', ' ----'],
'2': ['/---\\', ' |', ' ---/', '/ ', '-----'], 2: ['/---\\', ' |', ' ---/', '/ ', '-----'],
'3': ['----\\', ' |', ' ---/', ' |', '----/'], 3: ['----\\', ' |', ' ---/', ' |', '----/'],
'4': ['| |', '| |', '-----', ' |', ' |'], 4: ['| |', '| |', '-----', ' |', ' |'],
'5': ['-----', '| ', '----\\', ' |', '----/'], 5: ['-----', '| ', '----\\', ' |', '----/'],
'6': ['/----', '| ', '|---\\', '| |', '\\---/'], 6: ['/----', '| ', '|---\\', '| |', '\\---/'],
'7': ['-----', ' / ', ' / ', ' / ', '/ '], 7: ['-----', ' / ', ' / ', ' / ', '/ '],
'8': ['/---\\', '| |', ' --- ', '| |', '\\---/'], 8: ['/---\\', '| |', ' --- ', '| |', '\\---/'],
'9': ['/---\\', '| |', '\\----', ' |', '----/'] 9: ['/---\\', '| |', '\\----', ' |', '----/'],
} },
}, },
banner: { banner: {
height: 7, height: 7,
chars: { chars: {
'A': [' ##### ', ' ## ##', '## ##', '#########', '## ##', '## ##', '## ##'], A: [
'B': ['######## ', '## ##', '## ##', '######## ', '## ##', '## ##', '######## '], ' ##### ',
'C': [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '], ' ## ##',
'D': ['######## ', '## ##', '## ##', '## ##', '## ##', '## ##', '######## '], '## ##',
'E': ['########', '## ', '## ', '###### ', '## ', '## ', '########'], '#########',
'F': ['########', '## ', '## ', '###### ', '## ', '## ', '## '], '## ##',
'G': [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '], '## ##',
'H': ['## ##', '## ##', '## ##', '#########', '## ##', '## ##', '## ##'], '## ##',
'I': ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'], ],
'J': [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '], B: [
'K': ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'], '######## ',
'L': ['## ', '## ', '## ', '## ', '## ', '## ', '########'], '## ##',
'M': ['## ##', '### ###', '#### ####', '## ### ##', '## ##', '## ##', '## ##'], '## ##',
'N': ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'], '######## ',
'O': [' ####### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '], '## ##',
'P': ['######## ', '## ##', '## ##', '######## ', '## ', '## ', '## '], '## ##',
'Q': [' ####### ', '## ##', '## ##', '## ##', '## ## ##', '## ## ', ' ##### ##'], '######## ',
'R': ['######## ', '## ##', '## ##', '######## ', '## ## ', '## ## ', '## ##'], ],
'S': [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '], C: [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
'T': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '], D: [
'U': ['## ##', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '], '######## ',
'V': ['## ##', '## ##', '## ##', '## ##', ' ## ## ', ' ## ## ', ' ### '], '## ##',
'W': ['## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', ' ### ### '], '## ##',
'X': ['## ##', ' ## ## ', ' ## ## ', ' ### ', ' ## ## ', ' ## ## ', '## ##'], '## ##',
'Y': ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '], '## ##',
'Z': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'], '## ##',
'######## ',
],
E: ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
F: ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
G: [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
H: [
'## ##',
'## ##',
'## ##',
'#########',
'## ##',
'## ##',
'## ##',
],
I: ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
J: [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
K: ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
L: ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
M: [
'## ##',
'### ###',
'#### ####',
'## ### ##',
'## ##',
'## ##',
'## ##',
],
N: ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
O: [
' ####### ',
'## ##',
'## ##',
'## ##',
'## ##',
'## ##',
' ####### ',
],
P: [
'######## ',
'## ##',
'## ##',
'######## ',
'## ',
'## ',
'## ',
],
Q: [
' ####### ',
'## ##',
'## ##',
'## ##',
'## ## ##',
'## ## ',
' ##### ##',
],
R: [
'######## ',
'## ##',
'## ##',
'######## ',
'## ## ',
'## ## ',
'## ##',
],
S: [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
T: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
U: [
'## ##',
'## ##',
'## ##',
'## ##',
'## ##',
'## ##',
' ####### ',
],
V: [
'## ##',
'## ##',
'## ##',
'## ##',
' ## ## ',
' ## ## ',
' ### ',
],
W: [
'## ##',
'## ## ##',
'## ## ##',
'## ## ##',
'## ## ##',
'## ## ##',
' ### ### ',
],
X: [
'## ##',
' ## ## ',
' ## ## ',
' ### ',
' ## ## ',
' ## ## ',
'## ##',
],
Y: ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
Z: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '], ' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
'0': [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '], 0: [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
'1': [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '], 1: [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
'2': [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'], 2: [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
'3': [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '], 3: [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
'4': [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '], 4: [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
'5': ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '], 5: ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
'6': [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '], 6: [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
'7': ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '], 7: ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
'8': [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '], 8: [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
'9': [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### '] 9: [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### '],
} },
}, },
block: { block: {
height: 6, height: 6,
chars: { chars: {
'A': ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'], A: ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
'B': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '], B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
'C': ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '], C: ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
'D': ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '], D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
'E': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'], E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
'F': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '], F: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
'G': ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '], G: ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
'H': ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'], H: ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
'I': ['██╗', '██║', '██║', '██║', '██║', '╚═╝'], I: ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
'J': [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '], J: [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
'K': ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'], K: ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
'L': ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'], L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
'M': ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'], M: [
'N': ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'], '███╗ ███╗',
'O': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '], '████╗ ████║',
'P': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '], '██╔████╔██║',
'Q': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'], '██║╚██╔╝██║',
'R': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'], '██║ ╚═╝ ██║',
'S': ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '], '╚═╝ ╚═╝',
'T': ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '], ],
'U': ['██╗ ██╗', '██ ██║', '██ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '], N: ['██ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████', '╚═╝ ╚═══'],
'V': ['████╗', '████', '██║ ██║', '██ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '], O: ['████╗ ', '██╔══██', '██║ ██║', '██ ██', '╚████╔╝', ' ╚═══╝ '],
'W': ['████╗', '████', '██║ █╗ ██║', '██║███╗██║', '███╔███╔╝', ' ╚══╝╚══╝ '], P: ['██████╗ ', '██╔══██', '██████╔╝', '██╔═══╝ ', '██', '╚═╝ '],
'X': ['████╗', '██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'], Q: ['████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╗', ' ╚═══██╝'],
'Y': ['██╗ ██╗', '██╗ ██╔╝', '████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '], R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
'Z': ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'], S: ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████', '╚════╝ '],
' ': [' ', ' ', ' ', ' ', ' ', ' '] T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
} U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
} V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
X: ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
Y: ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
Z: ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
' ': [' ', ' ', ' ', ' ', ' ', ' '],
},
},
}; };
const TEMPLATES = { const TEMPLATES = {
'arrow-right': ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘', 'arrow-right':
'arrow-down': ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼', ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
'decision': ' ╱╲\n ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼', 'arrow-down':
'process': '┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘', ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
'flowchart': '┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘', decision:
'sequence': ' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query \n │ ├──────────►│\n │ │\n │ │ Result │\n ◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │', ' ╱╲\n ╲\n ? ╲\n \n ────────\n ╲\n YES NO\n \n ▼ ▼',
'network': ' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘', process:
'hierarchy': ' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n VP1 │ VP2 │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘', '┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘─────┘',
'header': '╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝', flowchart:
'note': '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n NOTE: ┃\n┃ This is an important note \n┃ that requires attention! \n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛', '┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ \n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C \n└──────┘ └──────┘',
'warning': '╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝', sequence:
'info': '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here.\n╰────────────────────────────────────╯', ' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n Query │\n ├──────────►│\n │\n │ │ Result │\n │ ──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
'divider': '════════════════════════════════════════', network:
'separator': '╭──────────────────────────────────────\n │\n──────────────────────────────────────', ' ┌─────────┐\n │ Server │\n └────────┘\n │\n ┌──────────────────\n │\n────────┐ ┌────┐ ┌────────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
'banner': '★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★', hierarchy:
'checklist': '☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed' ' ┌─────────┐\n │ CEO \n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
header:
'╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
note: '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
warning:
'╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
info: '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
divider: '════════════════════════════════════════',
separator:
'╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
banner:
'★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
checklist:
'☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed',
}; };
const BOX_STYLES = { const BOX_STYLES = {
@@ -480,20 +629,20 @@
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' }, double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' }, rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' }, bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' } ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
}; };
let currentMode = 'text'; let currentMode = 'text';
let currentTemplate = null; let currentTemplate = null;
// Mode switching // Mode switching
document.querySelectorAll('.mode-tab').forEach(tab => { document.querySelectorAll('.mode-tab').forEach((tab) => {
tab.addEventListener('click', () => { tab.addEventListener('click', () => {
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
currentMode = tab.dataset.mode; currentMode = tab.dataset.mode;
document.querySelectorAll('.mode-section').forEach(s => s.classList.remove('active')); document.querySelectorAll('.mode-section').forEach((s) => s.classList.remove('active'));
document.getElementById(currentMode + '-mode').classList.add('active'); document.getElementById(currentMode + '-mode').classList.add('active');
generatePreview(); generatePreview();
@@ -501,9 +650,9 @@
}); });
// Template selection // Template selection
document.querySelectorAll('.template-btn').forEach(btn => { document.querySelectorAll('.template-btn').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.template-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
currentTemplate = btn.dataset.template; currentTemplate = btn.dataset.template;
generatePreview(); generatePreview();
@@ -531,7 +680,7 @@
function generateBox(text, style, padding) { function generateBox(text, style, padding) {
const box = BOX_STYLES[style] || BOX_STYLES.single; const box = BOX_STYLES[style] || BOX_STYLES.single;
const lines = text.split('\n'); const lines = text.split('\n');
const maxLen = Math.max(...lines.map(l => l.length)) + padding * 2; const maxLen = Math.max(...lines.map((l) => l.length)) + padding * 2;
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n'; let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
+7 -3
View File
@@ -60,15 +60,19 @@ class CommandPalette {
renderResults(query) { renderResults(query) {
this.filteredCommands = query this.filteredCommands = query
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase())) ? this.commands.filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()))
: [...this.commands]; : [...this.commands];
this.results.innerHTML = this.filteredCommands.map((cmd, i) => ` this.results.innerHTML = this.filteredCommands
.map(
(cmd, i) => `
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}"> <div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span> <span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''} ${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
</div> </div>
`).join(''); `
)
.join('');
this.results.querySelectorAll('.command-item').forEach((el) => { this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
+36 -29
View File
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown'); const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
// Language extensions loaded lazily on first use // Language extensions loaded lazily on first use
let _javascript, _html, _css, _json, _python; let _javascript, _html, _css, _json, _python;
const { const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
defaultKeymap, const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
history, const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
historyKeymap, const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
indentWithTab,
} = require('@codemirror/commands');
const {
searchKeymap,
highlightSelectionMatches,
} = require('@codemirror/search');
const {
autocompletion,
completionKeymap,
} = require('@codemirror/autocomplete');
const {
bracketMatching,
foldGutter,
indentOnInput,
} = require('@codemirror/language');
const { oneDark } = require('@codemirror/theme-one-dark'); const { oneDark } = require('@codemirror/theme-one-dark');
// Custom theme for JetBrains Mono font // Custom theme for JetBrains Mono font
const jetBrainsMonoTheme = EditorView.theme({ const jetBrainsMonoTheme = EditorView.theme({
'&': { '&': {
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace" fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
}, },
'.cm-content': { '.cm-content': {
fontFamily: 'inherit' fontFamily: 'inherit',
}, },
'.cm-scroller': { '.cm-scroller': {
fontFamily: 'inherit' fontFamily: 'inherit',
} },
}); });
/** /**
@@ -59,7 +44,14 @@ const jetBrainsMonoTheme = EditorView.theme({
* @returns {EditorView} the created editor view * @returns {EditorView} the created editor view
*/ */
function createEditor(parentElement, options = {}) { function createEditor(parentElement, options = {}) {
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight); console.log(
'[createEditor] Called with parentElement:',
parentElement?.id,
'dimensions:',
parentElement?.clientWidth,
'x',
parentElement?.clientHeight
);
if (!parentElement) { if (!parentElement) {
console.error('[createEditor] ERROR: parentElement is null or undefined!'); console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null; return null;
@@ -125,11 +117,26 @@ function createEditor(parentElement, options = {}) {
*/ */
function getLanguageExtension(lang) { function getLanguageExtension(lang) {
const loaders = { const loaders = {
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); }, javascript: () => {
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); }, if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); }, return _javascript();
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); }, },
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); }, html: () => {
if (!_html) _html = require('@codemirror/lang-html').html;
return _html();
},
css: () => {
if (!_css) _css = require('@codemirror/lang-css').css;
return _css();
},
json: () => {
if (!_json) _json = require('@codemirror/lang-json').json;
return _json();
},
python: () => {
if (!_python) _python = require('@codemirror/lang-python').python;
return _python();
},
markdown: () => markdown({ base: markdownLanguage }), markdown: () => markdown({ base: markdownLanguage }),
}; };
loaders.js = loaders.javascript; loaders.js = loaders.javascript;
+1029 -242
View File
File diff suppressed because it is too large Load Diff
+1415 -887
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ async function getStatus(dir) {
try { try {
const git = getGitInstance(dir); const git = getGitInstance(dir);
return await git.status(); return await git.status();
} catch (err) { } catch {
return { error: 'Not a git repository' }; return { error: 'Not a git repository' };
} }
} }
+58 -39
View File
@@ -4,11 +4,11 @@ const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
function parsePageRanges(rangeString, totalPages) { function parsePageRanges(rangeString, totalPages) {
const pages = []; const pages = [];
const ranges = rangeString.split(',').map(r => r.trim()); const ranges = rangeString.split(',').map((r) => r.trim());
for (const range of ranges) { for (const range of ranges) {
if (range.includes('-')) { if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim())); const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let i = start; i <= end && i <= totalPages; i++) { for (let i = start; i <= end && i <= totalPages; i++) {
if (i > 0 && !pages.includes(i - 1)) { if (i > 0 && !pages.includes(i - 1)) {
pages.push(i - 1); pages.push(i - 1);
@@ -27,11 +27,13 @@ function parsePageRanges(rangeString, totalPages) {
function hexToRgb(hex) { function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? { return result
? {
r: parseInt(result[1], 16) / 255, r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255, g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255 b: parseInt(result[3], 16) / 255,
} : { r: 0, g: 0, b: 0 }; }
: { r: 0, g: 0, b: 0 };
} }
async function pdfMerge(data) { async function pdfMerge(data) {
@@ -42,7 +44,7 @@ async function pdfMerge(data) {
const pdfBytes = fs.readFileSync(filePath); const pdfBytes = fs.readFileSync(filePath);
const pdf = await PDFDocument.load(pdfBytes); const pdf = await PDFDocument.load(pdfBytes);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()); const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach(page => mergedPdf.addPage(page)); copiedPages.forEach((page) => mergedPdf.addPage(page));
} }
const pdfBytes = await mergedPdf.save(); const pdfBytes = await mergedPdf.save();
@@ -63,13 +65,13 @@ async function pdfSplit(data) {
const splits = []; const splits = [];
if (data.splitMode === 'pages') { if (data.splitMode === 'pages') {
const ranges = data.pageRanges.split(',').map(r => r.trim()); const ranges = data.pageRanges.split(',').map((r) => r.trim());
for (let i = 0; i < ranges.length; i++) { for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]; const range = ranges[i];
const pages = []; const pages = [];
if (range.includes('-')) { if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim())); const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let p = start; p <= end && p <= totalPages; p++) { for (let p = start; p <= end && p <= totalPages; p++) {
pages.push(p - 1); pages.push(p - 1);
} }
@@ -108,7 +110,7 @@ async function pdfSplit(data) {
for (const split of splits) { for (const split of splits) {
const newPdf = await PDFDocument.create(); const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, split.pages); const copiedPages = await newPdf.copyPages(pdf, split.pages);
copiedPages.forEach(page => newPdf.addPage(page)); copiedPages.forEach((page) => newPdf.addPage(page));
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`); const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
const newPdfBytes = await newPdf.save(); const newPdfBytes = await newPdf.save();
@@ -129,18 +131,18 @@ async function pdfCompress(data) {
const compressedPdfBytes = await pdf.save({ const compressedPdfBytes = await pdf.save({
useObjectStreams: true, useObjectStreams: true,
addDefaultPage: false, addDefaultPage: false,
objectsPerTick: 50 objectsPerTick: 50,
}); });
fs.writeFileSync(data.outputPath, compressedPdfBytes); fs.writeFileSync(data.outputPath, compressedPdfBytes);
const originalSize = fs.statSync(data.inputPath).size; const originalSize = fs.statSync(data.inputPath).size;
const compressedSize = fs.statSync(data.outputPath).size; const compressedSize = fs.statSync(data.outputPath).size;
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1); const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
return { return {
success: true, success: true,
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)` message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -160,7 +162,7 @@ async function pdfRotate(data) {
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i); pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
} }
pagesToRotate.forEach(pageIndex => { pagesToRotate.forEach((pageIndex) => {
const page = pdf.getPage(pageIndex); const page = pdf.getPage(pageIndex);
page.setRotation(degrees(data.angle)); page.setRotation(degrees(data.angle));
}); });
@@ -170,7 +172,7 @@ async function pdfRotate(data) {
return { return {
success: true, success: true,
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0` message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -185,7 +187,9 @@ async function pdfDeletePages(data) {
const pagesToDelete = parsePageRanges(data.pages, totalPages); const pagesToDelete = parsePageRanges(data.pages, totalPages);
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => { pagesToDelete
.sort((a, b) => b - a)
.forEach((pageIndex) => {
pdf.removePage(pageIndex); pdf.removePage(pageIndex);
}); });
@@ -194,7 +198,7 @@ async function pdfDeletePages(data) {
return { return {
success: true, success: true,
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages` message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -207,7 +211,7 @@ async function pdfReorder(data) {
const pdf = await PDFDocument.load(pdfBytes); const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount(); const totalPages = pdf.getPageCount();
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1); const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
if (newOrder.length !== totalPages) { if (newOrder.length !== totalPages) {
return { success: false, error: `New order must include all ${totalPages} pages` }; return { success: false, error: `New order must include all ${totalPages} pages` };
@@ -215,7 +219,7 @@ async function pdfReorder(data) {
const newPdf = await PDFDocument.create(); const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, newOrder); const copiedPages = await newPdf.copyPages(pdf, newOrder);
copiedPages.forEach(page => newPdf.addPage(page)); copiedPages.forEach((page) => newPdf.addPage(page));
const reorderedPdfBytes = await newPdf.save(); const reorderedPdfBytes = await newPdf.save();
fs.writeFileSync(data.outputPath, reorderedPdfBytes); fs.writeFileSync(data.outputPath, reorderedPdfBytes);
@@ -246,7 +250,9 @@ async function pdfWatermark(data) {
const page = pdf.getPage(pageIndex); const page = pdf.getPage(pageIndex);
const { width, height } = page.getSize(); const { width, height } = page.getSize();
let x, y, rotation = 0; let x,
y,
rotation = 0;
switch (data.position) { switch (data.position) {
case 'center': case 'center':
@@ -294,7 +300,7 @@ async function pdfWatermark(data) {
font, font,
color: rgb(color.r, color.g, color.b), color: rgb(color.r, color.g, color.b),
opacity: data.opacity, opacity: data.opacity,
rotate: degrees(rotation) rotate: degrees(rotation),
}); });
} }
@@ -303,7 +309,7 @@ async function pdfWatermark(data) {
return { return {
success: true, success: true,
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)` message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -325,8 +331,8 @@ async function pdfEncrypt(data) {
annotating: data.permissions.annotating, annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms, fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility, contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly documentAssembly: data.permissions.documentAssembly,
} },
}); });
fs.writeFileSync(data.outputPath, encryptedPdfBytes); fs.writeFileSync(data.outputPath, encryptedPdfBytes);
@@ -336,7 +342,8 @@ async function pdfEncrypt(data) {
if (error.message.includes('encrypt') || error.message.includes('password')) { if (error.message.includes('encrypt') || error.message.includes('password')) {
return { return {
success: false, success: false,
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.' error:
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
}; };
} }
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -375,8 +382,8 @@ async function pdfSetPermissions(data) {
annotating: data.permissions.annotating, annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms, fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility, contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly documentAssembly: data.permissions.documentAssembly,
} },
}); });
fs.writeFileSync(data.outputPath, newPdfBytes); fs.writeFileSync(data.outputPath, newPdfBytes);
@@ -386,7 +393,8 @@ async function pdfSetPermissions(data) {
if (error.message.includes('encrypt') || error.message.includes('permission')) { if (error.message.includes('encrypt') || error.message.includes('permission')) {
return { return {
success: false, success: false,
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.' error:
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
}; };
} }
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -395,17 +403,28 @@ async function pdfSetPermissions(data) {
function executeOperation(operation, data) { function executeOperation(operation, data) {
switch (operation) { switch (operation) {
case 'merge': return pdfMerge(data); case 'merge':
case 'split': return pdfSplit(data); return pdfMerge(data);
case 'compress': return pdfCompress(data); case 'split':
case 'rotate': return pdfRotate(data); return pdfSplit(data);
case 'delete': return pdfDeletePages(data); case 'compress':
case 'reorder': return pdfReorder(data); return pdfCompress(data);
case 'watermark': return pdfWatermark(data); case 'rotate':
case 'encrypt': return pdfEncrypt(data); return pdfRotate(data);
case 'decrypt': return pdfDecrypt(data); case 'delete':
case 'permissions': return pdfSetPermissions(data); return pdfDeletePages(data);
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` }); case 'reorder':
return pdfReorder(data);
case 'watermark':
return pdfWatermark(data);
case 'encrypt':
return pdfEncrypt(data);
case 'decrypt':
return pdfDecrypt(data);
case 'permissions':
return pdfSetPermissions(data);
default:
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
} }
} }
@@ -429,5 +448,5 @@ module.exports = {
pdfDecrypt, pdfDecrypt,
pdfSetPermissions, pdfSetPermissions,
executeOperation, executeOperation,
getPageCount getPageCount,
}; };
+1 -3
View File
@@ -5,9 +5,7 @@
"description": "Demonstrates the plugin system. Safe to delete.", "description": "Demonstrates the plugin system. Safe to delete.",
"icon": "puzzle", "icon": "puzzle",
"extensionPoints": { "extensionPoints": {
"commands": [ "commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
]
}, },
"settings": [] "settings": []
} }
+47 -17
View File
@@ -9,7 +9,7 @@ class WritingStudioPlugin extends PluginAPI {
this.context = context; this.context = context;
this.sprintEngine = new SprintEngine({ this.sprintEngine = new SprintEngine({
onEvent: (name, data) => context.events.emit(name, data) onEvent: (name, data) => context.events.emit(name, data),
}); });
this.goalTracker = new GoalTracker(context.settings); this.goalTracker = new GoalTracker(context.settings);
this.snapshotManager = new SnapshotManager(context.settings); this.snapshotManager = new SnapshotManager(context.settings);
@@ -17,14 +17,14 @@ class WritingStudioPlugin extends PluginAPI {
readFile: (p) => context.ipc.invoke('read-file', p), readFile: (p) => context.ipc.invoke('read-file', p),
writeFile: (p, c) => context.ipc.invoke('write-file', p, c), writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
fileExists: (p) => context.ipc.invoke('path-exists', p), fileExists: (p) => context.ipc.invoke('path-exists', p),
listDir: (p) => context.ipc.invoke('list-directory', p) listDir: (p) => context.ipc.invoke('list-directory', p),
}); });
this._engines = { this._engines = {
sprint: this.sprintEngine, sprint: this.sprintEngine,
goals: this.goalTracker, goals: this.goalTracker,
snapshots: this.snapshotManager, snapshots: this.snapshotManager,
projects: this.projectManager projects: this.projectManager,
}; };
this._registerCommands(context); this._registerCommands(context);
@@ -34,59 +34,89 @@ class WritingStudioPlugin extends PluginAPI {
_registerCommands(context) { _registerCommands(context) {
const { sprintEngine, snapshotManager, goalTracker } = this; const { sprintEngine, snapshotManager, goalTracker } = this;
context.commands.register('start-sprint', 'Studio: Start Sprint', () => { context.commands.register(
'start-sprint',
'Studio: Start Sprint',
() => {
const duration = context.settings.get('sprintDuration') || 25; const duration = context.settings.get('sprintDuration') || 25;
const content = context.editor.getContent() || ''; const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length; const words = content.split(/\s+/).filter(Boolean).length;
sprintEngine.start(duration, words); sprintEngine.start(duration, words);
}, 'Ctrl+Alt+S'); },
'Ctrl+Alt+S'
);
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => { context.commands.register(
'stop-sprint',
'Studio: Stop Sprint',
() => {
if (!sprintEngine.isActive()) return; if (!sprintEngine.isActive()) return;
const content = context.editor.getContent() || ''; const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length; const words = content.split(/\s+/).filter(Boolean).length;
const result = sprintEngine.stop(words); const result = sprintEngine.stop(words);
goalTracker.addWords(result.wordDelta); goalTracker.addWords(result.wordDelta);
context.events.emit('sprint:stopped', result); context.events.emit('sprint:stopped', result);
}, 'Ctrl+Alt+Shift+S'); },
'Ctrl+Alt+Shift+S'
);
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => { context.commands.register(
'take-snapshot',
'Studio: Take Snapshot',
() => {
const content = context.editor.getContent() || ''; const content = context.editor.getContent() || '';
snapshotManager.create(content, 'manual'); snapshotManager.create(content, 'manual');
context.events.emit('snapshot:created', {}); context.events.emit('snapshot:created', {});
}, 'Ctrl+Alt+N'); },
'Ctrl+Alt+N'
);
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => { context.commands.register(
'restore-last-snapshot',
'Studio: Restore Last Snapshot',
() => {
const snaps = snapshotManager.list(); const snaps = snapshotManager.list();
if (snaps.length === 0) return; if (snaps.length === 0) return;
const content = snapshotManager.restore(snaps[0].id); const content = snapshotManager.restore(snaps[0].id);
context.editor.insertAtCursor(content); context.editor.insertAtCursor(content);
}, 'Ctrl+Alt+Z'); },
'Ctrl+Alt+Z'
);
context.commands.register('new-project', 'Studio: New Project', () => { context.commands.register('new-project', 'Studio: New Project', () => {
context.events.emit('studio:new-project', {}); context.events.emit('studio:new-project', {});
}); });
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => { context.commands.register(
'compile-manuscript',
'Studio: Compile Manuscript',
() => {
context.events.emit('studio:compile', {}); context.events.emit('studio:compile', {});
}, 'Ctrl+Alt+E'); },
'Ctrl+Alt+E'
);
context.commands.register('proofread-document', 'Studio: Proofread Document', () => { context.commands.register(
'proofread-document',
'Studio: Proofread Document',
() => {
if (context.events.hasHandler('ai:analyze')) { if (context.events.hasHandler('ai:analyze')) {
const content = context.editor.getContent() || ''; const content = context.editor.getContent() || '';
context.events.emit('ai:analyze', { text: content, type: 'grammar' }); context.events.emit('ai:analyze', { text: content, type: 'grammar' });
} }
}, 'Ctrl+Alt+G'); },
'Ctrl+Alt+G'
);
} }
_registerStatusBar(context) { _registerStatusBar(context) {
context.statusBar.registerIndicator('word-goal', { context.statusBar.registerIndicator('word-goal', {
text: '0/1000', text: '0/1000',
tooltip: 'Daily word goal progress' tooltip: 'Daily word goal progress',
}); });
context.statusBar.registerIndicator('sprint-timer', { context.statusBar.registerIndicator('sprint-timer', {
text: '', text: '',
tooltip: 'Writing sprint timer' tooltip: 'Writing sprint timer',
}); });
} }
@@ -15,17 +15,34 @@
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" }, { "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" }, { "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" }, { "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" }, {
"id": "restore-last-snapshot",
"label": "Studio: Restore Last Snapshot",
"shortcut": "Ctrl+Alt+Z"
},
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" }, { "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" }, {
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" } "id": "compile-manuscript",
"label": "Studio: Compile Manuscript",
"shortcut": "Ctrl+Alt+E"
},
{
"id": "proofread-document",
"label": "Studio: Proofread Document",
"shortcut": "Ctrl+Alt+G"
}
], ],
"statusBar": { "indicators": ["sprint-timer", "word-goal"] } "statusBar": { "indicators": ["sprint-timer", "word-goal"] }
}, },
"settings": [ "settings": [
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" }, { "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" }, { "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" }, {
"key": "autoSnapshotInterval",
"type": "number",
"default": 0,
"label": "Auto-snapshot interval (min, 0=off)"
},
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" } { "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
] ]
} }
@@ -29,7 +29,8 @@ function renderGoalsPanel(container, { engines, settings }) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'ws-stat-row'; row.className = 'ws-stat-row';
const label = document.createElement('span'); const label = document.createElement('span');
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words'; label.textContent =
progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
const pct = document.createElement('span'); const pct = document.createElement('span');
pct.className = 'ws-pct'; pct.className = 'ws-pct';
pct.textContent = progress.pct + '%'; pct.textContent = progress.pct + '%';
@@ -81,7 +82,7 @@ function renderGoalsPanel(container, { engines, settings }) {
const chart = document.createElement('div'); const chart = document.createElement('div');
chart.className = 'ws-chart'; chart.className = 'ws-chart';
const maxWords = Math.max(...last30.map(d => d.words), 1); const maxWords = Math.max(...last30.map((d) => d.words), 1);
for (const day of last30) { for (const day of last30) {
const barEl = document.createElement('div'); const barEl = document.createElement('div');
const height = Math.max(2, (day.words / maxWords) * 60); const height = Math.max(2, (day.words / maxWords) * 60);
@@ -69,7 +69,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'ws-stat-row'; row.className = 'ws-stat-row';
const label = document.createElement('span'); const label = document.createElement('span');
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words'; label.textContent =
stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
const pct = document.createElement('span'); const pct = document.createElement('span');
pct.className = 'ws-pct'; pct.className = 'ws-pct';
pct.textContent = stats.pctComplete + '%'; pct.textContent = stats.pctComplete + '%';
@@ -42,7 +42,7 @@ function renderProofreadPanel(container, { events, editor }) {
if (result && result.issues) { if (result && result.issues) {
renderIssues(issuesList, result.issues); renderIssues(issuesList, result.issues);
} }
} },
}); });
}); });
} }
@@ -80,7 +80,10 @@ function renderIssues(container, issues) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'ws-issue-actions'; actions.className = 'ws-issue-actions';
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) { for (const [, label] of [
['accept', 'Accept'],
['dismiss', 'Dismiss'],
]) {
const actionBtn = document.createElement('button'); const actionBtn = document.createElement('button');
actionBtn.className = 'ws-btn ws-btn-sm'; actionBtn.className = 'ws-btn ws-btn-sm';
actionBtn.textContent = label; actionBtn.textContent = label;
@@ -45,7 +45,11 @@ function renderSnapshotsPanel(container, { engines, editor }) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'ws-snapshot-actions'; actions.className = 'ws-snapshot-actions';
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) { for (const [action, text, cls] of [
['restore', 'Restore', ''],
['diff', 'Diff', ''],
['delete', 'Delete', 'ws-btn-danger'],
]) {
const actionBtn = document.createElement('button'); const actionBtn = document.createElement('button');
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : ''); actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
actionBtn.textContent = text; actionBtn.textContent = text;
@@ -12,7 +12,7 @@ class ProjectManager {
type: opts.type || 'manuscript', type: opts.type || 'manuscript',
target: { words: opts.targetWords || 0, deadline: opts.deadline || null }, target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
chapters: [], chapters: [],
metadata: opts.metadata || {} metadata: opts.metadata || {},
}; };
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
return project; return project;
@@ -66,7 +66,7 @@ class ProjectManager {
totalWords, totalWords,
chapterCount: project.chapters.length, chapterCount: project.chapters.length,
targetWords: target, targetWords: target,
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0 pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0,
}; };
} }
} }
@@ -24,7 +24,7 @@ class SnapshotManager {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
content, content,
wordCount: content.split(/\s+/).filter(Boolean).length, wordCount: content.split(/\s+/).filter(Boolean).length,
label label,
}; };
snaps.unshift(snap); snaps.unshift(snap);
this._saveAll(snaps); this._saveAll(snaps);
@@ -36,7 +36,7 @@ class SnapshotManager {
} }
getById(id) { getById(id) {
return this._getAll().find(s => s.id === id) || null; return this._getAll().find((s) => s.id === id) || null;
} }
restore(id) { restore(id) {
@@ -46,7 +46,7 @@ class SnapshotManager {
} }
delete(id) { delete(id) {
const snaps = this._getAll().filter(s => s.id !== id); const snaps = this._getAll().filter((s) => s.id !== id);
this._saveAll(snaps); this._saveAll(snaps);
} }
@@ -59,8 +59,12 @@ class SnapshotManager {
const newSet = new Set(newLines); const newSet = new Set(newLines);
let added = 0; let added = 0;
let removed = 0; let removed = 0;
for (const line of newLines) { if (!oldSet.has(line)) added++; } for (const line of newLines) {
for (const line of oldLines) { if (!newSet.has(line)) removed++; } if (!oldSet.has(line)) added++;
}
for (const line of oldLines) {
if (!newSet.has(line)) removed++;
}
return { added, removed }; return { added, removed };
} }
@@ -40,7 +40,9 @@ class SprintEngine {
} }
} }
isActive() { return this._active; } isActive() {
return this._active;
}
getRemaining() { getRemaining() {
if (!this._active) return 0; if (!this._active) return 0;
+15 -10
View File
@@ -12,10 +12,11 @@ class PluginContext {
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] } * @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
*/ */
constructor(deps) { constructor(deps) {
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps; const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
deps;
this.sidebar = { this.sidebar = {
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts) registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
}; };
this.commands = { this.commands = {
@@ -28,41 +29,45 @@ class PluginContext {
} }
}; };
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut); commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
} },
}; };
this.statusBar = { this.statusBar = {
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts) registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
}; };
this.settings = { this.settings = {
get: (key) => settings.get(`plugins.${pluginId}.${key}`), get: (key) => settings.get(`plugins.${pluginId}.${key}`),
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value), set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb) onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb),
}; };
this.editor = { this.editor = {
getContent: () => editor.getContent(), getContent: () => editor.getContent(),
getSelection: () => editor.getSelection(), getSelection: () => editor.getSelection(),
insertAtCursor: (text) => editor.insertAtCursor(text), insertAtCursor: (text) => editor.insertAtCursor(text),
onContentChanged: (cb) => editor.onContentChanged(cb) onContentChanged: (cb) => editor.onContentChanged(cb),
}; };
this.events = { this.events = {
on: (event, handler) => eventBus.on(event, handler), on: (event, handler) => eventBus.on(event, handler),
off: (event, handler) => eventBus.off(event, handler), off: (event, handler) => eventBus.off(event, handler),
emit: (event, payload) => eventBus.emit(event, payload), emit: (event, payload) => eventBus.emit(event, payload),
hasHandler: (event) => eventBus.hasHandler(event) hasHandler: (event) => eventBus.hasHandler(event),
}; };
this.ipc = { this.ipc = {
invoke: (channel, ...args) => ipc.invoke(channel, ...args), invoke: (channel, ...args) => ipc.invoke(channel, ...args),
on: (channel, handler) => ipc.on(channel, handler) on: (channel, handler) => ipc.on(channel, handler),
}; };
this.exports = { this.exports = {
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); }, registerPreHook: (handler) => {
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); } if (exportHooks) exportHooks.preHooks.push(handler);
},
registerPostHook: (handler) => {
if (exportHooks) exportHooks.postHooks.push(handler);
},
}; };
} }
} }
+5 -2
View File
@@ -35,7 +35,10 @@ class PluginLoader {
const loaded = require(indexPath); const loaded = require(indexPath);
PluginClass = loaded.Plugin || loaded.default || null; PluginClass = loaded.Plugin || loaded.default || null;
} catch (err) { } catch (err) {
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message); console.error(
`[PluginLoader] Failed to load index.js for "${manifest.id}":`,
err.message
);
continue; continue;
} }
} }
@@ -46,7 +49,7 @@ class PluginLoader {
description: manifest.description, description: manifest.description,
manifest, manifest,
PluginClass, PluginClass,
dir: pluginDir dir: pluginDir,
}); });
this.loadedIds.add(manifest.id); this.loadedIds.add(manifest.id);
} catch (err) { } catch (err) {
+1 -1
View File
@@ -32,7 +32,7 @@ class PluginRegistry {
settings: this.deps.settings, settings: this.deps.settings,
editor: this.deps.editor, editor: this.deps.editor,
ipc: this.deps.ipc, ipc: this.deps.ipc,
exportHooks: this.exportHooks exportHooks: this.exportHooks,
}); });
try { try {
+1 -1
View File
@@ -14,7 +14,7 @@ class SettingsStore {
this.backend.set(key, value); this.backend.set(key, value);
} }
onChanged(key, callback) { onChanged(_key, _callback) {
// Deferred: plugins read settings on init/activate for MVP. // Deferred: plugins read settings on init/activate for MVP.
// Full change notification requires IPC watcher in main process. // Full change notification requires IPC watcher in main process.
} }
+25 -18
View File
@@ -140,7 +140,7 @@ const ALLOWED_SEND_CHANNELS = [
// Plugin settings // Plugin settings
'plugin-settings:get', 'plugin-settings:get',
'plugin-settings:set' 'plugin-settings:set',
]; ];
const ALLOWED_RECEIVE_CHANNELS = [ const ALLOWED_RECEIVE_CHANNELS = [
@@ -237,7 +237,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'load-template-menu', 'load-template-menu',
'toggle-command-palette', 'toggle-command-palette',
'toggle-sidebar-panel', 'toggle-sidebar-panel',
'toggle-bottom-panel' 'toggle-bottom-panel',
]; ];
/** /**
@@ -344,23 +344,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath), exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath), isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }), copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }) move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
}, },
// Theme Operations // Theme Operations
theme: { theme: {
get: () => ipcRenderer.send('get-theme') get: () => ipcRenderer.send('get-theme'),
}, },
// Print Operations // Print Operations
print: { print: {
doPrint: (options) => ipcRenderer.send('do-print', options) doPrint: (options) => ipcRenderer.send('do-print', options),
}, },
// Export Operations // Export Operations
export: { export: {
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }), withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }) spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }),
}, },
// Batch Conversion // Batch Conversion
@@ -368,7 +368,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
convert: (inputFolder, outputFolder, format, options) => { convert: (inputFolder, outputFolder, format, options) => {
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options }); ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
}, },
selectFolder: (type) => ipcRenderer.send('select-folder', type) selectFolder: (type) => ipcRenderer.send('select-folder', type),
}, },
// Universal Converter // Universal Converter
@@ -377,8 +377,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath }); ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
}, },
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => { convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder }); ipcRenderer.send('universal-convert-batch', {
} tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
});
},
}, },
// Header/Footer Operations // Header/Footer Operations
@@ -386,22 +392,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.send('get-header-footer-settings'), getSettings: () => ipcRenderer.send('get-header-footer-settings'),
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings), saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position), browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }), saveLogo: (position, filePath) =>
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position) ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
}, },
// Page Settings // Page Settings
page: { page: {
getSettings: () => ipcRenderer.send('get-page-settings'), getSettings: () => ipcRenderer.send('get-page-settings'),
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings), updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber) setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
}, },
// PDF Operations // PDF Operations
pdf: { pdf: {
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data), processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath), getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId) selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
}, },
// Image Converter Operations // Image Converter Operations
@@ -410,7 +417,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data), batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
resize: (data) => ipcRenderer.send('image-resize', data), resize: (data) => ipcRenderer.send('image-resize', data),
compress: (data) => ipcRenderer.send('image-compress', data), compress: (data) => ipcRenderer.send('image-compress', data),
rotate: (data) => ipcRenderer.send('image-rotate', data) rotate: (data) => ipcRenderer.send('image-rotate', data),
}, },
// Audio Converter Operations // Audio Converter Operations
@@ -419,7 +426,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data), batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
extract: (data) => ipcRenderer.send('audio-extract', data), extract: (data) => ipcRenderer.send('audio-extract', data),
trim: (data) => ipcRenderer.send('audio-trim', data), trim: (data) => ipcRenderer.send('audio-trim', data),
merge: (data) => ipcRenderer.send('audio-merge', data) merge: (data) => ipcRenderer.send('audio-merge', data),
}, },
// Video Converter Operations // Video Converter Operations
@@ -429,16 +436,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
compress: (data) => ipcRenderer.send('video-compress', data), compress: (data) => ipcRenderer.send('video-compress', data),
trim: (data) => ipcRenderer.send('video-trim', data), trim: (data) => ipcRenderer.send('video-trim', data),
extractFrames: (data) => ipcRenderer.send('video-frames', data), extractFrames: (data) => ipcRenderer.send('video-frames', data),
toGif: (data) => ipcRenderer.send('video-gif', data) toGif: (data) => ipcRenderer.send('video-gif', data),
}, },
// Generator Windows // Generator Windows
generators: { generators: {
openAscii: () => ipcRenderer.send('open-ascii-generator'), openAscii: () => ipcRenderer.send('open-ascii-generator'),
openTable: () => ipcRenderer.send('open-table-generator') openTable: () => ipcRenderer.send('open-table-generator'),
}, },
getAppVersion: () => ipcRenderer.invoke('get-app-version') getAppVersion: () => ipcRenderer.invoke('get-app-version'),
}); });
// Log successful preload initialization // Log successful preload initialization
+7 -7
View File
@@ -31,7 +31,7 @@ class PrintPreview {
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint()); document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
// Update preview on option changes // Update preview on option changes
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => { ['print-paper-size', 'print-orientation', 'print-margins'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview()); document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
}); });
@@ -67,12 +67,12 @@ class PrintPreview {
// Get dimensions for paper size // Get dimensions for paper size
const sizes = { const sizes = {
'A3': { width: '297mm', height: '420mm' }, A3: { width: '297mm', height: '420mm' },
'A4': { width: '210mm', height: '297mm' }, A4: { width: '210mm', height: '297mm' },
'A5': { width: '148mm', height: '210mm' }, A5: { width: '148mm', height: '210mm' },
'Letter': { width: '8.5in', height: '11in' }, Letter: { width: '8.5in', height: '11in' },
'Legal': { width: '8.5in', height: '14in' }, Legal: { width: '8.5in', height: '14in' },
'Tabloid': { width: '11in', height: '17in' }, Tabloid: { width: '11in', height: '17in' },
}; };
const size = sizes[paperSize] || sizes['A4']; const size = sizes[paperSize] || sizes['A4'];
+1742 -952
View File
File diff suppressed because it is too large Load Diff
-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,32 +0,0 @@
import { useEffect } from 'react';
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { useEditorStore } from '@/stores/editor-store';
import { usePreviewStore } from '@/stores/preview-store';
export function EditorPane() {
const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null;
const setPreviewSource = usePreviewStore((s) => s.setSource);
useEffect(() => {
if (buf) setPreviewSource(buf.content);
}, [buf?.id, buf?.content, buf, setPreviewSource]);
if (!buf) {
return (
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
<p>No file open. Use File Open to start.</p>
</div>
);
}
return (
<div className="h-full">
<CodeMirrorEditor
key={buf.id}
bufferId={buf.id}
initialContent={buf.content}
/>
</div>
);
}
@@ -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,51 +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 { PreviewPane } from '@/components/preview/PreviewPane';
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}>
<PreviewPane />
</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,44 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { renderMarkdown } from '@/lib/markdown';
import { MermaidLazy } from './MermaidLazy';
interface Props {
source: string;
}
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
function extractMermaidCodes(source: string): string[] {
const codes: string[] = [];
let match: RegExpExecArray | null;
while ((match = MERMAID_RE.exec(source)) !== null) {
codes.push(match[1].trim());
}
return codes;
}
export function MarkdownRenderer({ source }: Props) {
const html = renderMarkdown(source);
const containerRef = useRef<HTMLDivElement>(null);
const [mermaidCodes, setMermaidCodes] = useState<string[]>([]);
// Re-derive mermaid codes from the original source. The renderMarkdown function
// replaces mermaid blocks with placeholders (data-mermaid-source="${idx}"), but
// it does not return the actual codes. Rather than thread them through the API,
// we re-extract from the source. Source is small (single buffer content) so this
// cost is negligible.
useEffect(() => {
setMermaidCodes(extractMermaidCodes(source));
}, [source]);
return (
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
<div dangerouslySetInnerHTML={{ __html: html }} />
{mermaidCodes.map((code, i) => (
<div key={i} className="my-4">
<MermaidLazy code={code} />
</div>
))}
</div>
);
}
@@ -1,45 +0,0 @@
import { useEffect, useRef, useState } from 'react';
interface Props {
code: string;
}
let mermaidModule: typeof import('mermaid').default | null = null;
let initialized = false;
async function getMermaid() {
if (!mermaidModule) {
const mod = await import('mermaid');
mermaidModule = mod.default;
}
if (!initialized) {
mermaidModule.initialize({ startOnLoad: false, theme: 'default' });
initialized = true;
}
return mermaidModule;
}
export function MermaidLazy({ code }: Props) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);
useEffect(() => {
let cancelled = false;
getMermaid()
.then((m) => m.render(idRef.current, code))
.then((rendered) => {
if (!cancelled) setSvg(rendered);
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
});
return () => {
cancelled = true;
};
}, [code]);
if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
if (!svg) return <div className="text-xs text-muted-foreground">Loading diagram</div>;
return <div data-testid="mermaid-output" dangerouslySetInnerHTML={{ __html: svg }} />;
}
@@ -1,22 +0,0 @@
import { MarkdownRenderer } from './MarkdownRenderer';
import { usePreviewStore } from '@/stores/preview-store';
import { useScrollSync } from '@/hooks/use-scroll-sync';
export function PreviewPane() {
const { source, setScrollRatio } = usePreviewStore();
const { handlePreviewScroll } = useScrollSync({ onPreviewScroll: setScrollRatio });
if (!source) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>Nothing to preview. Start typing in the editor.</p>
</div>
);
}
return (
<div className="h-full overflow-auto bg-card/10" onScroll={handlePreviewScroll}>
<MarkdownRenderer source={source} />
</div>
);
}
@@ -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 }
-28
View File
@@ -1,28 +0,0 @@
import { useCallback, useRef } from 'react';
interface Options {
onEditorScroll?: (ratio: number) => void;
onPreviewScroll?: (ratio: number) => void;
}
export function useScrollSync(opts: Options) {
const FRAME_MS = 1000 / 60;
const lastTick = useRef(-FRAME_MS);
const handleEditorScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
const target = evt.currentTarget;
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
const now = performance.now();
if (now - lastTick.current < FRAME_MS) return;
lastTick.current = now;
opts.onEditorScroll?.(ratio);
}, [opts]);
const handlePreviewScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
const target = evt.currentTarget;
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
opts.onPreviewScroll?.(ratio);
}, [opts]);
return { handleEditorScroll, handlePreviewScroll };
}
-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),
},
};
-29
View File
@@ -1,29 +0,0 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.setOptions({
gfm: true,
breaks: false,
});
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
export function renderMarkdown(source: string): string {
// Mark mermaid blocks with a placeholder we can replace client-side.
const placeholders: string[] = [];
const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => {
const idx = placeholders.length;
placeholders.push(code.trim());
return `<div class="mermaid-block" data-mermaid-source="${idx}"></div>`;
});
const rawHtml = marked.parse(withPlaceholders, { async: false }) as string;
const clean = DOMPurify.sanitize(rawHtml, {
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'],
});
// The sanitized HTML still has placeholders; we leave the actual mermaid
// rendering to the React layer (MermaidLazy component) so the heavy
// mermaid library only loads when needed.
return clean;
}
-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;
}),
}))
);
-25
View File
@@ -1,25 +0,0 @@
import { create } from 'zustand';
interface PreviewState {
source: string;
scrollRatio: number;
setSource: (s: string) => void;
setScrollRatio: (r: number) => void;
}
const DEBOUNCE_MS = 300;
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: string = '';
export const usePreviewStore = create<PreviewState>((set) => ({
source: '',
scrollRatio: 0,
setSource: (s) => {
pending = s;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
set({ source: pending });
}, DEBOUNCE_MS);
},
setScrollRatio: (r) => set({ scrollRatio: r }),
}));
-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 }>;
}
+36 -12
View File
@@ -1,5 +1,3 @@
const path = require('path');
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) { function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
container.innerHTML = ` container.innerHTML = `
<div class="explorer-panel"> <div class="explorer-panel">
@@ -15,19 +13,33 @@ function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir
const dir = await listDirectory(null); // null means open folder dialog const dir = await listDirectory(null); // null means open folder dialog
if (dir) { if (dir) {
document.getElementById('explorer-path').value = dir.path; document.getElementById('explorer-path').value = dir.path;
renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, dir.path); renderTree(
document.getElementById('explorer-tree'),
dir.entries,
listDirectory,
onFileOpen,
dir.path
);
} }
}); });
if (currentDir) { if (currentDir) {
listDirectory(currentDir).then(dir => { listDirectory(currentDir).then((dir) => {
if (dir) renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, currentDir); if (dir)
renderTree(
document.getElementById('explorer-tree'),
dir.entries,
listDirectory,
onFileOpen,
currentDir
);
}); });
} }
} }
function renderTree(container, entries, listDirectory, onFileOpen, basePath) { function renderTree(container, entries, listDirectory, onFileOpen, _basePath) {
container.innerHTML = entries.map(entry => { container.innerHTML = entries
.map((entry) => {
if (entry.isDirectory) { if (entry.isDirectory) {
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}"> return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
<span class="tree-icon">&#x25B6;</span> <span class="tree-icon">&#x25B6;</span>
@@ -39,9 +51,10 @@ function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
<span class="tree-icon">${getFileIcon(entry.name)}</span> <span class="tree-icon">${getFileIcon(entry.name)}</span>
<span class="tree-name">${entry.name}</span> <span class="tree-name">${entry.name}</span>
</div>`; </div>`;
}).join(''); })
.join('');
container.querySelectorAll('.tree-folder').forEach(el => { container.querySelectorAll('.tree-folder').forEach((el) => {
el.querySelector('.tree-name').addEventListener('click', async () => { el.querySelector('.tree-name').addEventListener('click', async () => {
const isCollapsed = el.classList.contains('collapsed'); const isCollapsed = el.classList.contains('collapsed');
if (isCollapsed) { if (isCollapsed) {
@@ -52,18 +65,29 @@ function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
} }
} }
el.classList.toggle('collapsed'); el.classList.toggle('collapsed');
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed') ? '\u25B6' : '\u25BC'; el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed')
? '\u25B6'
: '\u25BC';
}); });
}); });
container.querySelectorAll('.tree-file').forEach(el => { container.querySelectorAll('.tree-file').forEach((el) => {
el.addEventListener('click', () => onFileOpen(el.dataset.path)); el.addEventListener('click', () => onFileOpen(el.dataset.path));
}); });
} }
function getFileIcon(filename) { function getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase(); const ext = filename.split('.').pop().toLowerCase();
const icons = { md: '\u{1F4DD}', js: '\u{1F4DC}', json: '{}', html: '\u{1F310}', css: '\u{1F3A8}', py: '\u{1F40D}', pdf: '\u{1F4D5}', txt: '\u{1F4C4}' }; const icons = {
md: '\u{1F4DD}',
js: '\u{1F4DC}',
json: '{}',
html: '\u{1F310}',
css: '\u{1F3A8}',
py: '\u{1F40D}',
pdf: '\u{1F4D5}',
txt: '\u{1F4C4}',
};
return icons[ext] || '\u{1F4C4}'; return icons[ext] || '\u{1F4C4}';
} }
+21 -11
View File
@@ -1,4 +1,4 @@
function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gitLog }) { function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, gitLog }) {
container.innerHTML = ` container.innerHTML = `
<div class="git-panel"> <div class="git-panel">
<div class="git-section"> <div class="git-section">
@@ -32,25 +32,29 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
} }
const files = [ const files = [
...status.modified.map(f => ({ file: f, status: 'M', color: '#f59e0b' })), ...status.modified.map((f) => ({ file: f, status: 'M', color: '#f59e0b' })),
...status.not_added.map(f => ({ file: f, status: '?', color: '#6b7280' })), ...status.not_added.map((f) => ({ file: f, status: '?', color: '#6b7280' })),
...status.created.map(f => ({ file: f, status: 'A', color: '#10b981' })), ...status.created.map((f) => ({ file: f, status: 'A', color: '#10b981' })),
...status.deleted.map(f => ({ file: f, status: 'D', color: '#ef4444' })), ...status.deleted.map((f) => ({ file: f, status: 'D', color: '#ef4444' })),
...status.staged.map(f => ({ file: f, status: 'S', color: '#3b82f6' })), ...status.staged.map((f) => ({ file: f, status: 'S', color: '#3b82f6' })),
]; ];
if (files.length === 0) { if (files.length === 0) {
changesEl.innerHTML = '<p class="git-info">No changes</p>'; changesEl.innerHTML = '<p class="git-info">No changes</p>';
} else { } else {
changesEl.innerHTML = files.map(f => ` changesEl.innerHTML = files
.map(
(f) => `
<div class="git-file" data-file="${f.file}"> <div class="git-file" data-file="${f.file}">
<span class="git-file-status" style="color:${f.color}">${f.status}</span> <span class="git-file-status" style="color:${f.color}">${f.status}</span>
<span class="git-file-name">${f.file}</span> <span class="git-file-name">${f.file}</span>
<button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button> <button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button>
</div> </div>
`).join(''); `
)
.join('');
changesEl.querySelectorAll('.git-stage-btn').forEach(btn => { changesEl.querySelectorAll('.git-stage-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
await gitStage([btn.dataset.file]); await gitStage([btn.dataset.file]);
@@ -63,12 +67,18 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
const log = await gitLog(); const log = await gitLog();
const logEl = document.getElementById('git-log'); const logEl = document.getElementById('git-log');
if (log && logEl) { if (log && logEl) {
logEl.innerHTML = (log.all || []).slice(0, 10).map(entry => ` logEl.innerHTML =
(log.all || [])
.slice(0, 10)
.map(
(entry) => `
<div class="git-log-entry"> <div class="git-log-entry">
<div class="git-log-msg">${entry.message}</div> <div class="git-log-msg">${entry.message}</div>
<div class="git-log-meta">${entry.date?.substring(0, 10) || ''} &middot; ${entry.author_name || ''}</div> <div class="git-log-meta">${entry.date?.substring(0, 10) || ''} &middot; ${entry.author_name || ''}</div>
</div> </div>
`).join('') || '<p class="git-info">No commits</p>'; `
)
.join('') || '<p class="git-info">No commits</p>';
} }
} }
+11 -5
View File
@@ -26,7 +26,7 @@ function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHead
result.push({ result.push({
level: match[1].length, level: match[1].length,
text: match[2].trim(), text: match[2].trim(),
line: i + 1 line: i + 1,
}); });
} }
} }
@@ -64,15 +64,21 @@ function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHead
const activeHeading = findActiveHeading(activeLine); const activeHeading = findActiveHeading(activeLine);
listEl.innerHTML = headings.map((h, idx) => ` listEl.innerHTML =
headings
.map(
(h, idx) => `
<div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}" <div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}"
data-line="${h.line}" data-index="${idx}"> data-line="${h.line}" data-index="${idx}">
<span class="outline-text">${escapeHtml(h.text)}</span> <span class="outline-text">${escapeHtml(h.text)}</span>
<span class="outline-badge">H${h.level}</span> <span class="outline-badge">H${h.level}</span>
</div> </div>
`).join('') + `<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`; `
)
.join('') +
`<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
listEl.querySelectorAll('.outline-item').forEach(item => { listEl.querySelectorAll('.outline-item').forEach((item) => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
const line = parseInt(item.dataset.line, 10); const line = parseInt(item.dataset.line, 10);
onHeadingClick(line); onHeadingClick(line);
@@ -95,7 +101,7 @@ function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHead
activeLine = line; activeLine = line;
const activeHeading = findActiveHeading(line); const activeHeading = findActiveHeading(line);
listEl.querySelectorAll('.outline-item').forEach(item => { listEl.querySelectorAll('.outline-item').forEach((item) => {
const itemLine = parseInt(item.dataset.line, 10); const itemLine = parseInt(item.dataset.line, 10);
if (activeHeading && itemLine === activeHeading.line) { if (activeHeading && itemLine === activeHeading.line) {
item.classList.add('active'); item.classList.add('active');
+6 -4
View File
@@ -9,10 +9,12 @@ class SidebarManager {
} }
setupEventListeners() { setupEventListeners() {
document.querySelectorAll('.sidebar-icon').forEach(btn => { document.querySelectorAll('.sidebar-icon').forEach((btn) => {
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel)); btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
}); });
document.querySelector('.sidebar-panel-close')?.addEventListener('click', () => this.collapse()); document
.querySelector('.sidebar-panel-close')
?.addEventListener('click', () => this.collapse());
} }
registerPanel(name, { title, render }) { registerPanel(name, { title, render }) {
@@ -35,7 +37,7 @@ class SidebarManager {
this.panelContent.innerHTML = ''; this.panelContent.innerHTML = '';
panel.render(this.panelContent); panel.render(this.panelContent);
this.activePanel = name; this.activePanel = name;
document.querySelectorAll('.sidebar-icon').forEach(btn => { document.querySelectorAll('.sidebar-icon').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.panel === name); btn.classList.toggle('active', btn.dataset.panel === name);
}); });
} }
@@ -43,7 +45,7 @@ class SidebarManager {
collapse() { collapse() {
this.sidebar.classList.add('collapsed'); this.sidebar.classList.add('collapsed');
this.activePanel = null; this.activePanel = null;
document.querySelectorAll('.sidebar-icon').forEach(btn => btn.classList.remove('active')); document.querySelectorAll('.sidebar-icon').forEach((btn) => btn.classList.remove('active'));
} }
} }
+20 -8
View File
@@ -12,7 +12,7 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
let snippets = []; let snippets = [];
async function loadSnippets() { async function loadSnippets() {
snippets = await getSnippets() || []; snippets = (await getSnippets()) || [];
renderList(document.getElementById('snippets-search')?.value || ''); renderList(document.getElementById('snippets-search')?.value || '');
} }
@@ -21,10 +21,17 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
if (!list) return; if (!list) return;
const filtered = query const filtered = query
? snippets.filter(s => s.name.toLowerCase().includes(query.toLowerCase()) || (s.language || '').toLowerCase().includes(query.toLowerCase())) ? snippets.filter(
(s) =>
s.name.toLowerCase().includes(query.toLowerCase()) ||
(s.language || '').toLowerCase().includes(query.toLowerCase())
)
: snippets; : snippets;
list.innerHTML = filtered.length ? filtered.map(s => ` list.innerHTML = filtered.length
? filtered
.map(
(s) => `
<div class="snippet-item" data-id="${s.id}"> <div class="snippet-item" data-id="${s.id}">
<div class="snippet-header"> <div class="snippet-header">
<span class="snippet-name">${s.name}</span> <span class="snippet-name">${s.name}</span>
@@ -36,16 +43,19 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
<button class="snippet-delete" data-id="${s.id}" title="Delete">&times;</button> <button class="snippet-delete" data-id="${s.id}" title="Delete">&times;</button>
</div> </div>
</div> </div>
`).join('') : '<p class="git-info">No snippets yet. Click + to add one.</p>'; `
)
.join('')
: '<p class="git-info">No snippets yet. Click + to add one.</p>';
list.querySelectorAll('.snippet-insert').forEach(btn => { list.querySelectorAll('.snippet-insert').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const s = snippets.find(sn => sn.id === btn.dataset.id); const s = snippets.find((sn) => sn.id === btn.dataset.id);
if (s) onInsert(s.code); if (s) onInsert(s.code);
}); });
}); });
list.querySelectorAll('.snippet-delete').forEach(btn => { list.querySelectorAll('.snippet-delete').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
await deleteSnippet(btn.dataset.id); await deleteSnippet(btn.dataset.id);
loadSnippets(); loadSnippets();
@@ -53,7 +63,9 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
}); });
} }
document.getElementById('snippets-search')?.addEventListener('input', (e) => renderList(e.target.value)); document
.getElementById('snippets-search')
?.addEventListener('input', (e) => renderList(e.target.value));
document.getElementById('snippets-add')?.addEventListener('click', () => { document.getElementById('snippets-add')?.addEventListener('click', () => {
const name = prompt('Snippet name:'); const name = prompt('Snippet name:');
+12 -7
View File
@@ -1,10 +1,11 @@
const fs = require('fs');
const path = require('path');
const templates = [ const templates = [
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' }, { name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' }, { name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
{ name: 'Technical Spec', file: 'technical-spec.md', description: 'Requirements and architecture' }, {
name: 'Technical Spec',
file: 'technical-spec.md',
description: 'Requirements and architecture',
},
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' }, { name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
{ name: 'README', file: 'readme.md', description: 'Project documentation' }, { name: 'README', file: 'readme.md', description: 'Project documentation' },
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' }, { name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
@@ -17,16 +18,20 @@ const templates = [
function renderTemplatesPanel(container, onSelect) { function renderTemplatesPanel(container, onSelect) {
container.innerHTML = ` container.innerHTML = `
<div class="panel-list"> <div class="panel-list">
${templates.map(t => ` ${templates
.map(
(t) => `
<div class="panel-list-item template-item" data-file="${t.file}"> <div class="panel-list-item template-item" data-file="${t.file}">
<div class="panel-list-item-title">${t.name}</div> <div class="panel-list-item-title">${t.name}</div>
<div class="panel-list-item-desc">${t.description}</div> <div class="panel-list-item-desc">${t.description}</div>
</div> </div>
`).join('')} `
)
.join('')}
</div> </div>
`; `;
container.querySelectorAll('.template-item').forEach(el => { container.querySelectorAll('.template-item').forEach((el) => {
el.addEventListener('click', () => onSelect(el.dataset.file)); el.addEventListener('click', () => onSelect(el.dataset.file));
}); });
} }
+21 -6
View File
@@ -675,18 +675,33 @@ body.theme-concreteinfo-dark .modal-footer {
Animations Animations
============================================ */ ============================================ */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes slideUp { @keyframes slideUp {
from { transform: translateY(20px); opacity: 0; } from {
to { transform: translateY(0); opacity: 1; } transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { transform: scale(1); } 0%,
50% { transform: scale(1.05); } 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
} }
.animate-fade-in { .animate-fade-in {
+200 -66
View File
@@ -60,7 +60,15 @@
/* Base styles - Reset is in styles.css */ /* Base styles - Reset is in styles.css */
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
overflow: hidden; overflow: hidden;
height: 100vh; height: 100vh;
/* Background controlled by themes - do not set here */ /* Background controlled by themes - do not set here */
@@ -68,9 +76,15 @@ body {
} }
@keyframes gradientShift { @keyframes gradientShift {
0% { background-position: 0% 50%; } 0% {
50% { background-position: 100% 50%; } background-position: 0% 50%;
100% { background-position: 0% 50%; } }
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
} }
.container { .container {
@@ -345,7 +359,9 @@ body {
} }
/* Preview Styles - Modern Typography */ /* Preview Styles - Modern Typography */
.preview-content h1, .preview-content h2, .preview-content h3 { .preview-content h1,
.preview-content h2,
.preview-content h3 {
/* Color controlled by theme */ /* Color controlled by theme */
font-weight: 700; font-weight: 700;
margin-top: 28px; margin-top: 28px;
@@ -695,11 +711,7 @@ body {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: linear-gradient(90deg, background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: shimmer 2s infinite; animation: shimmer 2s infinite;
} }
@@ -798,7 +810,9 @@ body {
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.4); background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s; transition:
width 0.6s,
height 0.6s;
} }
.toolbar button:hover::before, .toolbar button:hover::before,
@@ -1444,7 +1458,7 @@ body.theme-rosepine-dawn .pdf-viewer {
.export-dialog, .export-dialog,
.batch-dialog, .batch-dialog,
.editor-pane, .editor-pane,
[id^="editor-pane-"], [id^='editor-pane-'],
.new-tab-button, .new-tab-button,
.tab-close { .tab-close {
display: none !important; display: none !important;
@@ -1452,9 +1466,9 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Show preview content */ /* Show preview content */
.preview-pane, .preview-pane,
[id^="preview-pane-"], [id^='preview-pane-'],
.preview-content, .preview-content,
[id^="preview-"] { [id^='preview-'] {
display: block !important; display: block !important;
width: 100% !important; width: 100% !important;
padding: 20px !important; padding: 20px !important;
@@ -1491,9 +1505,9 @@ body.theme-rosepine-dawn .pdf-viewer {
body.printing-no-styles .tab-content, body.printing-no-styles .tab-content,
body.printing-no-styles .pane, body.printing-no-styles .pane,
body.printing-no-styles .preview-pane, body.printing-no-styles .preview-pane,
body.printing-no-styles [id^="preview-pane-"], body.printing-no-styles [id^='preview-pane-'],
body.printing-no-styles .preview-content, body.printing-no-styles .preview-content,
body.printing-no-styles [id^="preview-"] { body.printing-no-styles [id^='preview-'] {
background: white !important; background: white !important;
color: #000 !important; color: #000 !important;
} }
@@ -1539,27 +1553,27 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Common dark theme styles for preview content */ /* Common dark theme styles for preview content */
body.theme-dark .preview-content, body.theme-dark .preview-content,
body.theme-dark [id^="preview-"], body.theme-dark [id^='preview-'],
body.theme-one-dark .preview-content, body.theme-one-dark .preview-content,
body.theme-one-dark [id^="preview-"], body.theme-one-dark [id^='preview-'],
body.theme-dracula .preview-content, body.theme-dracula .preview-content,
body.theme-dracula [id^="preview-"], body.theme-dracula [id^='preview-'],
body.theme-nord .preview-content, body.theme-nord .preview-content,
body.theme-nord [id^="preview-"], body.theme-nord [id^='preview-'],
body.theme-tokyo-night .preview-content, body.theme-tokyo-night .preview-content,
body.theme-tokyo-night [id^="preview-"], body.theme-tokyo-night [id^='preview-'],
body.theme-palenight .preview-content, body.theme-palenight .preview-content,
body.theme-palenight [id^="preview-"], body.theme-palenight [id^='preview-'],
body.theme-ayu-dark .preview-content, body.theme-ayu-dark .preview-content,
body.theme-ayu-dark [id^="preview-"], body.theme-ayu-dark [id^='preview-'],
body.theme-ayu-mirage .preview-content, body.theme-ayu-mirage .preview-content,
body.theme-ayu-mirage [id^="preview-"], body.theme-ayu-mirage [id^='preview-'],
body.theme-oceanic-next .preview-content, body.theme-oceanic-next .preview-content,
body.theme-oceanic-next [id^="preview-"], body.theme-oceanic-next [id^='preview-'],
body.theme-gruvbox-dark .preview-content, body.theme-gruvbox-dark .preview-content,
body.theme-gruvbox-dark [id^="preview-"], body.theme-gruvbox-dark [id^='preview-'],
body.theme-cobalt2 .preview-content, body.theme-cobalt2 .preview-content,
body.theme-cobalt2 [id^="preview-"] { body.theme-cobalt2 [id^='preview-'] {
color: #e6e6e6; color: #e6e6e6;
background: transparent; background: transparent;
} }
@@ -2207,20 +2221,20 @@ body.theme-rosepine-dawn .batch-dialog-footer {
} }
/* Dark theme command palette */ /* Dark theme command palette */
body[class*="dark"] .command-palette { body[class*='dark'] .command-palette {
background: #1e1e1e; background: #1e1e1e;
border: 1px solid #333; border: 1px solid #333;
} }
body[class*="dark"] .command-palette-input { body[class*='dark'] .command-palette-input {
background: #1e1e1e; background: #1e1e1e;
color: #eee; color: #eee;
border-color: #333; border-color: #333;
} }
body[class*="dark"] .command-item:hover, body[class*='dark'] .command-item:hover,
body[class*="dark"] .command-item.selected { body[class*='dark'] .command-item.selected {
background: #2d2d2d; background: #2d2d2d;
} }
body[class*="dark"] .command-shortcut { body[class*='dark'] .command-shortcut {
background: #333; background: #333;
color: #888; color: #888;
} }
@@ -2240,7 +2254,7 @@ body[class*="dark"] .command-shortcut {
background: var(--gray-50, #f9fafb); background: var(--gray-50, #f9fafb);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
} }
body[class*="dark"] .breadcrumb-bar { body[class*='dark'] .breadcrumb-bar {
background: #1a1a1a; background: #1a1a1a;
color: #888; color: #888;
border-color: #333; border-color: #333;
@@ -2292,7 +2306,7 @@ body[class*="dark"] .breadcrumb-bar {
color: var(--gray-600, #4b5563); color: var(--gray-600, #4b5563);
} }
.print-option-group select, .print-option-group select,
.print-option-group input[type="text"] { .print-option-group input[type='text'] {
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--gray-300, #d1d5db); border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px; border-radius: 8px;
@@ -2304,7 +2318,7 @@ body[class*="dark"] .breadcrumb-bar {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.scale-control input[type="range"] { .scale-control input[type='range'] {
flex: 1; flex: 1;
} }
.checkbox-label { .checkbox-label {
@@ -2348,30 +2362,150 @@ body[class*="dark"] .breadcrumb-bar {
} }
/* Writing Analytics Modal */ /* Writing Analytics Modal */
.analytics-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; z-index:10000; animation:analyticsFadeIn 0.2s ease; } .analytics-overlay {
.analytics-modal { background:var(--bg-primary,#fff); border-radius:12px; width:520px; max-height:80vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,0.3); animation:analyticsSlideUp 0.3s ease; } position: fixed;
.analytics-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px; border-bottom:1px solid var(--border-color,#e5e7eb); } inset: 0;
.analytics-header h2 { margin:0; font-size:18px; font-weight:600; color:var(--text-primary); } background: rgba(0, 0, 0, 0.5);
.analytics-close { background:none; border:none; font-size:24px; cursor:pointer; color:var(--text-muted); padding:4px 8px; border-radius:4px; } backdrop-filter: blur(4px);
.analytics-close:hover { background:var(--bg-tertiary); color:var(--text-primary); } display: flex;
.analytics-body { padding:16px 24px 24px; } align-items: center;
.analytics-section { margin-bottom:20px; } justify-content: center;
.analytics-section h3 { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted,#9ca3af); margin:0 0 10px; } z-index: 10000;
.analytics-row { display:flex; justify-content:space-between; align-items:baseline; padding:6px 0; font-size:14px; } animation: analyticsFadeIn 0.2s ease;
.analytics-label { color:var(--text-secondary,#6b7280); } }
.analytics-value { font-weight:500; color:var(--text-primary); } .analytics-modal {
.analytics-value small { color:var(--text-muted); font-weight:400; margin-left:6px; } background: var(--bg-primary, #fff);
.readability-meter { height:4px; background:var(--bg-tertiary,#f3f4f6); border-radius:2px; margin-top:8px; overflow:hidden; } border-radius: 12px;
.readability-fill { height:100%; background:linear-gradient(90deg,#ef4444,#f59e0b,#10b981); border-radius:2px; transition:width 0.5s ease; } width: 520px;
.word-cloud { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; } max-height: 80vh;
.word-tag { background:var(--bg-tertiary,#f3f4f6); padding:4px 10px; border-radius:12px; font-size:13px; color:var(--text-secondary); } overflow-y: auto;
.word-tag small { opacity:0.5; font-size:10px; margin-left:2px; } box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
.analytics-longest { flex-direction:column; gap:4px; } animation: analyticsSlideUp 0.3s ease;
.analytics-sentence-preview { font-style:italic; font-size:13px; color:var(--text-secondary); font-weight:400; } }
@keyframes analyticsFadeIn { from{opacity:0} to{opacity:1} } .analytics-header {
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} } display: flex;
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); } justify-content: space-between;
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); } align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.analytics-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.analytics-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 4px;
}
.analytics-close:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.analytics-body {
padding: 16px 24px 24px;
}
.analytics-section {
margin-bottom: 20px;
}
.analytics-section h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #9ca3af);
margin: 0 0 10px;
}
.analytics-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
font-size: 14px;
}
.analytics-label {
color: var(--text-secondary, #6b7280);
}
.analytics-value {
font-weight: 500;
color: var(--text-primary);
}
.analytics-value small {
color: var(--text-muted);
font-weight: 400;
margin-left: 6px;
}
.readability-meter {
height: 4px;
background: var(--bg-tertiary, #f3f4f6);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
}
.readability-fill {
height: 100%;
background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981);
border-radius: 2px;
transition: width 0.5s ease;
}
.word-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.word-tag {
background: var(--bg-tertiary, #f3f4f6);
padding: 4px 10px;
border-radius: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.word-tag small {
opacity: 0.5;
font-size: 10px;
margin-left: 2px;
}
.analytics-longest {
flex-direction: column;
gap: 4px;
}
.analytics-sentence-preview {
font-style: italic;
font-size: 13px;
color: var(--text-secondary);
font-weight: 400;
}
@keyframes analyticsFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes analyticsSlideUp {
from {
transform: translateY(16px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
body[class*='dark'] .analytics-modal {
background: var(--gray-800, #1f2937);
}
body[class*='dark'] .word-tag {
background: var(--gray-700, #374151);
}
/* ======================================== /* ========================================
Interactive PDF Thumbnail Sidebar Styles Interactive PDF Thumbnail Sidebar Styles
@@ -2449,7 +2583,7 @@ body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
} }
.pdf-thumbnail-card.marked-delete::after { .pdf-thumbnail-card.marked-delete::after {
content: "DELETE"; content: 'DELETE';
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@@ -2604,21 +2738,21 @@ body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
} }
/* Dark mode adjustments */ /* Dark mode adjustments */
body[class*="dark"] .pdf-thumbnail-card { body[class*='dark'] .pdf-thumbnail-card {
background: var(--gray-800, #1f2937); background: var(--gray-800, #1f2937);
border-color: var(--gray-700, #374151); border-color: var(--gray-700, #374151);
} }
body[class*="dark"] .pdf-thumbnail-header .badge { body[class*='dark'] .pdf-thumbnail-header .badge {
background: var(--gray-700, #374151); background: var(--gray-700, #374151);
color: var(--gray-200); color: var(--gray-200);
} }
body[class*="dark"] .canvas-container { body[class*='dark'] .canvas-container {
background: var(--gray-900, #111827); background: var(--gray-900, #111827);
border-color: var(--gray-700); border-color: var(--gray-700);
} }
body[class*="dark"] .btn-rotate-thumbnail, body[class*='dark'] .btn-rotate-thumbnail,
body[class*="dark"] .btn-reorder-left, body[class*='dark'] .btn-reorder-left,
body[class*="dark"] .btn-reorder-right { body[class*='dark'] .btn-reorder-right {
background: var(--gray-700, #374151); background: var(--gray-700, #374151);
border-color: var(--gray-600); border-color: var(--gray-600);
color: var(--gray-200); color: var(--gray-200);
+288 -69
View File
@@ -115,28 +115,28 @@
} }
/* Dark theme support */ /* Dark theme support */
body[class*="dark"] .sidebar-icons, body[class*='dark'] .sidebar-icons,
body[class*="dark"] .sidebar-panel { body[class*='dark'] .sidebar-panel {
background: #1e1e1e; background: #1e1e1e;
border-color: #333; border-color: #333;
} }
body[class*="dark"] .sidebar-icon { body[class*='dark'] .sidebar-icon {
color: #888; color: #888;
} }
body[class*="dark"] .sidebar-icon:hover { body[class*='dark'] .sidebar-icon:hover {
background: #333; background: #333;
color: #ccc; color: #ccc;
} }
body[class*="dark"] .sidebar-icon.active { body[class*='dark'] .sidebar-icon.active {
background: #333; background: #333;
color: #8b9aff; color: #8b9aff;
box-shadow: inset 3px 0 0 #8b9aff; box-shadow: inset 3px 0 0 #8b9aff;
} }
body[class*="dark"] .sidebar-panel-header { body[class*='dark'] .sidebar-panel-header {
border-color: #333; border-color: #333;
color: #ccc; color: #ccc;
} }
@@ -162,13 +162,13 @@ body[class*="dark"] .sidebar-panel-header {
margin-top: 2px; margin-top: 2px;
} }
body[class*="dark"] .panel-list-item:hover { body[class*='dark'] .panel-list-item:hover {
background: #333; background: #333;
} }
body[class*="dark"] .panel-list-item-title { body[class*='dark'] .panel-list-item-title {
color: #ddd; color: #ddd;
} }
body[class*="dark"] .panel-list-item-desc { body[class*='dark'] .panel-list-item-desc {
color: #888; color: #888;
} }
@@ -220,85 +220,304 @@ body[class*="dark"] .panel-list-item-desc {
} }
/* Git Panel */ /* Git Panel */
.git-section { margin-bottom: 16px; } .git-section {
.git-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500); margin-bottom: 8px; } margin-bottom: 16px;
.git-file { display: flex; align-items: center; padding: 4px 6px; border-radius: 4px; font-size: 13px; gap: 6px; } }
.git-file:hover { background: var(--gray-100, #f3f4f6); } .git-section-title {
.git-file-status { font-weight: 700; font-family: monospace; width: 16px; text-align: center; } font-size: 11px;
.git-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } text-transform: uppercase;
.git-stage-btn { border: none; background: var(--gray-200); border-radius: 4px; cursor: pointer; font-size: 14px; width: 22px; height: 22px; } letter-spacing: 0.05em;
.git-commit-input { width: 100%; padding: 8px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; font-family: inherit; resize: vertical; box-sizing: border-box; } color: var(--gray-500);
.git-commit-btn { width: 100%; margin-top: 8px; padding: 8px; background: var(--primary-dark, #5661b3); color: white; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; } margin-bottom: 8px;
.git-commit-btn:hover { opacity: 0.9; } }
.git-log-entry { padding: 6px 0; border-bottom: 1px solid var(--gray-100); } .git-file {
.git-log-msg { font-size: 13px; } display: flex;
.git-log-meta { font-size: 11px; color: var(--gray-500); margin-top: 2px; } align-items: center;
.git-info { font-size: 13px; color: var(--gray-500); padding: 8px 0; } padding: 4px 6px;
.git-loading { font-size: 13px; color: var(--gray-400); } border-radius: 4px;
font-size: 13px;
gap: 6px;
}
.git-file:hover {
background: var(--gray-100, #f3f4f6);
}
.git-file-status {
font-weight: 700;
font-family: monospace;
width: 16px;
text-align: center;
}
.git-file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-stage-btn {
border: none;
background: var(--gray-200);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
width: 22px;
height: 22px;
}
.git-commit-input {
width: 100%;
padding: 8px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
.git-commit-btn {
width: 100%;
margin-top: 8px;
padding: 8px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.git-commit-btn:hover {
opacity: 0.9;
}
.git-log-entry {
padding: 6px 0;
border-bottom: 1px solid var(--gray-100);
}
.git-log-msg {
font-size: 13px;
}
.git-log-meta {
font-size: 11px;
color: var(--gray-500);
margin-top: 2px;
}
.git-info {
font-size: 13px;
color: var(--gray-500);
padding: 8px 0;
}
.git-loading {
font-size: 13px;
color: var(--gray-400);
}
/* Snippets Panel */ /* Snippets Panel */
.snippets-toolbar { display: flex; gap: 4px; margin-bottom: 8px; } .snippets-toolbar {
.snippets-search { flex: 1; padding: 6px 10px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; } display: flex;
.snippets-add-btn { width: 32px; border: 1px solid var(--gray-300); border-radius: 6px; background: white; font-size: 18px; cursor: pointer; } gap: 4px;
.snippet-item { padding: 8px; border: 1px solid var(--gray-200); border-radius: 6px; margin-bottom: 6px; } margin-bottom: 8px;
.snippet-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } }
.snippet-name { font-size: 13px; font-weight: 500; } .snippets-search {
.snippet-lang { font-size: 11px; background: var(--gray-100); padding: 2px 6px; border-radius: 4px; color: var(--gray-500); } flex: 1;
.snippet-preview { font-size: 12px; background: var(--gray-50); padding: 6px; border-radius: 4px; margin: 4px 0; overflow: hidden; max-height: 60px; } padding: 6px 10px;
.snippet-preview code { font-family: 'JetBrains Mono', monospace; } border: 1px solid var(--gray-300);
.snippet-actions { display: flex; gap: 4px; } border-radius: 6px;
.snippet-insert { font-size: 12px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; } font-size: 13px;
.snippet-delete { font-size: 14px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; color: #ef4444; } }
.snippets-add-btn {
width: 32px;
border: 1px solid var(--gray-300);
border-radius: 6px;
background: white;
font-size: 18px;
cursor: pointer;
}
.snippet-item {
padding: 8px;
border: 1px solid var(--gray-200);
border-radius: 6px;
margin-bottom: 6px;
}
.snippet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.snippet-name {
font-size: 13px;
font-weight: 500;
}
.snippet-lang {
font-size: 11px;
background: var(--gray-100);
padding: 2px 6px;
border-radius: 4px;
color: var(--gray-500);
}
.snippet-preview {
font-size: 12px;
background: var(--gray-50);
padding: 6px;
border-radius: 4px;
margin: 4px 0;
overflow: hidden;
max-height: 60px;
}
.snippet-preview code {
font-family: 'JetBrains Mono', monospace;
}
.snippet-actions {
display: flex;
gap: 4px;
}
.snippet-insert {
font-size: 12px;
padding: 4px 8px;
border: 1px solid var(--gray-300);
border-radius: 4px;
background: white;
cursor: pointer;
}
.snippet-delete {
font-size: 14px;
padding: 4px 8px;
border: 1px solid var(--gray-300);
border-radius: 4px;
background: white;
cursor: pointer;
color: #ef4444;
}
/* Dark theme for sidebar panels */ /* Dark theme for sidebar panels */
body[class*="dark"] .explorer-path, body[class*='dark'] .explorer-path,
body[class*="dark"] .explorer-browse-btn, body[class*='dark'] .explorer-browse-btn,
body[class*="dark"] .snippets-search, body[class*='dark'] .snippets-search,
body[class*="dark"] .snippets-add-btn, body[class*='dark'] .snippets-add-btn,
body[class*="dark"] .snippet-insert, body[class*='dark'] .snippet-insert,
body[class*="dark"] .snippet-delete { body[class*='dark'] .snippet-delete {
background: #2d2d2d; background: #2d2d2d;
border-color: #444; border-color: #444;
color: #ccc; color: #ccc;
} }
body[class*="dark"] .git-commit-input { body[class*='dark'] .git-commit-input {
background: #2d2d2d; background: #2d2d2d;
border-color: #444; border-color: #444;
color: #ccc; color: #ccc;
} }
body[class*="dark"] .snippet-item { body[class*='dark'] .snippet-item {
border-color: #444; border-color: #444;
} }
body[class*="dark"] .snippet-preview { body[class*='dark'] .snippet-preview {
background: #2d2d2d; background: #2d2d2d;
} }
body[class*="dark"] .tree-item:hover, body[class*='dark'] .tree-item:hover,
body[class*="dark"] .git-file:hover { body[class*='dark'] .git-file:hover {
background: #333; background: #333;
} }
/* Outline Panel */ /* Outline Panel */
.outline-panel { display: flex; flex-direction: column; height: 100%; } .outline-panel {
.outline-list { flex: 1; overflow-y: auto; padding: 4px 0; } display: flex;
.outline-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; cursor: pointer; font-size: 13px; color: var(--text-secondary, #6b7280); transition: background 0.15s, color 0.15s; border-left: 2px solid transparent; } flex-direction: column;
.outline-item:hover { background: var(--bg-tertiary, #f3f4f6); color: var(--text-primary, #1f2937); } height: 100%;
.outline-item.active { color: var(--accent-blue, #3b82f6); background: rgba(59, 130, 246, 0.08); border-left-color: var(--accent-blue, #3b82f6); font-weight: 600; } }
.outline-level-1 { padding-left: 12px; } .outline-list {
.outline-level-2 { padding-left: 24px; } flex: 1;
.outline-level-3 { padding-left: 36px; } overflow-y: auto;
.outline-level-4 { padding-left: 48px; } padding: 4px 0;
.outline-level-5 { padding-left: 56px; } }
.outline-level-6 { padding-left: 64px; } .outline-item {
.outline-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } display: flex;
.outline-badge { font-size: 10px; opacity: 0.5; margin-left: 8px; flex-shrink: 0; } align-items: center;
.outline-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-muted, #9ca3af); text-align: center; } justify-content: space-between;
.outline-empty p { margin: 4px 0; } padding: 4px 12px;
.outline-hint { font-family: monospace; font-size: 12px; opacity: 0.7; } cursor: pointer;
.outline-footer { padding: 8px 12px; border-top: 1px solid var(--border-color, #e5e7eb); font-size: 11px; color: var(--text-muted, #9ca3af); } font-size: 13px;
color: var(--text-secondary, #6b7280);
transition:
background 0.15s,
color 0.15s;
border-left: 2px solid transparent;
}
.outline-item:hover {
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-primary, #1f2937);
}
.outline-item.active {
color: var(--accent-blue, #3b82f6);
background: rgba(59, 130, 246, 0.08);
border-left-color: var(--accent-blue, #3b82f6);
font-weight: 600;
}
.outline-level-1 {
padding-left: 12px;
}
.outline-level-2 {
padding-left: 24px;
}
.outline-level-3 {
padding-left: 36px;
}
.outline-level-4 {
padding-left: 48px;
}
.outline-level-5 {
padding-left: 56px;
}
.outline-level-6 {
padding-left: 64px;
}
.outline-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-badge {
font-size: 10px;
opacity: 0.5;
margin-left: 8px;
flex-shrink: 0;
}
.outline-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--text-muted, #9ca3af);
text-align: center;
}
.outline-empty p {
margin: 4px 0;
}
.outline-hint {
font-family: monospace;
font-size: 12px;
opacity: 0.7;
}
.outline-footer {
padding: 8px 12px;
border-top: 1px solid var(--border-color, #e5e7eb);
font-size: 11px;
color: var(--text-muted, #9ca3af);
}
/* Outline dark mode */ /* Outline dark mode */
body[class*="dark"] .outline-item { color: #9ca3af; } body[class*='dark'] .outline-item {
body[class*="dark"] .outline-item:hover { background: #374151; color: #e5e7eb; } color: #9ca3af;
body[class*="dark"] .outline-item.active { color: #8b9aff; background: rgba(139, 154, 255, 0.1); border-left-color: #8b9aff; } }
body[class*="dark"] .outline-empty { color: #6b7280; } body[class*='dark'] .outline-item:hover {
body[class*="dark"] .outline-footer { border-color: #333; color: #6b7280; } background: #374151;
color: #e5e7eb;
}
body[class*='dark'] .outline-item.active {
color: #8b9aff;
background: rgba(139, 154, 255, 0.1);
border-left-color: #8b9aff;
}
body[class*='dark'] .outline-empty {
color: #6b7280;
}
body[class*='dark'] .outline-footer {
border-color: #333;
color: #6b7280;
}
+121 -24
View File
@@ -1,24 +1,121 @@
.welcome-container { padding: 40px; max-width: 900px; margin: 0 auto; } .welcome-container {
.welcome-hero { text-align: center; margin-bottom: 40px; } padding: 40px;
.welcome-title { font-size: 32px; font-weight: 700; color: var(--gray-800, #1f2937); } max-width: 900px;
.welcome-version { font-size: 14px; color: var(--primary-dark, #5661b3); margin-top: 4px; font-weight: 500; } margin: 0 auto;
.welcome-subtitle { font-size: 16px; color: var(--gray-500, #6b7280); margin-top: 8px; } }
.welcome-grid { display: grid; gap: 32px; } .welcome-hero {
.welcome-section h2 { font-size: 18px; margin-bottom: 16px; color: var(--gray-700, #374151); } text-align: center;
.welcome-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; } margin-bottom: 40px;
.welcome-card { padding: 20px; border: 1px solid var(--gray-200, #e5e7eb); border-radius: 12px; cursor: pointer; text-align: center; transition: all 0.2s; } }
.welcome-card:hover { border-color: var(--primary-dark, #5661b3); box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); } .welcome-title {
.welcome-card-icon { font-size: 28px; margin-bottom: 8px; } font-size: 32px;
.welcome-card h3 { font-size: 15px; margin-bottom: 4px; } font-weight: 700;
.welcome-card p { font-size: 13px; color: var(--gray-500); } color: var(--gray-800, #1f2937);
.welcome-card kbd { display: inline-block; margin-top: 8px; padding: 2px 8px; background: var(--gray-100); border: 1px solid var(--gray-300); border-radius: 4px; font-size: 12px; font-family: 'JetBrains Mono', monospace; } }
.welcome-features { list-style: none; padding: 0; } .welcome-version {
.welcome-features li { padding: 6px 0; font-size: 14px; border-bottom: 1px solid var(--gray-100, #f3f4f6); } font-size: 14px;
.welcome-features strong { color: var(--primary-dark, #5661b3); } color: var(--primary-dark, #5661b3);
.welcome-recent-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; } margin-top: 4px;
.welcome-recent-item:hover { background: var(--gray-100); } font-weight: 500;
.welcome-recent-name { font-size: 14px; font-weight: 500; display: block; } }
.welcome-recent-path { font-size: 12px; color: var(--gray-500); display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; } .welcome-subtitle {
.welcome-muted { color: var(--gray-400); font-size: 14px; } font-size: 16px;
.welcome-footer { margin-top: 32px; text-align: center; } color: var(--gray-500, #6b7280);
.welcome-checkbox { font-size: 13px; color: var(--gray-500); cursor: pointer; } margin-top: 8px;
}
.welcome-grid {
display: grid;
gap: 32px;
}
.welcome-section h2 {
font-size: 18px;
margin-bottom: 16px;
color: var(--gray-700, #374151);
}
.welcome-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.welcome-card {
padding: 20px;
border: 1px solid var(--gray-200, #e5e7eb);
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.welcome-card:hover {
border-color: var(--primary-dark, #5661b3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.welcome-card-icon {
font-size: 28px;
margin-bottom: 8px;
}
.welcome-card h3 {
font-size: 15px;
margin-bottom: 4px;
}
.welcome-card p {
font-size: 13px;
color: var(--gray-500);
}
.welcome-card kbd {
display: inline-block;
margin-top: 8px;
padding: 2px 8px;
background: var(--gray-100);
border: 1px solid var(--gray-300);
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
}
.welcome-features {
list-style: none;
padding: 0;
}
.welcome-features li {
padding: 6px 0;
font-size: 14px;
border-bottom: 1px solid var(--gray-100, #f3f4f6);
}
.welcome-features strong {
color: var(--primary-dark, #5661b3);
}
.welcome-recent-item {
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
}
.welcome-recent-item:hover {
background: var(--gray-100);
}
.welcome-recent-name {
font-size: 14px;
font-weight: 500;
display: block;
}
.welcome-recent-path {
font-size: 12px;
color: var(--gray-500);
display: block;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
}
.welcome-muted {
color: var(--gray-400);
font-size: 14px;
}
.welcome-footer {
margin-top: 32px;
text-align: center;
}
.welcome-checkbox {
font-size: 13px;
color: var(--gray-500);
cursor: pointer;
}
+1 -1
View File
@@ -97,6 +97,6 @@ body.zen-mode .editor-wrapper {
} }
/* Dark mode — HUD stays dark in both themes */ /* Dark mode — HUD stays dark in both themes */
body[class*="dark"].zen-mode .zen-hud { body[class*='dark'].zen-mode .zen-hud {
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
} }
+390 -205
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -31,7 +31,8 @@
z-index: calc(var(--z-modal, 200) + 1); z-index: calc(var(--z-modal, 200) + 1);
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)), transition:
opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
visibility var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)); visibility var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
padding: var(--spacing-4, 1rem); padding: var(--spacing-4, 1rem);
} }
@@ -103,7 +104,8 @@
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
transition: background-color var(--transition-fast, 150ms), transition:
background-color var(--transition-fast, 150ms),
color var(--transition-fast, 150ms); color var(--transition-fast, 150ms);
} }
@@ -233,15 +235,17 @@
* ============================================ */ * ============================================ */
.dark .modal-content, .dark .modal-content,
[data-theme="dark"] .modal-content { [data-theme='dark'] .modal-content {
background: hsl(var(--background)); background: hsl(var(--background));
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.3); box-shadow:
0 20px 25px -5px rgb(0 0 0 / 0.4),
0 8px 10px -6px rgb(0 0 0 / 0.3);
} }
.dark .modal-header, .dark .modal-header,
.dark .modal-footer, .dark .modal-footer,
[data-theme="dark"] .modal-header, [data-theme='dark'] .modal-header,
[data-theme="dark"] .modal-footer { [data-theme='dark'] .modal-footer {
background: hsl(var(--muted)); background: hsl(var(--muted));
} }
+49 -17
View File
@@ -148,7 +148,7 @@
* ============================================ */ * ============================================ */
.dark, .dark,
[data-theme="dark"] { [data-theme='dark'] {
/* Background and foreground */ /* Background and foreground */
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
@@ -205,24 +205,56 @@
* Semantic Color Classes * Semantic Color Classes
* ============================================ */ * ============================================ */
.bg-background { background-color: hsl(var(--background)); } .bg-background {
.bg-foreground { background-color: hsl(var(--foreground)); } background-color: hsl(var(--background));
.bg-card { background-color: hsl(var(--card)); } }
.bg-primary { background-color: hsl(var(--primary)); } .bg-foreground {
.bg-secondary { background-color: hsl(var(--secondary)); } background-color: hsl(var(--foreground));
.bg-muted { background-color: hsl(var(--muted)); } }
.bg-accent { background-color: hsl(var(--accent)); } .bg-card {
.bg-destructive { background-color: hsl(var(--destructive)); } background-color: hsl(var(--card));
}
.bg-primary {
background-color: hsl(var(--primary));
}
.bg-secondary {
background-color: hsl(var(--secondary));
}
.bg-muted {
background-color: hsl(var(--muted));
}
.bg-accent {
background-color: hsl(var(--accent));
}
.bg-destructive {
background-color: hsl(var(--destructive));
}
.text-foreground { color: hsl(var(--foreground)); } .text-foreground {
.text-primary { color: hsl(var(--primary)); } color: hsl(var(--foreground));
.text-secondary { color: hsl(var(--secondary-foreground)); } }
.text-muted-foreground { color: hsl(var(--muted-foreground)); } .text-primary {
.text-destructive { color: hsl(var(--destructive)); } color: hsl(var(--primary));
}
.text-secondary {
color: hsl(var(--secondary-foreground));
}
.text-muted-foreground {
color: hsl(var(--muted-foreground));
}
.text-destructive {
color: hsl(var(--destructive));
}
.border-border { border-color: hsl(var(--border)); } .border-border {
.border-primary { border-color: hsl(var(--primary)); } border-color: hsl(var(--border));
.border-input { border-color: hsl(var(--input)); } }
.border-primary {
border-color: hsl(var(--primary));
}
.border-input {
border-color: hsl(var(--input));
}
/* ============================================ /* ============================================
* Utility Classes * Utility Classes
+37 -16
View File
@@ -1,10 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Table Generator - MarkdownConverter</title> <title>Table Generator - MarkdownConverter</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style> <style>
:root { :root {
--ci-dark-gray: #464646; --ci-dark-gray: #464646;
@@ -86,7 +89,8 @@
color: var(--ci-medium-gray); color: var(--ci-medium-gray);
} }
.form-input, .form-select { .form-input,
.form-select {
padding: 8px 12px; padding: 8px 12px;
border: 2px solid var(--ci-light-gray); border: 2px solid var(--ci-light-gray);
border-radius: 6px; border-radius: 6px;
@@ -96,7 +100,8 @@
min-width: 100px; min-width: 100px;
} }
.form-input:focus, .form-select:focus { .form-input:focus,
.form-select:focus {
outline: none; outline: none;
border-color: var(--ci-accent); border-color: var(--ci-accent);
} }
@@ -274,11 +279,27 @@
<div class="controls-row"> <div class="controls-row">
<div class="control-group"> <div class="control-group">
<span class="control-label">Rows</span> <span class="control-label">Rows</span>
<input type="number" id="rows" class="form-input" min="1" max="50" value="4" style="width: 80px;"> <input
type="number"
id="rows"
class="form-input"
min="1"
max="50"
value="4"
style="width: 80px"
/>
</div> </div>
<div class="control-group"> <div class="control-group">
<span class="control-label">Columns</span> <span class="control-label">Columns</span>
<input type="number" id="cols" class="form-input" min="1" max="20" value="4" style="width: 80px;"> <input
type="number"
id="cols"
class="form-input"
min="1"
max="20"
value="4"
style="width: 80px"
/>
</div> </div>
<div class="control-group"> <div class="control-group">
<span class="control-label">Alignment</span> <span class="control-label">Alignment</span>
@@ -288,9 +309,9 @@
<option value="right">Right</option> <option value="right">Right</option>
</select> </select>
</div> </div>
<div class="control-group" style="justify-content: flex-end;"> <div class="control-group" style="justify-content: flex-end">
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" id="has-header" checked> <input type="checkbox" id="has-header" checked />
Include Header Row Include Header Row
</label> </label>
</div> </div>
@@ -360,7 +381,7 @@
table.innerHTML = html; table.innerHTML = html;
// Add event listeners for live preview // Add event listeners for live preview
table.querySelectorAll('input').forEach(input => { table.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', updatePreview); input.addEventListener('input', updatePreview);
}); });
@@ -425,7 +446,7 @@
for (let i = startRow; i < data.length; i++) { for (let i = startRow; i < data.length; i++) {
if (i === -1) { if (i === -1) {
// Generate empty header for tables without header // Generate empty header for tables without header
markdown += '| ' + colWidths.map(w => ' '.repeat(w)).join(' | ') + ' |\n'; markdown += '| ' + colWidths.map((w) => ' '.repeat(w)).join(' | ') + ' |\n';
} else { } else {
const row = data[i]; const row = data[i];
const cells = row.map((cell, j) => alignCell(cell, colWidths[j])); const cells = row.map((cell, j) => alignCell(cell, colWidths[j]));
@@ -434,7 +455,7 @@
// Add separator after first row // Add separator after first row
if ((hasHeader && i === 0) || (!hasHeader && i === -1)) { if ((hasHeader && i === 0) || (!hasHeader && i === -1)) {
const separators = colWidths.map(w => getSeparator(w)); const separators = colWidths.map((w) => getSeparator(w));
markdown += '| ' + separators.join(' | ') + ' |\n'; markdown += '| ' + separators.join(' | ') + ' |\n';
} }
} }
@@ -469,7 +490,7 @@
}); });
// Quick templates // Quick templates
document.querySelectorAll('.template-chip').forEach(chip => { document.querySelectorAll('.template-chip').forEach((chip) => {
chip.addEventListener('click', () => { chip.addEventListener('click', () => {
currentRows = parseInt(chip.dataset.rows); currentRows = parseInt(chip.dataset.rows);
currentCols = parseInt(chip.dataset.cols); currentCols = parseInt(chip.dataset.cols);
@@ -481,7 +502,7 @@
// Clear all // Clear all
document.getElementById('btn-clear').addEventListener('click', () => { document.getElementById('btn-clear').addEventListener('click', () => {
document.querySelectorAll('#editable-table input').forEach(input => { document.querySelectorAll('#editable-table input').forEach((input) => {
input.value = ''; input.value = '';
}); });
updatePreview(); updatePreview();
@@ -494,7 +515,7 @@
['Task 1', 'First task description', 'In Progress', 'High', '2024-01-15'], ['Task 1', 'First task description', 'In Progress', 'High', '2024-01-15'],
['Task 2', 'Second task description', 'Completed', 'Medium', '2024-01-10'], ['Task 2', 'Second task description', 'Completed', 'Medium', '2024-01-10'],
['Task 3', 'Third task description', 'Pending', 'Low', '2024-01-20'], ['Task 3', 'Third task description', 'Pending', 'Low', '2024-01-20'],
['Task 4', 'Fourth task description', 'In Review', 'High', '2024-01-18'] ['Task 4', 'Fourth task description', 'In Review', 'High', '2024-01-18'],
]; ];
for (let i = 0; i < currentRows; i++) { for (let i = 0; i < currentRows; i++) {
+2 -2
View File
@@ -23,7 +23,7 @@ GET /resources
**Query Parameters:** **Query Parameters:**
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
|-----------|------|----------|-------------| | --------- | ------- | -------- | ---------------------------- |
| page | integer | No | Page number (default: 1) | | page | integer | No | Page number (default: 1) |
| limit | integer | No | Items per page (default: 20) | | limit | integer | No | Items per page (default: 20) |
@@ -83,7 +83,7 @@ DELETE /resources/:id
## Error Codes ## Error Codes
| Code | Description | | Code | Description |
|------|-------------| | ---- | --------------------- |
| 400 | Bad Request | | 400 | Bad Request |
| 401 | Unauthorized | | 401 | Unauthorized |
| 404 | Not Found | | 404 | Not Found |
+5
View File
@@ -3,18 +3,23 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- New feature - New feature
### Changed ### Changed
- Updated feature - Updated feature
### Fixed ### Fixed
- Bug fix - Bug fix
### Removed ### Removed
- Removed feature - Removed feature
## [1.0.0] - {{DATE}} ## [1.0.0] - {{DATE}}
### Added ### Added
- Initial release - Initial release
+7 -3
View File
@@ -10,7 +10,7 @@ Brief description of what is being compared and why.
## Feature Comparison ## Feature Comparison
| Feature | Option A | Option B | | Feature | Option A | Option B |
|---------|----------|----------| | --------- | -------- | -------- |
| Feature 1 | Yes | Yes | | Feature 1 | Yes | Yes |
| Feature 2 | Yes | No | | Feature 2 | Yes | No |
| Feature 3 | No | Yes | | Feature 3 | No | Yes |
@@ -19,7 +19,7 @@ Brief description of what is being compared and why.
## Pricing ## Pricing
| Plan | Option A | Option B | | Plan | Option A | Option B |
|------|----------|----------| | ---------- | -------- | -------- |
| Free | Limited | Limited | | Free | Limited | Limited |
| Pro | $10/mo | $15/mo | | Pro | $10/mo | $15/mo |
| Enterprise | Custom | Custom | | Enterprise | Custom | Custom |
@@ -29,27 +29,31 @@ Brief description of what is being compared and why.
### Option A ### Option A
**Pros:** **Pros:**
- Pro 1 - Pro 1
- Pro 2 - Pro 2
**Cons:** **Cons:**
- Con 1 - Con 1
- Con 2 - Con 2
### Option B ### Option B
**Pros:** **Pros:**
- Pro 1 - Pro 1
- Pro 2 - Pro 2
**Cons:** **Cons:**
- Con 1 - Con 1
- Con 2 - Con 2
## Performance ## Performance
| Metric | Option A | Option B | | Metric | Option A | Option B |
|--------|----------|----------| | ----------- | -------- | --------- |
| Speed | Fast | Moderate | | Speed | Fast | Moderate |
| Memory | Low | Medium | | Memory | Low | Medium |
| Scalability | Good | Excellent | | Scalability | Good | Excellent |
+1
View File
@@ -2,6 +2,7 @@
**Date:** {{DATE}} **Date:** {{DATE}}
**Attendees:** **Attendees:**
- Name 1 - Name 1
- Name 2 - Name 2
+8 -3
View File
@@ -12,16 +12,18 @@
## Scope ## Scope
### In Scope ### In Scope
- Item 1 - Item 1
- Item 2 - Item 2
### Out of Scope ### Out of Scope
- Item 1 - Item 1
## Milestones ## Milestones
| Milestone | Target Date | Status | | Milestone | Target Date | Status |
|-----------|-------------|--------| | --------- | ----------- | ----------- |
| Kickoff | {{DATE}} | Not Started | | Kickoff | {{DATE}} | Not Started |
| MVP | TBD | Not Started | | MVP | TBD | Not Started |
| Launch | TBD | Not Started | | Launch | TBD | Not Started |
@@ -29,26 +31,29 @@
## Resources ## Resources
| Role | Person | Allocation | | Role | Person | Allocation |
|------|--------|------------| | --------- | ------ | ---------- |
| Lead | Name | 100% | | Lead | Name | 100% |
| Developer | Name | 50% | | Developer | Name | 50% |
## Risks ## Risks
| Risk | Impact | Likelihood | Mitigation | | Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------| | ------ | ------ | ---------- | ---------- |
| Risk 1 | High | Medium | Plan | | Risk 1 | High | Medium | Plan |
## Timeline ## Timeline
### Phase 1: Planning ### Phase 1: Planning
- [ ] Define requirements - [ ] Define requirements
- [ ] Create design documents - [ ] Create design documents
### Phase 2: Development ### Phase 2: Development
- [ ] Implement core features - [ ] Implement core features
- [ ] Write tests - [ ] Write tests
### Phase 3: Launch ### Phase 3: Launch
- [ ] Deploy to production - [ ] Deploy to production
- [ ] Monitor and iterate - [ ] Monitor and iterate
+1 -1
View File
@@ -22,7 +22,7 @@ const project = require('project-name');
## Configuration ## Configuration
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| | ------- | ------- | ----------- |
| option1 | true | Description | | option1 | true | Description |
## Contributing ## Contributing
+4 -2
View File
@@ -12,10 +12,12 @@ Brief description of what this feature does.
## Requirements ## Requirements
### Functional Requirements ### Functional Requirements
1. Requirement 1 1. Requirement 1
2. Requirement 2 2. Requirement 2
### Non-Functional Requirements ### Non-Functional Requirements
1. Performance requirement 1. Performance requirement
2. Security requirement 2. Security requirement
@@ -28,7 +30,7 @@ Describe the technical approach.
### Endpoints ### Endpoints
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | ------------- | ------------ |
| GET | /api/resource | Get resource | | GET | /api/resource | Get resource |
## Testing Strategy ## Testing Strategy
@@ -38,5 +40,5 @@ Describe how this will be tested.
## Timeline ## Timeline
| Phase | Duration | Deliverables | | Phase | Duration | Deliverables |
|-------|----------|-------------| | ------- | -------- | ------------------- |
| Phase 1 | 1 week | Core implementation | | Phase 1 | 1 week | Core implementation |
+2
View File
@@ -53,10 +53,12 @@ How to verify everything works.
## Common Issues ## Common Issues
### Issue 1 ### Issue 1
**Problem:** Description of the problem. **Problem:** Description of the problem.
**Solution:** How to fix it. **Solution:** How to fix it.
### Issue 2 ### Issue 2
**Problem:** Description of the problem. **Problem:** Description of the problem.
**Solution:** How to fix it. **Solution:** How to fix it.
+9 -8
View File
@@ -1,6 +1,6 @@
/** /**
* ModalManager - Unified modal system with accessibility support * ModalManager - Unified modal system with accessibility support
* @version 4.4.2 * @version 4.4.5
*/ */
class ModalManager { class ModalManager {
#modal; #modal;
@@ -21,7 +21,7 @@ class ModalManager {
focusFirst: true, focusFirst: true,
onOpen: null, onOpen: null,
onClose: null, onClose: null,
...options ...options,
}; };
this.#isOpen = false; this.#isOpen = false;
this.#eventListeners = []; this.#eventListeners = [];
@@ -58,7 +58,7 @@ class ModalManager {
// Elements with data-close attribute // Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]'); const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach(el => { closeTriggers.forEach((el) => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) { if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return; return;
} }
@@ -78,11 +78,12 @@ class ModalManager {
'select:not([disabled])', 'select:not([disabled])',
'textarea:not([disabled])', 'textarea:not([disabled])',
'a[href]', 'a[href]',
'[tabindex]:not([tabindex="-1"])' '[tabindex]:not([tabindex="-1"])',
].join(', '); ].join(', ');
return Array.from(this.#modal.querySelectorAll(selector)) return Array.from(this.#modal.querySelectorAll(selector)).filter(
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop')); (el) => el.offsetParent !== null && !el.classList.contains('modal-backdrop')
);
} }
#trapFocus(e) { #trapFocus(e) {
@@ -198,11 +199,11 @@ class ModalManager {
// Remove keyboard listener // Remove keyboard listener
const keydownListener = this.#eventListeners.find( const keydownListener = this.#eventListeners.find(
l => l.el === document && l.type === 'keydown' (l) => l.el === document && l.type === 'keydown'
); );
if (keydownListener) { if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler); document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener); this.#eventListeners = this.#eventListeners.filter((l) => l !== keydownListener);
} }
// Restore focus // Restore focus
+4 -2
View File
@@ -1,9 +1,11 @@
function createWelcomeContent(recentFiles = [], appVersion = '') { function createWelcomeContent(recentFiles = [], appVersion = '') {
const recentHtml = recentFiles.length const recentHtml = recentFiles.length
? recentFiles.map(f => { ? recentFiles
.map((f) => {
const name = f.split(/[/\\]/).pop(); const name = f.split(/[/\\]/).pop();
return `<div class="welcome-recent-item" data-path="${f}"><span class="welcome-recent-name">${name}</span><span class="welcome-recent-path">${f}</span></div>`; return `<div class="welcome-recent-item" data-path="${f}"><span class="welcome-recent-name">${name}</span><span class="welcome-recent-path">${f}</span></div>`;
}).join('') })
.join('')
: '<p class="welcome-muted">No recent files</p>'; : '<p class="welcome-muted">No recent files</p>';
return ` return `
Binary file not shown.
+20 -28
View File
@@ -4,12 +4,7 @@
* Toggle with F11, exit with Escape. * Toggle with F11, exit with Escape.
*/ */
const { const { EditorView, ViewPlugin, Decoration } = require('@codemirror/view');
EditorView,
ViewPlugin,
Decoration,
DecorationSet,
} = require('@codemirror/view');
const { RangeSetBuilder } = require('@codemirror/state'); const { RangeSetBuilder } = require('@codemirror/state');
class ZenMode { class ZenMode {
@@ -45,10 +40,7 @@ class ZenMode {
// Add CM6 extensions to the active editor // Add CM6 extensions to the active editor
if (tab.editorView) { if (tab.editorView) {
this._extensions = [ this._extensions = [typewriterScrollExtension, lineDimmingExtension];
typewriterScrollExtension,
lineDimmingExtension,
];
// Reconfigure is not straightforward with CM6's immutable state. // Reconfigure is not straightforward with CM6's immutable state.
// Instead we dispatch effects or simply rebuild with new extensions. // Instead we dispatch effects or simply rebuild with new extensions.
// Since we cannot hot-swap extensions on an existing EditorView, // Since we cannot hot-swap extensions on an existing EditorView,
@@ -116,9 +108,6 @@ class ZenMode {
_applyTypewriterBehavior(view) { _applyTypewriterBehavior(view) {
// We use a custom update listener that scrolls the cursor to center. // We use a custom update listener that scrolls the cursor to center.
// This is stored on the instance so we can clean it up.
const scrollTimeout = null;
const scrollFn = () => { const scrollFn = () => {
if (!this.active) return; if (!this.active) return;
const pos = view.state.selection.main.head; const pos = view.state.selection.main.head;
@@ -129,13 +118,6 @@ class ZenMode {
}); });
}; };
// Listen for selection/doc changes via a polling approach
// since we cannot add extensions to an existing view.
// Instead, use the DOM scrollIntoView after selection changes.
const observer = new MutationObserver(() => {
// No-op, we use interval below
});
// Use interval to keep cursor centered during typing // Use interval to keep cursor centered during typing
this._scrollInterval = setInterval(() => { this._scrollInterval = setInterval(() => {
if (!this.active) { if (!this.active) {
@@ -157,7 +139,7 @@ class ZenMode {
scroller.scrollTop += diff; scroller.scrollTop += diff;
} }
} }
} catch (e) { } catch {
// Ignore errors from destroyed views // Ignore errors from destroyed views
} }
}, 200); }, 200);
@@ -188,7 +170,12 @@ class ZenMode {
if (!this.active || !this._hud) return; if (!this.active || !this._hud) return;
const content = this.tabManager.getEditorContent(); const content = this.tabManager.getEditorContent();
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0; const words = content.trim()
? content
.trim()
.split(/\s+/)
.filter((w) => w.length > 0).length
: 0;
const readingTime = Math.max(1, Math.ceil(words / 200)); const readingTime = Math.max(1, Math.ceil(words / 200));
const wordsEl = document.getElementById('zen-words'); const wordsEl = document.getElementById('zen-words');
@@ -228,7 +215,8 @@ class ZenMode {
} }
// Typewriter scroll ViewPlugin // Typewriter scroll ViewPlugin
const typewriterScrollExtension = ViewPlugin.fromClass(class { const typewriterScrollExtension = ViewPlugin.fromClass(
class {
constructor(view) { constructor(view) {
this.view = view; this.view = view;
} }
@@ -245,7 +233,8 @@ const typewriterScrollExtension = ViewPlugin.fromClass(class {
}); });
} }
} }
}); }
);
// Line dimming ViewPlugin // Line dimming ViewPlugin
function getOpacity(distance) { function getOpacity(distance) {
@@ -256,7 +245,8 @@ function getOpacity(distance) {
return 0.3; return 0.3;
} }
const lineDimmingExtension = ViewPlugin.fromClass(class { const lineDimmingExtension = ViewPlugin.fromClass(
class {
constructor(view) { constructor(view) {
this.decorations = this.buildDecorations(view); this.decorations = this.buildDecorations(view);
} }
@@ -285,8 +275,10 @@ const lineDimmingExtension = ViewPlugin.fromClass(class {
return builder.finish(); return builder.finish();
} }
}, { },
decorations: v => v.decorations, {
}); decorations: (v) => v.decorations,
}
);
module.exports = { ZenMode }; module.exports = { ZenMode };
-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],
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Tests for bundled external tools
*/
const fs = require('fs');
const path = require('path');
describe('Bundled Pandoc binary', () => {
const rootDir = path.resolve(__dirname, '..');
const platform = process.platform;
const binaryPath = path.join(rootDir, 'bin', platform, 'pandoc');
const windowsBinaryPath = path.join(rootDir, 'bin', platform, 'pandoc.exe');
test('Pandoc binary exists for current platform', () => {
const exists = fs.existsSync(binaryPath) || fs.existsSync(windowsBinaryPath);
expect(exists).toBe(true);
});
test('Pandoc binary is executable', () => {
if (platform === 'win32') {
expect(fs.existsSync(windowsBinaryPath)).toBe(true);
} else {
expect(fs.existsSync(binaryPath)).toBe(true);
const stats = fs.statSync(binaryPath);
expect(stats.mode & 0o111).toBeGreaterThan(0);
}
});
});
@@ -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();
});
});

Some files were not shown because too many files have changed in this diff Show More