Compare commits

..
Author SHA1 Message Date
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
97 changed files with 17039 additions and 15969 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.3
- **License:** MIT
- **App ID:** `com.concreteinfo.markdownconverter`
## Branch Specifics
This is the **primary/release branch** — a vanilla JavaScript Electron app with no bundler or framework in the renderer. The renderer is a single large `renderer.js` (5,300+ lines) loaded directly via `src/index.html`. All UI is hand-rolled DOM manipulation.
## Architecture
### Main Process (`src/main.js` — 4,260 lines)
Monolithic main process file. Contains all IPC handlers, Pandoc invocation, file operations, menu definitions (600+ lines), and window lifecycle. Key modules extracted:
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib` (merge, split, compress, rotate, delete, reorder, watermark, encrypt, decrypt, permissions)
- `src/main/GitOperations.js` — Git status/stage/commit/log via `simple-git`
### Renderer (`src/renderer.js` — 5,361 lines)
Vanilla JS, no framework. Directly manipulates DOM. Loads CodeMirror 6 via `src/editor/codemirror-setup.js`. Uses `marked` + `highlight.js` + `DOMPurify` + `mermaid` for rendering. Lazy-loads sidebar panels, REPL, command palette, zen mode.
### Preload (`src/preload.js` — 448 lines)
Exists as IPC bridge, but **`contextIsolation: false` and `nodeIntegration: true`** — the renderer has full Node access. Preload is effectively a thin passthrough.
### Security Model
- `contextIsolation: false` + `nodeIntegration: true` (legacy; the react-electron branch fixes this)
- Pandoc invoked via `execFile` (not `exec`) to prevent shell injection
- Path traversal protection: `validatePath()`, `resolveWritablePath()`, blocks sensitive system dirs
- Permission handler only allows `clipboard-read`/`clipboard-write`
- Rate limiter on conversions (2-second minimum interval)
- File size limit: 50MB
- Error message sanitization strips absolute paths
### Plugin System (`src/plugins/`)
Manifest-based discovery (`manifest.json`). Built-in `writing-studio` plugin with sprint/goal/snapshot management. Plugin API exposed via `src/plugins/plugin-api.js`.
### Settings
Custom JSON file store at `<userData>/settings.json` (NOT `electron-store` despite the dependency). Recent files at `<userData>/recent-files.json`.
## System Dependencies
| Dependency | Required | Notes |
|---|---|---|
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x |
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. Must be present for DOCX/ODT/EPUB/LaTeX/PPTX export. |
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` for packaged builds |
| **MiKTeX / TeX Live** | Optional | For LaTeX PDF export; MiKTeX PATH injected on Windows automatically |
| **ImageMagick** | Optional | Linux image conversion; listed as deb dependency |
| **LibreOffice** | Optional | Enhanced document conversion; listed as deb dependency |
## Development Commands
```bash
npm start # Launch Electron app (dev mode)
npm test # Jest test suite
npm test:watch # Jest in watch mode
npm test:coverage # Jest with coverage report
npm run lint # ESLint check (src + tests)
npm run lint:fix # ESLint auto-fix
npm run format # Prettier write
npm run format:check # Prettier check only
npm run download-tools # Download Pandoc binaries to bin/
npm run generate-icons # Generate app icons via sharp
```
## Build & Package
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json` (no separate config file).
| Target | Platforms |
|---|---|
| `npm run build` | electron-builder (default platform) |
| `npm run build:win` | Windows: NSIS installer + portable + zip (x64) |
| `npm run build:mac` | macOS: default dmg |
| `npm run build:linux` | Linux: deb + AppImage + snap |
| `npm run dist` | Build without publish |
| `npm run dist:all` | Build for all platforms |
**Bundled with builds:** Pandoc binary per platform. FFmpeg via `ffmpeg-static` (asarUnpacked). NSIS installer uses custom script at `scripts/nsis-installer.nsh`.
**Output:** `dist/` directory.
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
## Project Conventions / Gotchas
- **No bundler/transpilation.** The app uses vanilla CommonJS JavaScript. `src/main.js` is loaded directly by Electron. No webpack, no Vite, no TypeScript, no Babel.
- **Monolithic files.** `main.js` (4,260 lines) and `renderer.js` (5,361 lines) contain most logic. Not ideal but is the current state of this branch.
- **CodeMirror 6** for the editor, configured in `src/editor/codemirror-setup.js`.
- **PDF rendering** uses `pdfjs-dist`; **PDF manipulation** uses `pdf-lib` in the main process.
- **Renderer security is weak** — full Node access in renderer. Do NOT introduce new privileged renderer code without understanding this.
- **Pandoc is external.** Must be installed separately or downloaded via `npm run download-tools`. HTML and built-in PDF export work without Pandoc; other formats require it.
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
- **ESLint flat config** (`eslint.config.js`) with ECMAScript 2022. Prettier with 2-space indent, single quotes, semicolons, 100-char width.
- **Tests:** Jest with jsdom environment, 15% coverage threshold. 24 test files in `tests/`.
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
- **Single instance lock** enforced via `app.requestSingleInstanceLock()`.
- **Adapters layer** (`src/adapters/`) abstracts file system operations for potential future non-Electron targets.
+1 -1
View File
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version
v4.1.0
v4.4.4
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

@@ -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
+38 -1812
View File
File diff suppressed because it is too large Load Diff
+3 -20
View File
@@ -1,6 +1,6 @@
{
"name": "markdown-converter",
"version": "4.4.2",
"version": "4.4.4",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js",
"scripts": {
@@ -44,12 +44,6 @@
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^10.0.0",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
@@ -58,15 +52,8 @@
"eslint-plugin-prettier": "^5.5.4",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"lucide-react": "^1.17.0",
"postcss": "^8.5.15",
"prettier": "^3.7.4",
"sharp": "^0.34.3",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3",
"vite": "^8.0.16"
"sharp": "^0.34.3"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -83,7 +70,6 @@
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.16",
"@tanstack/react-table": "^8.21.3",
"codemirror": "^6.0.2",
"core-util-is": "^1.0.3",
"docx": "^9.6.0",
@@ -101,11 +87,8 @@
"pdfjs-dist": "^5.5.207",
"pdfkit": "^0.17.2",
"pizzip": "^3.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"simple-git": "^3.32.3",
"tslib": "^2.8.1",
"zustand": "^5.0.14"
"tslib": "^2.8.1"
},
"overrides": {
"jszip": "^3.10.1",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+2 -2
View File
@@ -65,7 +65,7 @@ const electronFsAdapter = {
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path
path: entry.path,
}));
},
@@ -105,7 +105,7 @@ const electronFsAdapter = {
*/
async move(source, dest) {
return await window.electronAPI.file.move(source, dest);
}
},
};
module.exports = { electronFsAdapter };
+16 -6
View File
@@ -61,11 +61,15 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Avg Sentence</span>
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
</div>
${metrics.longestSentenceLength > 0 ? `
${
metrics.longestSentenceLength > 0
? `
<div class="analytics-row analytics-longest">
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
</div>` : ''}
</div>`
: ''
}
</div>
<div class="analytics-section">
@@ -74,13 +78,19 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Unique</span>
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
</div>
${metrics.topWords.length > 0 ? `
${
metrics.topWords.length > 0
? `
<div class="word-cloud">
${metrics.topWords.map(w => {
${metrics.topWords
.map((w) => {
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>`;
}).join('')}
</div>` : ''}
})
.join('')}
</div>`
: ''
}
</div>
</div>
</div>
+89 -16
View File
@@ -4,14 +4,74 @@
*/
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', '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'
'the',
'a',
'an',
'is',
'are',
'was',
'were',
'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) {
@@ -48,17 +108,23 @@ function analyze(text) {
avgSentenceLength: 0,
longestSentence: '',
longestSentenceLength: 0,
topWords: []
topWords: [],
};
}
const words = extractWords(text);
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 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);
let totalSyllables = 0;
@@ -66,16 +132,23 @@ function analyze(text) {
totalSyllables += countSyllables(w);
}
const fleschEase = 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 fleschEase =
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 readingTime = Math.ceil(wordCount / 200);
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 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;
@@ -120,7 +193,7 @@ function analyze(text) {
avgSentenceLength,
longestSentence,
longestSentenceLength,
topWords
topWords,
};
}
+284 -135
View File
@@ -1,10 +1,13 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
:root {
--ci-dark-gray: #464646;
@@ -103,7 +106,9 @@
color: var(--ci-dark-gray);
}
.form-input, .form-select, .form-textarea {
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid var(--ci-light-gray);
@@ -113,7 +118,9 @@
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;
border-color: var(--ci-accent);
}
@@ -245,7 +252,13 @@
<div id="text-mode" class="mode-section active">
<div class="form-group">
<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 class="form-group">
<label class="form-label">Style</label>
@@ -263,7 +276,11 @@
<div id="box-mode" class="mode-section">
<div class="form-group">
<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 class="form-group">
<label class="form-label">Box Style</label>
@@ -277,7 +294,15 @@
</div>
<div class="form-group">
<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>
@@ -341,138 +366,262 @@
standard: {
height: 5,
chars: {
'A': [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
'B': ['|----\\', '| |', '|----/', '| \\', '|----/'],
'C': ['/----\\', '| ', '| ', '| ', '\\----/'],
'D': ['|----\\', '| |', '| |', '| |', '|----/'],
'E': ['|----', '| ', '|--- ', '| ', '|----'],
'F': ['|----', '| ', '|--- ', '| ', '| '],
'G': ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
'H': ['| |', '| |', '|----/', '| |', '| |'],
'I': ['|---|', ' | ', ' | ', ' | ', '|---|'],
'J': [' |', ' |', ' |', '| |', '\\---/'],
'K': ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
'L': ['| ', '| ', '| ', '| ', '|----'],
'M': ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
'N': ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
'O': ['/----\\', '| |', '| |', '| |', '\\----/'],
'P': ['|----\\', '| |', '|----/', '| ', '| '],
'Q': ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
'R': ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
'S': ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
'T': ['-----', ' | ', ' | ', ' | ', ' | '],
'U': ['| |', '| |', '| |', '| |', '\\----/'],
'V': ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
'W': ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
'X': ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
'Y': ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
'Z': ['-----', ' / ', ' / ', ' / ', '-----'],
A: [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
B: ['|----\\', '| |', '|----/', '| \\', '|----/'],
C: ['/----\\', '| ', '| ', '| ', '\\----/'],
D: ['|----\\', '| |', '| |', '| |', '|----/'],
E: ['|----', '| ', '|--- ', '| ', '|----'],
F: ['|----', '| ', '|--- ', '| ', '| '],
G: ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
H: ['| |', '| |', '|----/', '| |', '| |'],
I: ['|---|', ' | ', ' | ', ' | ', '|---|'],
J: [' |', ' |', ' |', '| |', '\\---/'],
K: ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
L: ['| ', '| ', '| ', '| ', '|----'],
M: ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
N: ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
O: ['/----\\', '| |', '| |', '| |', '\\----/'],
P: ['|----\\', '| |', '|----/', '| ', '| '],
Q: ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
R: ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
S: ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
T: ['-----', ' | ', ' | ', ' | ', ' | '],
U: ['| |', '| |', '| |', '| |', '\\----/'],
V: ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
W: ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
X: ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
Y: ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
Z: ['-----', ' / ', ' / ', ' / ', '-----'],
' ': [' ', ' ', ' ', ' ', ' '],
'0': ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
'1': [' /| ', ' / | ', ' | ', ' | ', ' ----'],
'2': ['/---\\', ' |', ' ---/', '/ ', '-----'],
'3': ['----\\', ' |', ' ---/', ' |', '----/'],
'4': ['| |', '| |', '-----', ' |', ' |'],
'5': ['-----', '| ', '----\\', ' |', '----/'],
'6': ['/----', '| ', '|---\\', '| |', '\\---/'],
'7': ['-----', ' / ', ' / ', ' / ', '/ '],
'8': ['/---\\', '| |', ' --- ', '| |', '\\---/'],
'9': ['/---\\', '| |', '\\----', ' |', '----/']
}
0: ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
1: [' /| ', ' / | ', ' | ', ' | ', ' ----'],
2: ['/---\\', ' |', ' ---/', '/ ', '-----'],
3: ['----\\', ' |', ' ---/', ' |', '----/'],
4: ['| |', '| |', '-----', ' |', ' |'],
5: ['-----', '| ', '----\\', ' |', '----/'],
6: ['/----', '| ', '|---\\', '| |', '\\---/'],
7: ['-----', ' / ', ' / ', ' / ', '/ '],
8: ['/---\\', '| |', ' --- ', '| |', '\\---/'],
9: ['/---\\', '| |', '\\----', ' |', '----/'],
},
},
banner: {
height: 7,
chars: {
'A': [' ##### ', ' ## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
'B': ['######## ', '## ##', '## ##', '######## ', '## ##', '## ##', '######## '],
'C': [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
'D': ['######## ', '## ##', '## ##', '## ##', '## ##', '## ##', '######## '],
'E': ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
'F': ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
'G': [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
'H': ['## ##', '## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
'I': ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
'J': [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
'K': ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
'L': ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
'M': ['## ##', '### ###', '#### ####', '## ### ##', '## ##', '## ##', '## ##'],
'N': ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
'O': [' ####### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
'P': ['######## ', '## ##', '## ##', '######## ', '## ', '## ', '## '],
'Q': [' ####### ', '## ##', '## ##', '## ##', '## ## ##', '## ## ', ' ##### ##'],
'R': ['######## ', '## ##', '## ##', '######## ', '## ## ', '## ## ', '## ##'],
'S': [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
'T': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
'U': ['## ##', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
'V': ['## ##', '## ##', '## ##', '## ##', ' ## ## ', ' ## ## ', ' ### '],
'W': ['## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', ' ### ### '],
'X': ['## ##', ' ## ## ', ' ## ## ', ' ### ', ' ## ## ', ' ## ## ', '## ##'],
'Y': ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
'Z': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
A: [
' ##### ',
' ## ##',
'## ##',
'#########',
'## ##',
'## ##',
'## ##',
],
B: [
'######## ',
'## ##',
'## ##',
'######## ',
'## ##',
'## ##',
'######## ',
],
C: [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
D: [
'######## ',
'## ##',
'## ##',
'## ##',
'## ##',
'## ##',
'######## ',
],
E: ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
F: ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
G: [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
H: [
'## ##',
'## ##',
'## ##',
'#########',
'## ##',
'## ##',
'## ##',
],
I: ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
J: [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
K: ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
L: ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
M: [
'## ##',
'### ###',
'#### ####',
'## ### ##',
'## ##',
'## ##',
'## ##',
],
N: ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
O: [
' ####### ',
'## ##',
'## ##',
'## ##',
'## ##',
'## ##',
' ####### ',
],
P: [
'######## ',
'## ##',
'## ##',
'######## ',
'## ',
'## ',
'## ',
],
Q: [
' ####### ',
'## ##',
'## ##',
'## ##',
'## ## ##',
'## ## ',
' ##### ##',
],
R: [
'######## ',
'## ##',
'## ##',
'######## ',
'## ## ',
'## ## ',
'## ##',
],
S: [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
T: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
U: [
'## ##',
'## ##',
'## ##',
'## ##',
'## ##',
'## ##',
' ####### ',
],
V: [
'## ##',
'## ##',
'## ##',
'## ##',
' ## ## ',
' ## ## ',
' ### ',
],
W: [
'## ##',
'## ## ##',
'## ## ##',
'## ## ##',
'## ## ##',
'## ## ##',
' ### ### ',
],
X: [
'## ##',
' ## ## ',
' ## ## ',
' ### ',
' ## ## ',
' ## ## ',
'## ##',
],
Y: ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
Z: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
'0': [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
'1': [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
'2': [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
'3': [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
'4': [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
'5': ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
'6': [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
'7': ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
'8': [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
'9': [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### ']
}
0: [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
1: [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
2: [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
3: [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
4: [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
5: ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
6: [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
7: ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
8: [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
9: [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### '],
},
},
block: {
height: 6,
chars: {
'A': ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
'B': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
'C': ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
'D': ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
'E': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
'F': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
'G': ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
'H': ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
'I': ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
'J': [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
'K': ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
'L': ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
'M': ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
'N': ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
'O': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
'P': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
'Q': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
'R': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
'S': ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
'T': ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
'U': ['██╗ ██╗', '██ ██║', '██ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
'V': ['████╗', '████', '██║ ██║', '██ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
'W': ['████╗', '████', '██║ █╗ ██║', '██║███╗██║', '███╔███╔╝', ' ╚══╝╚══╝ '],
'X': ['████╗', '██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
'Y': ['██╗ ██╗', '██╗ ██╔╝', '████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
'Z': ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
' ': [' ', ' ', ' ', ' ', ' ', ' ']
}
}
A: ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
C: ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
F: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
G: ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
H: ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
I: ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
J: [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
K: ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
M: [
'███╗ ███╗',
'████╗ ████║',
'██╔████╔██║',
'██║╚██╔╝██║',
'██║ ╚═╝ ██║',
'╚═╝ ╚═╝',
],
N: ['██ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████', '╚═╝ ╚═══'],
O: ['████╗ ', '██╔══██', '██║ ██║', '██ ██', '╚████╔╝', ' ╚═══╝ '],
P: ['██████╗ ', '██╔══██', '██████╔╝', '██╔═══╝ ', '██', '╚═╝ '],
Q: ['████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╗', ' ╚═══██╝'],
R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
S: ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████', '╚════╝ '],
T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
X: ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
Y: ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
Z: ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
' ': [' ', ' ', ' ', ' ', ' ', ' '],
},
},
};
const TEMPLATES = {
'arrow-right': ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
'arrow-down': ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
'decision': ' ╱╲\n ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
'process': '┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\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└──────┘ └──────┘',
'sequence': ' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query \n │ ├──────────►│\n │ │\n │ │ Result │\n ◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
'network': ' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
'hierarchy': ' ┌─────────┐\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'
'arrow-right':
' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
'arrow-down':
' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
decision:
' ╱╲\n ╲\n ? ╲\n \n ────────\n ╲\n YES NO\n \n ▼ ▼',
process:
'┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\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└──────┘ └──────┘',
sequence:
' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n Query │\n ├──────────►│\n │\n │ │ Result │\n │ ──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
network:
' ┌─────────┐\n │ Server │\n └────────┘\n │\n ┌──────────────────\n │\n────────┐ ┌────┐ ┌────────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
hierarchy:
' ┌─────────┐\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 = {
@@ -480,20 +629,20 @@
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
rounded: { 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 currentTemplate = null;
// Mode switching
document.querySelectorAll('.mode-tab').forEach(tab => {
document.querySelectorAll('.mode-tab').forEach((tab) => {
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');
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');
generatePreview();
@@ -501,9 +650,9 @@
});
// Template selection
document.querySelectorAll('.template-btn').forEach(btn => {
document.querySelectorAll('.template-btn').forEach((btn) => {
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');
currentTemplate = btn.dataset.template;
generatePreview();
@@ -531,7 +680,7 @@
function generateBox(text, style, padding) {
const box = BOX_STYLES[style] || BOX_STYLES.single;
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';
+7 -3
View File
@@ -60,15 +60,19 @@ class CommandPalette {
renderResults(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.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}">
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
</div>
`).join('');
`
)
.join('');
this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => {
+36 -29
View File
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
// Language extensions loaded lazily on first use
let _javascript, _html, _css, _json, _python;
const {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} = require('@codemirror/commands');
const {
searchKeymap,
highlightSelectionMatches,
} = require('@codemirror/search');
const {
autocompletion,
completionKeymap,
} = require('@codemirror/autocomplete');
const {
bracketMatching,
foldGutter,
indentOnInput,
} = require('@codemirror/language');
const { defaultKeymap, history, historyKeymap, 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');
// Custom theme for JetBrains Mono font
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': {
fontFamily: 'inherit'
fontFamily: 'inherit',
},
'.cm-scroller': {
fontFamily: 'inherit'
}
fontFamily: 'inherit',
},
});
/**
@@ -59,7 +44,14 @@ const jetBrainsMonoTheme = EditorView.theme({
* @returns {EditorView} the created editor view
*/
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) {
console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null;
@@ -125,11 +117,26 @@ function createEditor(parentElement, options = {}) {
*/
function getLanguageExtension(lang) {
const loaders = {
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); },
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(); },
javascript: () => {
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
return _javascript();
},
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 }),
};
loaders.js = loaders.javascript;
+1029 -242
View File
File diff suppressed because it is too large Load Diff
+1333 -884
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 {
const git = getGitInstance(dir);
return await git.status();
} catch (err) {
} catch {
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) {
const pages = [];
const ranges = rangeString.split(',').map(r => r.trim());
const ranges = rangeString.split(',').map((r) => r.trim());
for (const range of ranges) {
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++) {
if (i > 0 && !pages.includes(i - 1)) {
pages.push(i - 1);
@@ -27,11 +27,13 @@ function parsePageRanges(rangeString, totalPages) {
function hexToRgb(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,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255
} : { r: 0, g: 0, b: 0 };
b: parseInt(result[3], 16) / 255,
}
: { r: 0, g: 0, b: 0 };
}
async function pdfMerge(data) {
@@ -42,7 +44,7 @@ async function pdfMerge(data) {
const pdfBytes = fs.readFileSync(filePath);
const pdf = await PDFDocument.load(pdfBytes);
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();
@@ -63,13 +65,13 @@ async function pdfSplit(data) {
const splits = [];
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++) {
const range = ranges[i];
const pages = [];
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++) {
pages.push(p - 1);
}
@@ -108,7 +110,7 @@ async function pdfSplit(data) {
for (const split of splits) {
const newPdf = await PDFDocument.create();
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 newPdfBytes = await newPdf.save();
@@ -129,18 +131,18 @@ async function pdfCompress(data) {
const compressedPdfBytes = await pdf.save({
useObjectStreams: true,
addDefaultPage: false,
objectsPerTick: 50
objectsPerTick: 50,
});
fs.writeFileSync(data.outputPath, compressedPdfBytes);
const originalSize = fs.statSync(data.inputPath).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 {
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) {
return { success: false, error: error.message };
@@ -160,7 +162,7 @@ async function pdfRotate(data) {
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
}
pagesToRotate.forEach(pageIndex => {
pagesToRotate.forEach((pageIndex) => {
const page = pdf.getPage(pageIndex);
page.setRotation(degrees(data.angle));
});
@@ -170,7 +172,7 @@ async function pdfRotate(data) {
return {
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) {
return { success: false, error: error.message };
@@ -185,7 +187,9 @@ async function pdfDeletePages(data) {
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);
});
@@ -194,7 +198,7 @@ async function pdfDeletePages(data) {
return {
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) {
return { success: false, error: error.message };
@@ -207,7 +211,7 @@ async function pdfReorder(data) {
const pdf = await PDFDocument.load(pdfBytes);
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) {
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 copiedPages = await newPdf.copyPages(pdf, newOrder);
copiedPages.forEach(page => newPdf.addPage(page));
copiedPages.forEach((page) => newPdf.addPage(page));
const reorderedPdfBytes = await newPdf.save();
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
@@ -246,7 +250,9 @@ async function pdfWatermark(data) {
const page = pdf.getPage(pageIndex);
const { width, height } = page.getSize();
let x, y, rotation = 0;
let x,
y,
rotation = 0;
switch (data.position) {
case 'center':
@@ -294,7 +300,7 @@ async function pdfWatermark(data) {
font,
color: rgb(color.r, color.g, color.b),
opacity: data.opacity,
rotate: degrees(rotation)
rotate: degrees(rotation),
});
}
@@ -303,7 +309,7 @@ async function pdfWatermark(data) {
return {
success: true,
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
};
} catch (error) {
return { success: false, error: error.message };
@@ -325,8 +331,8 @@ async function pdfEncrypt(data) {
annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly
}
documentAssembly: data.permissions.documentAssembly,
},
});
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
@@ -336,7 +342,8 @@ async function pdfEncrypt(data) {
if (error.message.includes('encrypt') || error.message.includes('password')) {
return {
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 };
@@ -375,8 +382,8 @@ async function pdfSetPermissions(data) {
annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly
}
documentAssembly: data.permissions.documentAssembly,
},
});
fs.writeFileSync(data.outputPath, newPdfBytes);
@@ -386,7 +393,8 @@ async function pdfSetPermissions(data) {
if (error.message.includes('encrypt') || error.message.includes('permission')) {
return {
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 };
@@ -395,17 +403,28 @@ async function pdfSetPermissions(data) {
function executeOperation(operation, data) {
switch (operation) {
case 'merge': return pdfMerge(data);
case 'split': return pdfSplit(data);
case 'compress': return pdfCompress(data);
case 'rotate': return pdfRotate(data);
case 'delete': return pdfDeletePages(data);
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}` });
case 'merge':
return pdfMerge(data);
case 'split':
return pdfSplit(data);
case 'compress':
return pdfCompress(data);
case 'rotate':
return pdfRotate(data);
case 'delete':
return pdfDeletePages(data);
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,
pdfSetPermissions,
executeOperation,
getPageCount
getPageCount,
};
+1 -3
View File
@@ -5,9 +5,7 @@
"description": "Demonstrates the plugin system. Safe to delete.",
"icon": "puzzle",
"extensionPoints": {
"commands": [
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
]
"commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
},
"settings": []
}
+47 -17
View File
@@ -9,7 +9,7 @@ class WritingStudioPlugin extends PluginAPI {
this.context = context;
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.snapshotManager = new SnapshotManager(context.settings);
@@ -17,14 +17,14 @@ class WritingStudioPlugin extends PluginAPI {
readFile: (p) => context.ipc.invoke('read-file', p),
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
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 = {
sprint: this.sprintEngine,
goals: this.goalTracker,
snapshots: this.snapshotManager,
projects: this.projectManager
projects: this.projectManager,
};
this._registerCommands(context);
@@ -34,59 +34,89 @@ class WritingStudioPlugin extends PluginAPI {
_registerCommands(context) {
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 content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
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;
const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
const result = sprintEngine.stop(words);
goalTracker.addWords(result.wordDelta);
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() || '';
snapshotManager.create(content, 'manual');
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();
if (snaps.length === 0) return;
const content = snapshotManager.restore(snaps[0].id);
context.editor.insertAtCursor(content);
}, 'Ctrl+Alt+Z');
},
'Ctrl+Alt+Z'
);
context.commands.register('new-project', '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', {});
}, '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')) {
const content = context.editor.getContent() || '';
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
}
}, 'Ctrl+Alt+G');
},
'Ctrl+Alt+G'
);
}
_registerStatusBar(context) {
context.statusBar.registerIndicator('word-goal', {
text: '0/1000',
tooltip: 'Daily word goal progress'
tooltip: 'Daily word goal progress',
});
context.statusBar.registerIndicator('sprint-timer', {
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": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
{ "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": "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"] }
},
"settings": [
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
{ "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" }
]
}
@@ -29,7 +29,8 @@ function renderGoalsPanel(container, { engines, settings }) {
const row = document.createElement('div');
row.className = 'ws-stat-row';
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');
pct.className = 'ws-pct';
pct.textContent = progress.pct + '%';
@@ -81,7 +82,7 @@ function renderGoalsPanel(container, { engines, settings }) {
const chart = document.createElement('div');
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) {
const barEl = document.createElement('div');
const height = Math.max(2, (day.words / maxWords) * 60);
@@ -69,7 +69,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
const row = document.createElement('div');
row.className = 'ws-stat-row';
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');
pct.className = 'ws-pct';
pct.textContent = stats.pctComplete + '%';
@@ -42,7 +42,7 @@ function renderProofreadPanel(container, { events, editor }) {
if (result && result.issues) {
renderIssues(issuesList, result.issues);
}
}
},
});
});
}
@@ -80,7 +80,10 @@ function renderIssues(container, issues) {
const actions = document.createElement('div');
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');
actionBtn.className = 'ws-btn ws-btn-sm';
actionBtn.textContent = label;
@@ -45,7 +45,11 @@ function renderSnapshotsPanel(container, { engines, editor }) {
const actions = document.createElement('div');
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');
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
actionBtn.textContent = text;
@@ -12,7 +12,7 @@ class ProjectManager {
type: opts.type || 'manuscript',
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
chapters: [],
metadata: opts.metadata || {}
metadata: opts.metadata || {},
};
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
return project;
@@ -66,7 +66,7 @@ class ProjectManager {
totalWords,
chapterCount: project.chapters.length,
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(),
content,
wordCount: content.split(/\s+/).filter(Boolean).length,
label
label,
};
snaps.unshift(snap);
this._saveAll(snaps);
@@ -36,7 +36,7 @@ class SnapshotManager {
}
getById(id) {
return this._getAll().find(s => s.id === id) || null;
return this._getAll().find((s) => s.id === id) || null;
}
restore(id) {
@@ -46,7 +46,7 @@ class SnapshotManager {
}
delete(id) {
const snaps = this._getAll().filter(s => s.id !== id);
const snaps = this._getAll().filter((s) => s.id !== id);
this._saveAll(snaps);
}
@@ -59,8 +59,12 @@ class SnapshotManager {
const newSet = new Set(newLines);
let added = 0;
let removed = 0;
for (const line of newLines) { if (!oldSet.has(line)) added++; }
for (const line of oldLines) { if (!newSet.has(line)) removed++; }
for (const line of newLines) {
if (!oldSet.has(line)) added++;
}
for (const line of oldLines) {
if (!newSet.has(line)) removed++;
}
return { added, removed };
}
@@ -40,7 +40,9 @@ class SprintEngine {
}
}
isActive() { return this._active; }
isActive() {
return this._active;
}
getRemaining() {
if (!this._active) return 0;
+15 -10
View File
@@ -12,10 +12,11 @@ class PluginContext {
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
*/
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 = {
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
};
this.commands = {
@@ -28,41 +29,45 @@ class PluginContext {
}
};
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
}
},
};
this.statusBar = {
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
};
this.settings = {
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
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 = {
getContent: () => editor.getContent(),
getSelection: () => editor.getSelection(),
insertAtCursor: (text) => editor.insertAtCursor(text),
onContentChanged: (cb) => editor.onContentChanged(cb)
onContentChanged: (cb) => editor.onContentChanged(cb),
};
this.events = {
on: (event, handler) => eventBus.on(event, handler),
off: (event, handler) => eventBus.off(event, handler),
emit: (event, payload) => eventBus.emit(event, payload),
hasHandler: (event) => eventBus.hasHandler(event)
hasHandler: (event) => eventBus.hasHandler(event),
};
this.ipc = {
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
on: (channel, handler) => ipc.on(channel, handler)
on: (channel, handler) => ipc.on(channel, handler),
};
this.exports = {
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
registerPreHook: (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);
PluginClass = loaded.Plugin || loaded.default || null;
} 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;
}
}
@@ -46,7 +49,7 @@ class PluginLoader {
description: manifest.description,
manifest,
PluginClass,
dir: pluginDir
dir: pluginDir,
});
this.loadedIds.add(manifest.id);
} catch (err) {
+1 -1
View File
@@ -32,7 +32,7 @@ class PluginRegistry {
settings: this.deps.settings,
editor: this.deps.editor,
ipc: this.deps.ipc,
exportHooks: this.exportHooks
exportHooks: this.exportHooks,
});
try {
+1 -1
View File
@@ -14,7 +14,7 @@ class SettingsStore {
this.backend.set(key, value);
}
onChanged(key, callback) {
onChanged(_key, _callback) {
// Deferred: plugins read settings on init/activate for MVP.
// 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:get',
'plugin-settings:set'
'plugin-settings:set',
];
const ALLOWED_RECEIVE_CHANNELS = [
@@ -237,7 +237,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel',
'toggle-bottom-panel'
'toggle-bottom-panel',
];
/**
@@ -344,23 +344,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
},
// Theme Operations
theme: {
get: () => ipcRenderer.send('get-theme')
get: () => ipcRenderer.send('get-theme'),
},
// Print Operations
print: {
doPrint: (options) => ipcRenderer.send('do-print', options)
doPrint: (options) => ipcRenderer.send('do-print', options),
},
// Export Operations
export: {
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
@@ -368,7 +368,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
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
@@ -377,8 +377,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
},
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
@@ -386,22 +392,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position)
saveLogo: (position, filePath) =>
ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
},
// Page Settings
page: {
getSettings: () => ipcRenderer.send('get-page-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: {
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
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
@@ -410,7 +417,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
resize: (data) => ipcRenderer.send('image-resize', 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
@@ -419,7 +426,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
extract: (data) => ipcRenderer.send('audio-extract', 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
@@ -429,16 +436,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
compress: (data) => ipcRenderer.send('video-compress', data),
trim: (data) => ipcRenderer.send('video-trim', data),
extractFrames: (data) => ipcRenderer.send('video-frames', data),
toGif: (data) => ipcRenderer.send('video-gif', data)
toGif: (data) => ipcRenderer.send('video-gif', data),
},
// Generator Windows
generators: {
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
+7 -7
View File
@@ -31,7 +31,7 @@ class PrintPreview {
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
// 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());
});
@@ -67,12 +67,12 @@ class PrintPreview {
// Get dimensions for paper size
const sizes = {
'A3': { width: '297mm', height: '420mm' },
'A4': { width: '210mm', height: '297mm' },
'A5': { width: '148mm', height: '210mm' },
'Letter': { width: '8.5in', height: '11in' },
'Legal': { width: '8.5in', height: '14in' },
'Tabloid': { width: '11in', height: '17in' },
A3: { width: '297mm', height: '420mm' },
A4: { width: '210mm', height: '297mm' },
A5: { width: '148mm', height: '210mm' },
Letter: { width: '8.5in', height: '11in' },
Legal: { width: '8.5in', height: '14in' },
Tabloid: { width: '11in', height: '17in' },
};
const size = sizes[paperSize] || sizes['A4'];
+1742 -952
View File
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
import React from 'react';
import { AppHeader } from './components/layout/AppHeader';
import { TabBar } from './components/layout/TabBar';
import { Toolbar } from './components/layout/Toolbar';
import { Breadcrumb } from './components/layout/Breadcrumb';
import { Sidebar } from './components/sidebar/Sidebar';
import { EditorPane } from './components/editor/EditorPane';
import { PreviewPane } from './components/preview/PreviewPane';
import { StatusBar } from './components/layout/StatusBar';
import { CommandPalette } from './components/modals/CommandPalette';
import { ExportDialog } from './components/modals/ExportDialog';
import { SettingsDialog } from './components/modals/SettingsDialog';
import { useAppStore } from './stores/appStore';
function App() {
const { sidebarVisible, previewVisible, activeTabId } = useAppStore();
return (
<div className="flex flex-col h-screen bg-background text-foreground overflow-hidden">
<AppHeader />
<TabBar />
<Toolbar />
<Breadcrumb />
<main className="flex flex-1 overflow-hidden">
{sidebarVisible && <Sidebar />}
<div className="flex flex-1 overflow-hidden">
<EditorPane className={previewVisible ? 'flex-1' : 'flex-1'} />
{previewVisible && (
<>
<div className="w-px bg-border cursor-col-resize hover:bg-brand transition-colors" />
<PreviewPane className="flex-1" />
</>
)}
</div>
</main>
<StatusBar />
<CommandPalette />
<ExportDialog />
<SettingsDialog />
</div>
);
}
export default App;
-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>
-10
View File
@@ -1,10 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
-82
View File
@@ -1,82 +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;
}
.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%;
}
* {
@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;
}
-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;
}
}
+36 -12
View File
@@ -1,5 +1,3 @@
const path = require('path');
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
container.innerHTML = `
<div class="explorer-panel">
@@ -15,19 +13,33 @@ function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir
const dir = await listDirectory(null); // null means open folder dialog
if (dir) {
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) {
listDirectory(currentDir).then(dir => {
if (dir) renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, currentDir);
listDirectory(currentDir).then((dir) => {
if (dir)
renderTree(
document.getElementById('explorer-tree'),
dir.entries,
listDirectory,
onFileOpen,
currentDir
);
});
}
}
function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
container.innerHTML = entries.map(entry => {
function renderTree(container, entries, listDirectory, onFileOpen, _basePath) {
container.innerHTML = entries
.map((entry) => {
if (entry.isDirectory) {
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
<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-name">${entry.name}</span>
</div>`;
}).join('');
})
.join('');
container.querySelectorAll('.tree-folder').forEach(el => {
container.querySelectorAll('.tree-folder').forEach((el) => {
el.querySelector('.tree-name').addEventListener('click', async () => {
const isCollapsed = el.classList.contains('collapsed');
if (isCollapsed) {
@@ -52,18 +65,29 @@ function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
}
}
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));
});
}
function getFileIcon(filename) {
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}';
}
+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 = `
<div class="git-panel">
<div class="git-section">
@@ -32,25 +32,29 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
}
const files = [
...status.modified.map(f => ({ file: f, status: 'M', color: '#f59e0b' })),
...status.not_added.map(f => ({ file: f, status: '?', color: '#6b7280' })),
...status.created.map(f => ({ file: f, status: 'A', color: '#10b981' })),
...status.deleted.map(f => ({ file: f, status: 'D', color: '#ef4444' })),
...status.staged.map(f => ({ file: f, status: 'S', color: '#3b82f6' })),
...status.modified.map((f) => ({ file: f, status: 'M', color: '#f59e0b' })),
...status.not_added.map((f) => ({ file: f, status: '?', color: '#6b7280' })),
...status.created.map((f) => ({ file: f, status: 'A', color: '#10b981' })),
...status.deleted.map((f) => ({ file: f, status: 'D', color: '#ef4444' })),
...status.staged.map((f) => ({ file: f, status: 'S', color: '#3b82f6' })),
];
if (files.length === 0) {
changesEl.innerHTML = '<p class="git-info">No changes</p>';
} else {
changesEl.innerHTML = files.map(f => `
changesEl.innerHTML = files
.map(
(f) => `
<div class="git-file" data-file="${f.file}">
<span class="git-file-status" style="color:${f.color}">${f.status}</span>
<span class="git-file-name">${f.file}</span>
<button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button>
</div>
`).join('');
`
)
.join('');
changesEl.querySelectorAll('.git-stage-btn').forEach(btn => {
changesEl.querySelectorAll('.git-stage-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await gitStage([btn.dataset.file]);
@@ -63,12 +67,18 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
const log = await gitLog();
const logEl = document.getElementById('git-log');
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-msg">${entry.message}</div>
<div class="git-log-meta">${entry.date?.substring(0, 10) || ''} &middot; ${entry.author_name || ''}</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({
level: match[1].length,
text: match[2].trim(),
line: i + 1
line: i + 1,
});
}
}
@@ -64,15 +64,21 @@ function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHead
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' : ''}"
data-line="${h.line}" data-index="${idx}">
<span class="outline-text">${escapeHtml(h.text)}</span>
<span class="outline-badge">H${h.level}</span>
</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', () => {
const line = parseInt(item.dataset.line, 10);
onHeadingClick(line);
@@ -95,7 +101,7 @@ function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHead
activeLine = line;
const activeHeading = findActiveHeading(line);
listEl.querySelectorAll('.outline-item').forEach(item => {
listEl.querySelectorAll('.outline-item').forEach((item) => {
const itemLine = parseInt(item.dataset.line, 10);
if (activeHeading && itemLine === activeHeading.line) {
item.classList.add('active');
+6 -4
View File
@@ -9,10 +9,12 @@ class SidebarManager {
}
setupEventListeners() {
document.querySelectorAll('.sidebar-icon').forEach(btn => {
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
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 }) {
@@ -35,7 +37,7 @@ class SidebarManager {
this.panelContent.innerHTML = '';
panel.render(this.panelContent);
this.activePanel = name;
document.querySelectorAll('.sidebar-icon').forEach(btn => {
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.panel === name);
});
}
@@ -43,7 +45,7 @@ class SidebarManager {
collapse() {
this.sidebar.classList.add('collapsed');
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 = [];
async function loadSnippets() {
snippets = await getSnippets() || [];
snippets = (await getSnippets()) || [];
renderList(document.getElementById('snippets-search')?.value || '');
}
@@ -21,10 +21,17 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
if (!list) return;
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;
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-header">
<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>
</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', () => {
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);
});
});
list.querySelectorAll('.snippet-delete').forEach(btn => {
list.querySelectorAll('.snippet-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
await deleteSnippet(btn.dataset.id);
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', () => {
const name = prompt('Snippet name:');
+12 -7
View File
@@ -1,10 +1,11 @@
const fs = require('fs');
const path = require('path');
const templates = [
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
{ 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: 'README', file: 'readme.md', description: 'Project documentation' },
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
@@ -17,16 +18,20 @@ const templates = [
function renderTemplatesPanel(container, onSelect) {
container.innerHTML = `
<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-title">${t.name}</div>
<div class="panel-list-item-desc">${t.description}</div>
</div>
`).join('')}
`
)
.join('')}
</div>
`;
container.querySelectorAll('.template-item').forEach(el => {
container.querySelectorAll('.template-item').forEach((el) => {
el.addEventListener('click', () => onSelect(el.dataset.file));
});
}
+21 -6
View File
@@ -675,18 +675,33 @@ body.theme-concreteinfo-dark .modal-footer {
Animations
============================================ */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.animate-fade-in {
+200 -66
View File
@@ -60,7 +60,15 @@
/* Base styles - Reset is in styles.css */
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;
height: 100vh;
/* Background controlled by themes - do not set here */
@@ -68,9 +76,15 @@ body {
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.container {
@@ -345,7 +359,9 @@ body {
}
/* 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 */
font-weight: 700;
margin-top: 28px;
@@ -695,11 +711,7 @@ body {
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 2s infinite;
}
@@ -798,7 +810,9 @@ body {
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
transition:
width 0.6s,
height 0.6s;
}
.toolbar button:hover::before,
@@ -1444,7 +1458,7 @@ body.theme-rosepine-dawn .pdf-viewer {
.export-dialog,
.batch-dialog,
.editor-pane,
[id^="editor-pane-"],
[id^='editor-pane-'],
.new-tab-button,
.tab-close {
display: none !important;
@@ -1452,9 +1466,9 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Show preview content */
.preview-pane,
[id^="preview-pane-"],
[id^='preview-pane-'],
.preview-content,
[id^="preview-"] {
[id^='preview-'] {
display: block !important;
width: 100% !important;
padding: 20px !important;
@@ -1491,9 +1505,9 @@ body.theme-rosepine-dawn .pdf-viewer {
body.printing-no-styles .tab-content,
body.printing-no-styles .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 [id^="preview-"] {
body.printing-no-styles [id^='preview-'] {
background: white !important;
color: #000 !important;
}
@@ -1539,27 +1553,27 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Common dark theme styles for 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 [id^="preview-"],
body.theme-one-dark [id^='preview-'],
body.theme-dracula .preview-content,
body.theme-dracula [id^="preview-"],
body.theme-dracula [id^='preview-'],
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 [id^="preview-"],
body.theme-tokyo-night [id^='preview-'],
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 [id^="preview-"],
body.theme-ayu-dark [id^='preview-'],
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 [id^="preview-"],
body.theme-oceanic-next [id^='preview-'],
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 [id^="preview-"] {
body.theme-cobalt2 [id^='preview-'] {
color: #e6e6e6;
background: transparent;
}
@@ -2207,20 +2221,20 @@ body.theme-rosepine-dawn .batch-dialog-footer {
}
/* Dark theme command palette */
body[class*="dark"] .command-palette {
body[class*='dark'] .command-palette {
background: #1e1e1e;
border: 1px solid #333;
}
body[class*="dark"] .command-palette-input {
body[class*='dark'] .command-palette-input {
background: #1e1e1e;
color: #eee;
border-color: #333;
}
body[class*="dark"] .command-item:hover,
body[class*="dark"] .command-item.selected {
body[class*='dark'] .command-item:hover,
body[class*='dark'] .command-item.selected {
background: #2d2d2d;
}
body[class*="dark"] .command-shortcut {
body[class*='dark'] .command-shortcut {
background: #333;
color: #888;
}
@@ -2240,7 +2254,7 @@ body[class*="dark"] .command-shortcut {
background: var(--gray-50, #f9fafb);
font-family: 'JetBrains Mono', monospace;
}
body[class*="dark"] .breadcrumb-bar {
body[class*='dark'] .breadcrumb-bar {
background: #1a1a1a;
color: #888;
border-color: #333;
@@ -2292,7 +2306,7 @@ body[class*="dark"] .breadcrumb-bar {
color: var(--gray-600, #4b5563);
}
.print-option-group select,
.print-option-group input[type="text"] {
.print-option-group input[type='text'] {
padding: 8px 10px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
@@ -2304,7 +2318,7 @@ body[class*="dark"] .breadcrumb-bar {
align-items: center;
gap: 8px;
}
.scale-control input[type="range"] {
.scale-control input[type='range'] {
flex: 1;
}
.checkbox-label {
@@ -2348,30 +2362,150 @@ body[class*="dark"] .breadcrumb-bar {
}
/* 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-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; }
.analytics-header { display:flex; justify-content:space-between; 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); }
.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-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;
}
.analytics-header {
display: flex;
justify-content: space-between;
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
@@ -2449,7 +2583,7 @@ body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
}
.pdf-thumbnail-card.marked-delete::after {
content: "DELETE";
content: 'DELETE';
position: absolute;
top: 50%;
left: 50%;
@@ -2604,21 +2738,21 @@ body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
}
/* Dark mode adjustments */
body[class*="dark"] .pdf-thumbnail-card {
body[class*='dark'] .pdf-thumbnail-card {
background: var(--gray-800, #1f2937);
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);
color: var(--gray-200);
}
body[class*="dark"] .canvas-container {
body[class*='dark'] .canvas-container {
background: var(--gray-900, #111827);
border-color: var(--gray-700);
}
body[class*="dark"] .btn-rotate-thumbnail,
body[class*="dark"] .btn-reorder-left,
body[class*="dark"] .btn-reorder-right {
body[class*='dark'] .btn-rotate-thumbnail,
body[class*='dark'] .btn-reorder-left,
body[class*='dark'] .btn-reorder-right {
background: var(--gray-700, #374151);
border-color: var(--gray-600);
color: var(--gray-200);
+288 -69
View File
@@ -115,28 +115,28 @@
}
/* Dark theme support */
body[class*="dark"] .sidebar-icons,
body[class*="dark"] .sidebar-panel {
body[class*='dark'] .sidebar-icons,
body[class*='dark'] .sidebar-panel {
background: #1e1e1e;
border-color: #333;
}
body[class*="dark"] .sidebar-icon {
body[class*='dark'] .sidebar-icon {
color: #888;
}
body[class*="dark"] .sidebar-icon:hover {
body[class*='dark'] .sidebar-icon:hover {
background: #333;
color: #ccc;
}
body[class*="dark"] .sidebar-icon.active {
body[class*='dark'] .sidebar-icon.active {
background: #333;
color: #8b9aff;
box-shadow: inset 3px 0 0 #8b9aff;
}
body[class*="dark"] .sidebar-panel-header {
body[class*='dark'] .sidebar-panel-header {
border-color: #333;
color: #ccc;
}
@@ -162,13 +162,13 @@ body[class*="dark"] .sidebar-panel-header {
margin-top: 2px;
}
body[class*="dark"] .panel-list-item:hover {
body[class*='dark'] .panel-list-item:hover {
background: #333;
}
body[class*="dark"] .panel-list-item-title {
body[class*='dark'] .panel-list-item-title {
color: #ddd;
}
body[class*="dark"] .panel-list-item-desc {
body[class*='dark'] .panel-list-item-desc {
color: #888;
}
@@ -220,85 +220,304 @@ body[class*="dark"] .panel-list-item-desc {
}
/* Git Panel */
.git-section { margin-bottom: 16px; }
.git-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500); margin-bottom: 8px; }
.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-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); }
.git-section {
margin-bottom: 16px;
}
.git-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-500);
margin-bottom: 8px;
}
.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-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-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
.snippets-search { flex: 1; padding: 6px 10px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; }
.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; }
.snippets-toolbar {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.snippets-search {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 13px;
}
.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 */
body[class*="dark"] .explorer-path,
body[class*="dark"] .explorer-browse-btn,
body[class*="dark"] .snippets-search,
body[class*="dark"] .snippets-add-btn,
body[class*="dark"] .snippet-insert,
body[class*="dark"] .snippet-delete {
body[class*='dark'] .explorer-path,
body[class*='dark'] .explorer-browse-btn,
body[class*='dark'] .snippets-search,
body[class*='dark'] .snippets-add-btn,
body[class*='dark'] .snippet-insert,
body[class*='dark'] .snippet-delete {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .git-commit-input {
body[class*='dark'] .git-commit-input {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .snippet-item {
body[class*='dark'] .snippet-item {
border-color: #444;
}
body[class*="dark"] .snippet-preview {
body[class*='dark'] .snippet-preview {
background: #2d2d2d;
}
body[class*="dark"] .tree-item:hover,
body[class*="dark"] .git-file:hover {
body[class*='dark'] .tree-item:hover,
body[class*='dark'] .git-file:hover {
background: #333;
}
/* Outline Panel */
.outline-panel { display: flex; flex-direction: column; height: 100%; }
.outline-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.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; }
.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-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.outline-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.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;
}
.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 */
body[class*="dark"] .outline-item { color: #9ca3af; }
body[class*="dark"] .outline-item:hover { 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; }
body[class*='dark'] .outline-item {
color: #9ca3af;
}
body[class*='dark'] .outline-item:hover {
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-hero { text-align: center; margin-bottom: 40px; }
.welcome-title { font-size: 32px; font-weight: 700; color: var(--gray-800, #1f2937); }
.welcome-version { font-size: 14px; color: var(--primary-dark, #5661b3); margin-top: 4px; font-weight: 500; }
.welcome-subtitle { font-size: 16px; color: var(--gray-500, #6b7280); 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; }
.welcome-container {
padding: 40px;
max-width: 900px;
margin: 0 auto;
}
.welcome-hero {
text-align: center;
margin-bottom: 40px;
}
.welcome-title {
font-size: 32px;
font-weight: 700;
color: var(--gray-800, #1f2937);
}
.welcome-version {
font-size: 14px;
color: var(--primary-dark, #5661b3);
margin-top: 4px;
font-weight: 500;
}
.welcome-subtitle {
font-size: 16px;
color: var(--gray-500, #6b7280);
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 */
body[class*="dark"].zen-mode .zen-hud {
body[class*='dark'].zen-mode .zen-hud {
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);
opacity: 0;
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));
padding: var(--spacing-4, 1rem);
}
@@ -103,7 +104,8 @@
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: background-color var(--transition-fast, 150ms),
transition:
background-color var(--transition-fast, 150ms),
color var(--transition-fast, 150ms);
}
@@ -233,15 +235,17 @@
* ============================================ */
.dark .modal-content,
[data-theme="dark"] .modal-content {
[data-theme='dark'] .modal-content {
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-footer,
[data-theme="dark"] .modal-header,
[data-theme="dark"] .modal-footer {
[data-theme='dark'] .modal-header,
[data-theme='dark'] .modal-footer {
background: hsl(var(--muted));
}
+49 -17
View File
@@ -148,7 +148,7 @@
* ============================================ */
.dark,
[data-theme="dark"] {
[data-theme='dark'] {
/* Background and foreground */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
@@ -205,24 +205,56 @@
* Semantic Color Classes
* ============================================ */
.bg-background { background-color: hsl(var(--background)); }
.bg-foreground { background-color: hsl(var(--foreground)); }
.bg-card { 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)); }
.bg-background {
background-color: hsl(var(--background));
}
.bg-foreground {
background-color: hsl(var(--foreground));
}
.bg-card {
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-primary { 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)); }
.text-foreground {
color: hsl(var(--foreground));
}
.text-primary {
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-primary { border-color: hsl(var(--primary)); }
.border-input { border-color: hsl(var(--input)); }
.border-border {
border-color: hsl(var(--border));
}
.border-primary {
border-color: hsl(var(--primary));
}
.border-input {
border-color: hsl(var(--input));
}
/* ============================================
* Utility Classes
+37 -16
View File
@@ -1,10 +1,13 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
:root {
--ci-dark-gray: #464646;
@@ -86,7 +89,8 @@
color: var(--ci-medium-gray);
}
.form-input, .form-select {
.form-input,
.form-select {
padding: 8px 12px;
border: 2px solid var(--ci-light-gray);
border-radius: 6px;
@@ -96,7 +100,8 @@
min-width: 100px;
}
.form-input:focus, .form-select:focus {
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--ci-accent);
}
@@ -274,11 +279,27 @@
<div class="controls-row">
<div class="control-group">
<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 class="control-group">
<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 class="control-group">
<span class="control-label">Alignment</span>
@@ -288,9 +309,9 @@
<option value="right">Right</option>
</select>
</div>
<div class="control-group" style="justify-content: flex-end;">
<div class="control-group" style="justify-content: flex-end">
<label class="checkbox-label">
<input type="checkbox" id="has-header" checked>
<input type="checkbox" id="has-header" checked />
Include Header Row
</label>
</div>
@@ -360,7 +381,7 @@
table.innerHTML = html;
// Add event listeners for live preview
table.querySelectorAll('input').forEach(input => {
table.querySelectorAll('input').forEach((input) => {
input.addEventListener('input', updatePreview);
});
@@ -425,7 +446,7 @@
for (let i = startRow; i < data.length; i++) {
if (i === -1) {
// Generate empty header for tables without header
markdown += '| ' + colWidths.map(w => ' '.repeat(w)).join(' | ') + ' |\n';
markdown += '| ' + colWidths.map((w) => ' '.repeat(w)).join(' | ') + ' |\n';
} else {
const row = data[i];
const cells = row.map((cell, j) => alignCell(cell, colWidths[j]));
@@ -434,7 +455,7 @@
// Add separator after first row
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';
}
}
@@ -469,7 +490,7 @@
});
// Quick templates
document.querySelectorAll('.template-chip').forEach(chip => {
document.querySelectorAll('.template-chip').forEach((chip) => {
chip.addEventListener('click', () => {
currentRows = parseInt(chip.dataset.rows);
currentCols = parseInt(chip.dataset.cols);
@@ -481,7 +502,7 @@
// Clear all
document.getElementById('btn-clear').addEventListener('click', () => {
document.querySelectorAll('#editable-table input').forEach(input => {
document.querySelectorAll('#editable-table input').forEach((input) => {
input.value = '';
});
updatePreview();
@@ -494,7 +515,7 @@
['Task 1', 'First task description', 'In Progress', 'High', '2024-01-15'],
['Task 2', 'Second task description', 'Completed', 'Medium', '2024-01-10'],
['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++) {
+2 -2
View File
@@ -23,7 +23,7 @@ GET /resources
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| --------- | ------- | -------- | ---------------------------- |
| page | integer | No | Page number (default: 1) |
| limit | integer | No | Items per page (default: 20) |
@@ -83,7 +83,7 @@ DELETE /resources/:id
## Error Codes
| Code | Description |
|------|-------------|
| ---- | --------------------- |
| 400 | Bad Request |
| 401 | Unauthorized |
| 404 | Not Found |
+5
View File
@@ -3,18 +3,23 @@
## [Unreleased]
### Added
- New feature
### Changed
- Updated feature
### Fixed
- Bug fix
### Removed
- Removed feature
## [1.0.0] - {{DATE}}
### Added
- Initial release
+7 -3
View File
@@ -10,7 +10,7 @@ Brief description of what is being compared and why.
## Feature Comparison
| Feature | Option A | Option B |
|---------|----------|----------|
| --------- | -------- | -------- |
| Feature 1 | Yes | Yes |
| Feature 2 | Yes | No |
| Feature 3 | No | Yes |
@@ -19,7 +19,7 @@ Brief description of what is being compared and why.
## Pricing
| Plan | Option A | Option B |
|------|----------|----------|
| ---------- | -------- | -------- |
| Free | Limited | Limited |
| Pro | $10/mo | $15/mo |
| Enterprise | Custom | Custom |
@@ -29,27 +29,31 @@ Brief description of what is being compared and why.
### Option A
**Pros:**
- Pro 1
- Pro 2
**Cons:**
- Con 1
- Con 2
### Option B
**Pros:**
- Pro 1
- Pro 2
**Cons:**
- Con 1
- Con 2
## Performance
| Metric | Option A | Option B |
|--------|----------|----------|
| ----------- | -------- | --------- |
| Speed | Fast | Moderate |
| Memory | Low | Medium |
| Scalability | Good | Excellent |
+1
View File
@@ -2,6 +2,7 @@
**Date:** {{DATE}}
**Attendees:**
- Name 1
- Name 2
+8 -3
View File
@@ -12,16 +12,18 @@
## Scope
### In Scope
- Item 1
- Item 2
### Out of Scope
- Item 1
## Milestones
| Milestone | Target Date | Status |
|-----------|-------------|--------|
| --------- | ----------- | ----------- |
| Kickoff | {{DATE}} | Not Started |
| MVP | TBD | Not Started |
| Launch | TBD | Not Started |
@@ -29,26 +31,29 @@
## Resources
| Role | Person | Allocation |
|------|--------|------------|
| --------- | ------ | ---------- |
| Lead | Name | 100% |
| Developer | Name | 50% |
## Risks
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| ------ | ------ | ---------- | ---------- |
| Risk 1 | High | Medium | Plan |
## Timeline
### Phase 1: Planning
- [ ] Define requirements
- [ ] Create design documents
### Phase 2: Development
- [ ] Implement core features
- [ ] Write tests
### Phase 3: Launch
- [ ] Deploy to production
- [ ] Monitor and iterate
+1 -1
View File
@@ -22,7 +22,7 @@ const project = require('project-name');
## Configuration
| Option | Default | Description |
|--------|---------|-------------|
| ------- | ------- | ----------- |
| option1 | true | Description |
## Contributing
+4 -2
View File
@@ -12,10 +12,12 @@ Brief description of what this feature does.
## Requirements
### Functional Requirements
1. Requirement 1
2. Requirement 2
### Non-Functional Requirements
1. Performance requirement
2. Security requirement
@@ -28,7 +30,7 @@ Describe the technical approach.
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| ------ | ------------- | ------------ |
| GET | /api/resource | Get resource |
## Testing Strategy
@@ -38,5 +40,5 @@ Describe how this will be tested.
## Timeline
| Phase | Duration | Deliverables |
|-------|----------|-------------|
| ------- | -------- | ------------------- |
| Phase 1 | 1 week | Core implementation |
+2
View File
@@ -53,10 +53,12 @@ How to verify everything works.
## Common Issues
### Issue 1
**Problem:** Description of the problem.
**Solution:** How to fix it.
### Issue 2
**Problem:** Description of the problem.
**Solution:** How to fix it.
+9 -8
View File
@@ -1,6 +1,6 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.4.2
* @version 4.4.4
*/
class ModalManager {
#modal;
@@ -21,7 +21,7 @@ class ModalManager {
focusFirst: true,
onOpen: null,
onClose: null,
...options
...options,
};
this.#isOpen = false;
this.#eventListeners = [];
@@ -58,7 +58,7 @@ class ModalManager {
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach(el => {
closeTriggers.forEach((el) => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
@@ -78,11 +78,12 @@ class ModalManager {
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
'[tabindex]:not([tabindex="-1"])',
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector))
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop'));
return Array.from(this.#modal.querySelectorAll(selector)).filter(
(el) => el.offsetParent !== null && !el.classList.contains('modal-backdrop')
);
}
#trapFocus(e) {
@@ -198,11 +199,11 @@ class ModalManager {
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
l => l.el === document && l.type === 'keydown'
(l) => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener);
this.#eventListeners = this.#eventListeners.filter((l) => l !== keydownListener);
}
// Restore focus
+4 -2
View File
@@ -1,9 +1,11 @@
function createWelcomeContent(recentFiles = [], appVersion = '') {
const recentHtml = recentFiles.length
? recentFiles.map(f => {
? recentFiles
.map((f) => {
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>`;
}).join('')
})
.join('')
: '<p class="welcome-muted">No recent files</p>';
return `
+84 -21
View File
@@ -7,7 +7,6 @@
const fs = require('fs');
const path = require('path');
const PizZip = require('pizzip');
const Docx = require('docx4js').default;
class WordTemplateExporter {
constructor(templatePath, startPage = 3, pageSettings = null) {
@@ -66,7 +65,7 @@ class WordTemplateExporter {
b5: { width: 9979, height: 14170 },
letter: { width: 12240, height: 15840 },
legal: { width: 12240, height: 20160 },
tabloid: { width: 15840, height: 24480 }
tabloid: { width: 15840, height: 24480 },
};
let width, height;
@@ -373,7 +372,10 @@ class WordTemplateExporter {
continue;
}
// Split by | and trim
const cells = line.split('|').filter(cell => cell.trim()).map(cell => cell.trim());
const cells = line
.split('|')
.filter((cell) => cell.trim())
.map((cell) => cell.trim());
if (cells.length > 0) {
rows.push(cells);
}
@@ -382,7 +384,7 @@ class WordTemplateExporter {
if (rows.length === 0) return '';
// Calculate number of columns
const numCols = Math.max(...rows.map(row => row.length));
const numCols = Math.max(...rows.map((row) => row.length));
// Calculate column width in twips (1440 twips = 1 inch)
// Assume standard page width of 9360 twips (6.5 inches usable)
@@ -429,7 +431,7 @@ class WordTemplateExporter {
rowCells.push('');
}
rowCells.forEach((cellText, colIndex) => {
rowCells.forEach((cellText, _colIndex) => {
tableXml += '<w:tc>';
tableXml += '<w:tcPr>';
@@ -444,7 +446,8 @@ class WordTemplateExporter {
}
// Cell borders
tableXml += '<w:tcBorders>' +
tableXml +=
'<w:tcBorders>' +
'<w:top w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:left w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:bottom w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
@@ -452,7 +455,8 @@ class WordTemplateExporter {
'</w:tcBorders>';
// Cell margins for proper padding
tableXml += '<w:tcMar>' +
tableXml +=
'<w:tcMar>' +
'<w:top w:w="80" w:type="dxa"/>' +
'<w:left w:w="120" w:type="dxa"/>' +
'<w:bottom w:w="80" w:type="dxa"/>' +
@@ -499,7 +503,7 @@ class WordTemplateExporter {
// Don't treat markdown tables as ASCII art
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
// Check if it's a proper markdown table (has multiple cells)
const cells = line.split('|').filter(c => c.trim());
const cells = line.split('|').filter((c) => c.trim());
if (cells.length >= 2) {
return false;
}
@@ -507,17 +511,72 @@ class WordTemplateExporter {
// Common ASCII art characters (Unicode box drawing and symbols)
const asciiArtChars = [
'─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', // Box drawing
'═', '║', '╔', '╗', '╚', '╝', '╠', '╣', '╦', '╩', '╬', // Double box
'╭', '╮', '╯', '╰', // Rounded corners
'▲', '▼', '◄', '►', '♦', '●', '○', '■', '□', '◆', '◇', // Shapes
'↓', '→', '←', '↑', '↔', '↕', '⇒', '⇐', '⇓', '⇑', // Arrows
'┃', '━', '┏', '┓', '┗', '┛', '┣', '┫', '┳', '┻', '╋', // Heavy box
'░', '▒', '▓', '█', // Shading
'─',
'│',
'┌',
'┐',
'└',
'┘',
'├',
'┤',
'┬',
'┴',
'┼', // Box drawing
'═',
'║',
'╔',
'╗',
'╚',
'╝',
'╠',
'╣',
'╦',
'╩',
'╬', // Double box
'╭',
'╮',
'╯',
'╰', // Rounded corners
'▲',
'▼',
'◄',
'►',
'♦',
'●',
'○',
'■',
'□',
'◆',
'◇', // Shapes
'↓',
'→',
'←',
'↑',
'↔',
'↕',
'⇒',
'⇐',
'⇓',
'⇑', // Arrows
'┃',
'━',
'┏',
'┓',
'┗',
'┛',
'┣',
'┫',
'┳',
'┻',
'╋', // Heavy box
'░',
'▒',
'▓',
'█', // Shading
];
// Check for box drawing characters
if (asciiArtChars.some(char => line.includes(char))) {
if (asciiArtChars.some((char) => line.includes(char))) {
return true;
}
@@ -537,7 +596,7 @@ class WordTemplateExporter {
/^\s*\*[-=\*]+\*/, // *----*
];
return asciiPatterns.some(pattern => pattern.test(line));
return asciiPatterns.some((pattern) => pattern.test(line));
}
/**
@@ -564,7 +623,7 @@ class WordTemplateExporter {
// Check if line contains arrow characters and color them red
const arrowChars = ['↓', '→', '←', '↑', '▼', '►', '◄', '▲'];
const hasArrow = arrowChars.some(arrow => line.includes(arrow));
const hasArrow = arrowChars.some((arrow) => line.includes(arrow));
if (hasArrow) {
// Split line into parts and color arrows red
@@ -655,14 +714,13 @@ class WordTemplateExporter {
*/
parseInlineFormatting(text) {
let xml = '';
const pos = 0;
// Patterns for inline formatting
const patterns = [
{ regex: /\*\*\*(.+?)\*\*\*/g, bold: true, italic: true },
{ regex: /\*\*(.+?)\*\*/g, bold: true },
{ regex: /\*(.+?)\*/g, italic: true },
{ regex: /`(.+?)`/g, code: true }
{ regex: /`(.+?)`/g, code: true },
];
// Simple approach: process text sequentially
@@ -693,7 +751,12 @@ class WordTemplateExporter {
}
// Add formatted text
xml += this.createRunXml(match[1], matchedPattern.bold, matchedPattern.italic, matchedPattern.code);
xml += this.createRunXml(
match[1],
matchedPattern.bold,
matchedPattern.italic,
matchedPattern.code
);
remaining = remaining.substring(earliestPos + match[0].length);
} else {
+20 -28
View File
@@ -4,12 +4,7 @@
* Toggle with F11, exit with Escape.
*/
const {
EditorView,
ViewPlugin,
Decoration,
DecorationSet,
} = require('@codemirror/view');
const { EditorView, ViewPlugin, Decoration } = require('@codemirror/view');
const { RangeSetBuilder } = require('@codemirror/state');
class ZenMode {
@@ -45,10 +40,7 @@ class ZenMode {
// Add CM6 extensions to the active editor
if (tab.editorView) {
this._extensions = [
typewriterScrollExtension,
lineDimmingExtension,
];
this._extensions = [typewriterScrollExtension, lineDimmingExtension];
// Reconfigure is not straightforward with CM6's immutable state.
// Instead we dispatch effects or simply rebuild with new extensions.
// Since we cannot hot-swap extensions on an existing EditorView,
@@ -116,9 +108,6 @@ class ZenMode {
_applyTypewriterBehavior(view) {
// 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 = () => {
if (!this.active) return;
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
this._scrollInterval = setInterval(() => {
if (!this.active) {
@@ -157,7 +139,7 @@ class ZenMode {
scroller.scrollTop += diff;
}
}
} catch (e) {
} catch {
// Ignore errors from destroyed views
}
}, 200);
@@ -188,7 +170,12 @@ class ZenMode {
if (!this.active || !this._hud) return;
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 wordsEl = document.getElementById('zen-words');
@@ -228,7 +215,8 @@ class ZenMode {
}
// Typewriter scroll ViewPlugin
const typewriterScrollExtension = ViewPlugin.fromClass(class {
const typewriterScrollExtension = ViewPlugin.fromClass(
class {
constructor(view) {
this.view = view;
}
@@ -245,7 +233,8 @@ const typewriterScrollExtension = ViewPlugin.fromClass(class {
});
}
}
});
}
);
// Line dimming ViewPlugin
function getOpacity(distance) {
@@ -256,7 +245,8 @@ function getOpacity(distance) {
return 0.3;
}
const lineDimmingExtension = ViewPlugin.fromClass(class {
const lineDimmingExtension = ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
@@ -285,8 +275,10 @@ const lineDimmingExtension = ViewPlugin.fromClass(class {
return builder.finish();
}
}, {
decorations: v => v.decorations,
});
},
{
decorations: (v) => v.decorations,
}
);
module.exports = { ZenMode };
-47
View File
@@ -1,47 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./src/renderer/**/*.{js,ts,jsx,tsx}',
'./src/renderer/index.html',
],
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#e5461f',
dark: '#c93a18',
light: '#ff6b47',
},
success: '#1a7a56',
warning: '#eab308',
danger: '#ef4444',
info: '#0ea5e9',
warm: {
50: '#fafbfc',
100: '#f5f5f5',
200: '#e3e3e3',
300: '#d1d1d1',
400: '#b0b0b0',
500: '#9a9696',
600: '#7a7878',
700: '#5a5858',
800: '#4e4e4e',
900: '#464646',
950: '#0d0b09',
},
},
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'],
},
borderRadius: {
lg: '0.75rem',
md: '0.5rem',
sm: '0.25rem',
},
},
},
plugins: [require('tailwindcss-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);
}
});
});
+3 -1
View File
@@ -48,7 +48,9 @@ describe('EventBus', () => {
test('handler errors are caught and logged, not thrown', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
bus.on('bad:event', () => { throw new Error('boom'); });
bus.on('bad:event', () => {
throw new Error('boom');
});
expect(() => bus.emit('bad:event', {})).not.toThrow();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
+9 -15
View File
@@ -56,24 +56,20 @@ describe('GitOperations Utilities', () => {
'branch',
'checkout',
'push',
'pull'
'pull',
];
expect(gitOps.length).toBeGreaterThan(0);
gitOps.forEach(op => {
gitOps.forEach((op) => {
expect(typeof op).toBe('string');
expect(op.length).toBeGreaterThan(0);
});
});
it('should handle directory paths', () => {
const paths = [
'/home/user/project',
'./current/dir',
'../parent/dir'
];
const paths = ['/home/user/project', './current/dir', '../parent/dir'];
paths.forEach(pathStr => {
paths.forEach((pathStr) => {
expect(typeof pathStr).toBe('string');
expect(pathStr.length).toBeGreaterThan(0);
});
@@ -83,10 +79,10 @@ describe('GitOperations Utilities', () => {
const messages = [
'fix: bug in git panel',
'feat: add new feature',
'refactor: clean up code'
'refactor: clean up code',
];
messages.forEach(msg => {
messages.forEach((msg) => {
expect(typeof msg).toBe('string');
expect(msg.length).toBeGreaterThan(0);
});
@@ -101,7 +97,7 @@ describe('GitOperations Utilities', () => {
deleted: [],
modified: [],
renamed: [],
staged: ['file.md']
staged: ['file.md'],
};
expect(statusResponse).toHaveProperty('staged');
@@ -110,10 +106,8 @@ describe('GitOperations Utilities', () => {
it('should return log entries', () => {
const logResponse = {
all: [
{ hash: 'abc123', message: 'fix: something' }
],
latest: { hash: 'abc123', message: 'fix: something' }
all: [{ hash: 'abc123', message: 'fix: something' }],
latest: { hash: 'abc123', message: 'fix: something' },
};
expect(logResponse).toHaveProperty('all');
+3 -1
View File
@@ -8,7 +8,9 @@ describe('GoalTracker', () => {
store = {};
tracker = new GoalTracker({
get: (key) => store[key],
set: (key, value) => { store[key] = value; }
set: (key, value) => {
store[key] = value;
},
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* Tests ensuring the codebase has no ESLint warnings.
*/
const { execSync } = require('child_process');
const path = require('path');
describe('ESLint quality gate', () => {
test('src and tests have no ESLint warnings', () => {
const rootDir = path.resolve(__dirname, '..');
const result = execSync('npm run lint', {
cwd: rootDir,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(result).not.toMatch(/warning/);
});
});
+9 -10
View File
@@ -12,13 +12,11 @@ describe('sanitizeErrorMessage', () => {
};
test('strips Windows absolute paths', () => {
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js'))
.toBe('Error in file.js');
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js')).toBe('Error in file.js');
});
test('strips Unix absolute paths', () => {
expect(sanitizeErrorMessage('Error in /home/user/project/file.js'))
.toBe('Error in file.js');
expect(sanitizeErrorMessage('Error in /home/user/project/file.js')).toBe('Error in file.js');
});
test('handles non-string input', () => {
@@ -27,18 +25,19 @@ describe('sanitizeErrorMessage', () => {
});
test('preserves messages without paths', () => {
expect(sanitizeErrorMessage('Something went wrong'))
.toBe('Something went wrong');
expect(sanitizeErrorMessage('Something went wrong')).toBe('Something went wrong');
});
test('strips nested Windows paths', () => {
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json'))
.toBe('Cannot read config.json');
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json')).toBe(
'Cannot read config.json'
);
});
test('strips nested Unix paths', () => {
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log'))
.toBe('File not found: error.log');
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log')).toBe(
'File not found: error.log'
);
});
test('handles undefined input', () => {
+26 -12
View File
@@ -53,7 +53,7 @@ describe('Markdown Extensions', () => {
test('matches all admonition types', () => {
const types = ['note', 'warning', 'tip', 'danger', 'info'];
types.forEach(type => {
types.forEach((type) => {
const src = `:::${type}\nContent\n:::\n`;
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
@@ -80,7 +80,9 @@ describe('Markdown Extensions', () => {
const extension = {
name: 'admonition',
level: 'block',
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
start(src) {
return src.match(/^:::(note|warning|tip|danger|info)/m)?.index;
},
tokenizer(src) {
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
if (match && match.index === 0) {
@@ -93,7 +95,7 @@ describe('Markdown Extensions', () => {
raw: match[0],
admonitionType,
text,
tokens
tokens,
};
}
},
@@ -105,7 +107,7 @@ describe('Markdown Extensions', () => {
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
<div class="admonition-content">${inner}</div>
</div>`;
}
},
};
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
@@ -113,7 +115,7 @@ describe('Markdown Extensions', () => {
const mockLexer = {
blockTokens: jest.fn((text, tokens) => {
tokens.push({ type: 'text', text });
})
}),
};
const context = { lexer: mockLexer };
const result = extension.tokenizer.call(context, src);
@@ -130,10 +132,10 @@ describe('Markdown Extensions', () => {
const token = {
type: 'admonition',
admonitionType: 'warning',
tokens: [{ type: 'text', text: 'Be careful!' }]
tokens: [{ type: 'text', text: 'Be careful!' }],
};
const mockParser = {
parse: jest.fn((tokens) => '<p>Be careful!</p>')
parse: jest.fn(() => '<p>Be careful!</p>'),
};
const context = { parser: mockParser };
const html = extension.renderer.call(context, token);
@@ -148,7 +150,7 @@ describe('Markdown Extensions', () => {
describe('PlantUML hex encoding', () => {
const plantumlEncode = (text) => {
const hex = Array.from(Buffer.from(text, 'utf-8'))
.map(b => b.toString(16).padStart(2, '0'))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return '~h' + hex;
};
@@ -200,13 +202,23 @@ describe('Markdown Extensions', () => {
describe('scopeCSS utility', () => {
const scopeCSS = (cssText, scopeSelector) => {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
return cssText.replace(
/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g,
(match, selector, separator) => {
const trimmed = selector.trim();
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
if (
!trimmed ||
trimmed.startsWith('@') ||
trimmed.startsWith(':root') ||
trimmed.startsWith('from') ||
trimmed.startsWith('to') ||
/^\d+%$/.test(trimmed)
) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
}
);
};
test('scopes standard tag selector', () => {
@@ -218,7 +230,9 @@ describe('Markdown Extensions', () => {
test('scopes multiple class selectors', () => {
const css = '.title, .content { font-family: sans-serif; }';
const scoped = scopeCSS(css, '.preview-content');
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
expect(scoped).toBe(
'.preview-content .title,.preview-content .content{ font-family: sans-serif; }'
);
});
test('ignores @rules like @media', () => {
+19
View File
@@ -0,0 +1,19 @@
/**
* Tests ensuring production code does not contain leftover diagnostic console.log statements.
*/
const fs = require('fs');
const path = require('path');
describe('No leftover diagnostic console.log statements', () => {
const rootDir = path.resolve(__dirname, '..');
const filesToCheck = ['src/main.js', 'src/renderer.js'];
filesToCheck.forEach((relativePath) => {
test(`${relativePath} has no console.log statements`, () => {
const content = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
const matches = content.match(/console\.log\(/g) || [];
expect(matches).toHaveLength(0);
});
});
});
+2 -5
View File
@@ -8,7 +8,6 @@ describe('PDFOperations Utilities', () => {
describe('page range parsing', () => {
it('should parse single page numbers', () => {
// Test logic: parsing "1" should extract page index 0
const input = '1';
const pages = [0]; // Parsed result
expect(pages.length).toBe(1);
@@ -17,7 +16,6 @@ describe('PDFOperations Utilities', () => {
it('should parse page ranges', () => {
// Test logic: parsing "1-3" should extract pages 0, 1, 2
const input = '1-3';
const pages = [0, 1, 2]; // Expected result
expect(pages.length).toBe(3);
@@ -26,7 +24,6 @@ describe('PDFOperations Utilities', () => {
it('should handle multiple ranges', () => {
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
const input = '1-2,4-5';
const pages = [0, 1, 3, 4]; // Expected result
expect(pages.length).toBe(4);
@@ -52,7 +49,7 @@ describe('PDFOperations Utilities', () => {
it('should detect invalid hex colors', () => {
const invalidColors = ['#GG5733', '#12345', 'notahex'];
invalidColors.forEach(color => {
invalidColors.forEach((color) => {
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
expect(isValid).toBe(false);
});
@@ -73,7 +70,7 @@ describe('PDFOperations Utilities', () => {
{ input: '-1', isValid: false },
{ input: 'abc', isValid: false },
{ input: '1', isValid: true },
{ input: '5', isValid: true }
{ input: '5', isValid: true },
];
testPages.forEach(({ input, isValid }) => {
+15 -4
View File
@@ -13,9 +13,14 @@ describe('PluginContext', () => {
statusBar: { registerIndicator: jest.fn() },
eventBus: new EventBus(),
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
editor: {
getContent: jest.fn(),
getSelection: jest.fn(),
insertAtCursor: jest.fn(),
onContentChanged: jest.fn(),
},
ipc: { invoke: jest.fn(), on: jest.fn() },
exportHooks: { preHooks: [], postHooks: [] }
exportHooks: { preHooks: [], postHooks: [] },
};
context = new PluginContext(mockDeps);
});
@@ -23,11 +28,17 @@ describe('PluginContext', () => {
test('exposes sidebar.registerPanel with namespaced id', () => {
const handler = jest.fn();
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler });
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', {
icon: 'test',
title: 'Test',
render: handler,
});
});
test('exposes commands.register with crash-safe wrapper', () => {
const badHandler = () => { throw new Error('boom'); };
const badHandler = () => {
throw new Error('boom');
};
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
expect(() => registeredHandler()).not.toThrow();
+21 -7
View File
@@ -25,7 +25,7 @@ describe('PluginLoader', () => {
id: 'my-plugin',
name: 'My Plugin',
version: '1.0.0',
description: 'Test'
description: 'Test',
});
const loader = new PluginLoader([tempDir]);
const plugins = loader.discoverPlugins();
@@ -46,7 +46,7 @@ describe('PluginLoader', () => {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
description: 'A test plugin'
description: 'A test plugin',
};
const loader = new PluginLoader([]);
expect(loader.validateManifest(manifest)).toBe(true);
@@ -67,18 +67,32 @@ describe('PluginLoader', () => {
test('validateManifest — rejects manifest with duplicate id', () => {
const loader = new PluginLoader([]);
loader.loadedIds = new Set(['existing-plugin']);
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
.toThrow(/duplicate/i);
expect(() =>
loader.validateManifest({
id: 'existing-plugin',
name: 'Dup',
version: '1.0.0',
description: 'x',
})
).toThrow(/duplicate/i);
});
test('discoverPlugins — loads index.js Plugin export if present', () => {
const pluginDir = path.join(tempDir, 'with-index');
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
writeManifest(pluginDir, {
id: 'with-index',
name: 'With Index',
version: '1.0.0',
description: 'Test',
});
// Write a simple index.js with a Plugin export
fs.writeFileSync(path.join(pluginDir, 'index.js'), `
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
`
class SimplePlugin { init() {} }
module.exports = { Plugin: SimplePlugin };
`);
`
);
const loader = new PluginLoader([tempDir]);
const plugins = loader.discoverPlugins();
expect(plugins).toHaveLength(1);
+86 -17
View File
@@ -3,9 +3,16 @@ const { PluginAPI } = require('../src/plugins/plugin-api');
const { EventBus } = require('../src/plugins/event-bus');
class TestPlugin extends PluginAPI {
init(context) { this.initialized = true; this.ctx = context; }
activate() { this.activated = true; }
deactivate() { this.deactivated = true; }
init(context) {
this.initialized = true;
this.ctx = context;
}
activate() {
this.activated = true;
}
deactivate() {
this.deactivated = true;
}
}
describe('PluginRegistry', () => {
@@ -19,16 +26,26 @@ describe('PluginRegistry', () => {
statusBar: { registerIndicator: jest.fn() },
eventBus: new EventBus(),
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
editor: { getContent: jest.fn(() => ''), getSelection: jest.fn(() => ''), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
ipc: { invoke: jest.fn(), on: jest.fn() }
editor: {
getContent: jest.fn(() => ''),
getSelection: jest.fn(() => ''),
insertAtCursor: jest.fn(),
onContentChanged: jest.fn(),
},
ipc: { invoke: jest.fn(), on: jest.fn() },
};
registry = new PluginRegistry(mockDeps);
});
test('register — stores plugin and calls init', () => {
registry.register({
id: 'test', name: 'Test', version: '1.0.0', description: 'desc',
manifest: {}, PluginClass: TestPlugin, dir: '/tmp/test'
id: 'test',
name: 'Test',
version: '1.0.0',
description: 'desc',
manifest: {},
PluginClass: TestPlugin,
dir: '/tmp/test',
});
const entry = registry.getPlugin('test');
expect(entry).toBeDefined();
@@ -37,20 +54,32 @@ describe('PluginRegistry', () => {
test('register — works without PluginClass', () => {
registry.register({
id: 'manifest-only', name: 'Manifest Only', version: '1.0.0', description: 'desc',
manifest: {}, PluginClass: null, dir: '/tmp/test'
id: 'manifest-only',
name: 'Manifest Only',
version: '1.0.0',
description: 'desc',
manifest: {},
PluginClass: null,
dir: '/tmp/test',
});
expect(registry.getPlugin('manifest-only')).toBeDefined();
});
test('register — init error does not crash, plugin not registered', () => {
class BadPlugin extends PluginAPI {
init() { throw new Error('init fail'); }
init() {
throw new Error('init fail');
}
}
expect(() => {
registry.register({
id: 'bad', name: 'Bad', version: '1.0.0', description: 'desc',
manifest: {}, PluginClass: BadPlugin, dir: '/tmp/test'
id: 'bad',
name: 'Bad',
version: '1.0.0',
description: 'desc',
manifest: {},
PluginClass: BadPlugin,
dir: '/tmp/test',
});
}).not.toThrow();
expect(registry.getPlugin('bad')).toBeUndefined();
@@ -61,25 +90,65 @@ describe('PluginRegistry', () => {
});
test('getAll — returns all registered plugins', () => {
registry.register({ id: 'a', name: 'A', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
registry.register({ id: 'b', name: 'B', version: '1', description: '', manifest: {}, PluginClass: null, dir: '' });
registry.register({
id: 'a',
name: 'A',
version: '1',
description: '',
manifest: {},
PluginClass: null,
dir: '',
});
registry.register({
id: 'b',
name: 'B',
version: '1',
description: '',
manifest: {},
PluginClass: null,
dir: '',
});
expect(registry.getAll()).toHaveLength(2);
});
test('activate — calls activate on plugin instance', () => {
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
registry.register({
id: 'test',
name: 'Test',
version: '1',
description: '',
manifest: {},
PluginClass: TestPlugin,
dir: '',
});
registry.activate('test');
expect(registry.getPlugin('test').instance.activated).toBe(true);
});
test('deactivate — calls deactivate on plugin instance', () => {
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
registry.register({
id: 'test',
name: 'Test',
version: '1',
description: '',
manifest: {},
PluginClass: TestPlugin,
dir: '',
});
registry.deactivate('test');
expect(registry.getPlugin('test').instance.deactivated).toBe(true);
});
test('exportHooks are available and populated', () => {
registry.register({ id: 'test', name: 'Test', version: '1', description: '', manifest: {}, PluginClass: TestPlugin, dir: '' });
registry.register({
id: 'test',
name: 'Test',
version: '1',
description: '',
manifest: {},
PluginClass: TestPlugin,
dir: '',
});
const handler = jest.fn();
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
expect(registry.exportHooks.preHooks).toContain(handler);
+2 -2
View File
@@ -74,7 +74,7 @@ describe('Preload Security', () => {
'execute-code',
'show-pdf-editor-from-toolbar',
'menu-open',
'export'
'export',
];
const EXPECTED_RECEIVE_CHANNELS = [
@@ -127,7 +127,7 @@ describe('Preload Security', () => {
'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel',
'toggle-bottom-panel'
'toggle-bottom-panel',
];
test('should define all expected send channels', () => {
+19 -7
View File
@@ -10,9 +10,13 @@
describe('PrintPreview', () => {
beforeEach(() => {
// Mock electron require for executePrint
jest.mock('electron', () => ({
ipcRenderer: { send: jest.fn(), invoke: jest.fn() }
}), { virtual: true });
jest.mock(
'electron',
() => ({
ipcRenderer: { send: jest.fn(), invoke: jest.fn() },
}),
{ virtual: true }
);
document.body.innerHTML = `
<div class="dialog-overlay hidden" id="print-preview-overlay">
@@ -51,9 +55,13 @@ describe('PrintPreview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(false);
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
false
);
preview.close();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('updateScaleLabel reflects slider value', () => {
@@ -69,7 +77,9 @@ describe('PrintPreview', () => {
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-preview-close').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('cancel button closes preview', () => {
@@ -77,7 +87,9 @@ describe('PrintPreview', () => {
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-cancel').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('getOptions reflects changed values', () => {
+47 -19
View File
@@ -8,15 +8,22 @@ describe('ProjectManager', () => {
files = {};
pm = new ProjectManager({
readFile: (p) => files[p] || null,
writeFile: (p, c) => { files[p] = c; },
writeFile: (p, c) => {
files[p] = c;
},
fileExists: (p) => p in files,
listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1))
listDir: (p) =>
Object.keys(files)
.filter((f) => f.startsWith(p))
.map((f) => f.slice(p.length + 1)),
});
});
test('createProject writes .project.json', () => {
const project = pm.createProject('/manuscripts/novel', {
title: 'My Novel', type: 'manuscript', targetWords: 80000
title: 'My Novel',
type: 'manuscript',
targetWords: 80000,
});
expect(project.title).toBe('My Novel');
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
@@ -26,8 +33,11 @@ describe('ProjectManager', () => {
test('loadProject reads and returns project data', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
chapters: [], metadata: {}
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [],
metadata: {},
});
const project = pm.loadProject('/manuscripts/novel');
expect(project.title).toBe('Test');
@@ -39,10 +49,17 @@ describe('ProjectManager', () => {
test('addChapter appends chapter and saves', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
chapters: [], metadata: {}
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [],
metadata: {},
});
pm.addChapter('/manuscripts/novel', {
file: '01-chapter.md',
title: 'Chapter One',
status: 'draft',
});
pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' });
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
expect(parsed.chapters.length).toBe(1);
expect(parsed.chapters[0].title).toBe('Chapter One');
@@ -50,11 +67,14 @@ describe('ProjectManager', () => {
test('compileManuscript concatenates chapter files', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [
{ file: '01.md', title: 'One', status: 'draft' },
{ file: '02.md', title: 'Two', status: 'draft' }
], metadata: {}
{ file: '02.md', title: 'Two', status: 'draft' },
],
metadata: {},
});
files['/manuscripts/novel/01.md'] = 'First chapter content.';
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
@@ -64,11 +84,14 @@ describe('ProjectManager', () => {
test('compileManuscript skips missing files', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [
{ file: '01.md', title: 'One', status: 'draft' },
{ file: '02.md', title: 'Two', status: 'draft' }
], metadata: {}
{ file: '02.md', title: 'Two', status: 'draft' },
],
metadata: {},
});
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
const result = pm.compileManuscript('/manuscripts/novel');
@@ -77,11 +100,14 @@ describe('ProjectManager', () => {
test('getStats returns total word count across chapters', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [
{ file: '01.md', title: 'One', status: 'draft' },
{ file: '02.md', title: 'Two', status: 'draft' }
], metadata: {}
{ file: '02.md', title: 'Two', status: 'draft' },
],
metadata: {},
});
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
@@ -94,9 +120,11 @@ describe('ProjectManager', () => {
test('updateChapter modifies a chapter by index', () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', type: 'manuscript', target: { words: 50000 },
title: 'Test',
type: 'manuscript',
target: { words: 50000 },
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
metadata: {}
metadata: {},
});
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
+21
View File
@@ -0,0 +1,21 @@
/**
* Tests for project metadata consistency
*/
const fs = require('fs');
const path = require('path');
describe('Project version consistency', () => {
const rootDir = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
const readme = fs.readFileSync(path.join(rootDir, 'README.md'), 'utf8');
test('README version matches package.json version', () => {
const version = packageJson.version;
expect(readme).toContain(`v${version}`);
});
test('README contains current version string', () => {
expect(readme).toContain('v4.4.4');
});
});
+9 -26
View File
@@ -8,25 +8,18 @@ const path = require('path');
describe('Security: Path Handling', () => {
describe('path traversal prevention', () => {
it('should detect path traversal patterns', () => {
const maliciousPaths = [
'../etc/passwd',
'../../sensitive',
'./../outside'
];
const maliciousPaths = ['../etc/passwd', '../../sensitive', './../outside'];
maliciousPaths.forEach(pathStr => {
maliciousPaths.forEach((pathStr) => {
// Path traversal attempts contain .. patterns
expect(pathStr).toMatch(/\.\./);
});
});
it('should normalize relative paths safely', () => {
const safePaths = [
'./documents/file.md',
'relative/path/file.txt'
];
const safePaths = ['./documents/file.md', 'relative/path/file.txt'];
safePaths.forEach(pathStr => {
safePaths.forEach((pathStr) => {
const normalized = path.normalize(pathStr);
// Safe relative paths should normalize cleanly
expect(normalized).toBeDefined();
@@ -58,14 +51,9 @@ describe('Security: Path Handling', () => {
describe('filename safety', () => {
it('should identify safe filenames', () => {
const safeNames = [
'document.md',
'my-file.txt',
'file_name.pdf',
'report_2026_04_24.xlsx'
];
const safeNames = ['document.md', 'my-file.txt', 'file_name.pdf', 'report_2026_04_24.xlsx'];
safeNames.forEach(name => {
safeNames.forEach((name) => {
// Safe names should not contain path separators or null bytes
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
expect(isSafe).toBe(true);
@@ -73,12 +61,9 @@ describe('Security: Path Handling', () => {
});
it('should flag filenames with path separators', () => {
const problematicNames = [
'file/with/slashes.txt',
'file\\with\\backslashes.txt'
];
const problematicNames = ['file/with/slashes.txt', 'file\\with\\backslashes.txt'];
problematicNames.forEach(name => {
problematicNames.forEach((name) => {
// These contain path separators and should be flagged
const hasPathSeparators = /[\\/]/.test(name);
expect(hasPathSeparators).toBe(true);
@@ -95,10 +80,8 @@ describe('Security: Path Handling', () => {
describe('validation patterns', () => {
it('should validate path existence check pattern', () => {
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
validPaths.forEach(pathStr => {
validPaths.forEach((pathStr) => {
// These should match a reasonable filename pattern
expect(typeof pathStr).toBe('string');
});
+3 -1
View File
@@ -8,7 +8,9 @@ describe('SettingsStore', () => {
data = {};
store = new SettingsStore({
get: (key) => data[key],
set: (key, value) => { data[key] = value; }
set: (key, value) => {
data[key] = value;
},
});
});
+15 -15
View File
@@ -25,61 +25,61 @@ global.window.electronAPI = {
exists: jest.fn(() => Promise.resolve(false)),
isDirectory: jest.fn(() => Promise.resolve(false)),
copy: jest.fn(() => Promise.resolve()),
move: jest.fn(() => Promise.resolve())
move: jest.fn(() => Promise.resolve()),
},
theme: {
get: jest.fn()
get: jest.fn(),
},
print: {
doPrint: jest.fn()
doPrint: jest.fn(),
},
export: {
withOptions: jest.fn(),
spreadsheet: jest.fn()
spreadsheet: jest.fn(),
},
batch: {
convert: jest.fn(),
selectFolder: jest.fn()
selectFolder: jest.fn(),
},
converter: {
convert: jest.fn(),
convertBatch: jest.fn()
convertBatch: jest.fn(),
},
headerFooter: {
getSettings: jest.fn(),
saveSettings: jest.fn(),
browseLogo: jest.fn(),
saveLogo: jest.fn(),
clearLogo: jest.fn()
clearLogo: jest.fn(),
},
page: {
getSettings: jest.fn(),
updateSettings: jest.fn(),
setCustomStartPage: jest.fn()
setCustomStartPage: jest.fn(),
},
pdf: {
processOperation: jest.fn(),
getPageCount: jest.fn(),
selectFolder: jest.fn()
}
selectFolder: jest.fn(),
},
};
// Mock marked library
global.window.marked = {
parse: jest.fn((text) => `<p>${text}</p>`),
use: jest.fn()
use: jest.fn(),
};
// Mock DOMPurify
global.window.DOMPurify = {
sanitize: jest.fn((html) => html)
sanitize: jest.fn((html) => html),
};
// Mock highlight.js
global.window.hljs = {
highlight: jest.fn((code, options) => ({ value: code })),
highlight: jest.fn((code, _options) => ({ value: code })),
highlightAuto: jest.fn((code) => ({ value: code })),
getLanguage: jest.fn(() => true)
getLanguage: jest.fn(() => true),
};
// Mock localStorage
@@ -87,7 +87,7 @@ const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn()
clear: jest.fn(),
};
global.localStorage = localStorageMock;
+19 -4
View File
@@ -30,13 +30,18 @@ describe('SidebarManager', () => {
});
test('starts collapsed', () => {
const mgr = new SidebarManager();
new SidebarManager();
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
});
test('expands on panel toggle', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Test 1', render: (c) => { c.innerHTML = 'hello'; } });
mgr.registerPanel('test1', {
title: 'Test 1',
render: (c) => {
c.innerHTML = 'hello';
},
});
mgr.togglePanel('test1');
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false);
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1');
@@ -52,8 +57,18 @@ describe('SidebarManager', () => {
test('switches panels', () => {
const mgr = new SidebarManager();
mgr.registerPanel('test1', { title: 'Panel 1', render: (c) => { c.innerHTML = 'one'; } });
mgr.registerPanel('test2', { title: 'Panel 2', render: (c) => { c.innerHTML = 'two'; } });
mgr.registerPanel('test1', {
title: 'Panel 1',
render: (c) => {
c.innerHTML = 'one';
},
});
mgr.registerPanel('test2', {
title: 'Panel 2',
render: (c) => {
c.innerHTML = 'two';
},
});
mgr.togglePanel('test1');
mgr.togglePanel('test2');
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2');
+3 -1
View File
@@ -8,7 +8,9 @@ describe('SnapshotManager', () => {
store = {};
manager = new SnapshotManager({
get: (key) => store[key],
set: (key, value) => { store[key] = value; }
set: (key, value) => {
store[key] = value;
},
});
});
+2 -2
View File
@@ -7,7 +7,7 @@ describe('SprintEngine', () => {
beforeEach(() => {
events = [];
engine = new SprintEngine({
onEvent: (name, data) => events.push({ name, data })
onEvent: (name, data) => events.push({ name, data }),
});
});
@@ -45,7 +45,7 @@ describe('SprintEngine', () => {
test('tick auto-stops when time expires and emits sprint:complete', () => {
engine.start(1, 100); // 1 minute
engine.tick(60 * 1000 + 1); // just past
expect(events.some(e => e.name === 'sprint:complete')).toBe(true);
expect(events.some((e) => e.name === 'sprint:complete')).toBe(true);
expect(engine.isActive()).toBe(false);
});
+9 -5
View File
@@ -35,7 +35,7 @@ describe('Utility Functions', () => {
return {
command: parts[0],
args: parts.slice(1)
args: parts.slice(1),
};
}
@@ -58,7 +58,9 @@ describe('Utility Functions', () => {
});
test('should handle multiple options', () => {
const result = parseCommand('pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf');
const result = parseCommand(
'pandoc input.md --pdf-engine=xelatex -V geometry:margin=1in -o output.pdf'
);
expect(result.command).toBe('pandoc');
expect(result.args).toContain('--pdf-engine=xelatex');
expect(result.args).toContain('-V');
@@ -75,11 +77,13 @@ describe('Utility Functions', () => {
// This function converts hex colors to RGB
function hexToRgb(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,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255
} : null;
b: parseInt(result[3], 16) / 255,
}
: null;
}
test('should convert black hex to RGB', () => {
-23
View File
@@ -1,23 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
root: path.join(__dirname, 'src/renderer'),
base: './',
build: {
outDir: path.join(__dirname, 'dist/renderer'),
emptyOutDir: true,
sourcemap: true,
},
resolve: {
alias: {
'@': path.join(__dirname, 'src/renderer'),
},
},
server: {
port: 5173,
strictPort: true,
},
});