Compare commits

..
27 Commits
Author SHA1 Message Date
amitwh 8027d0b9b5 chore(release): bump version to 5.0.0 2026-06-06 14:13:52 +05:30
amitwh c0988a0108 docs: add CHANGELOG.md (v5.0.0 entry, Keep a Changelog 1.1.0 format) 2026-06-06 14:12:27 +05:30
amitwh 18fc2317ae refactor: delete 17 legacy files (renderer.js, 8 stylesheets, 5 scripts, 2 htmls, src/index.html orphan, deprecated src/main.js) 2026-06-06 14:11:56 +05:30
amitwh 94dd62946a refactor(preload): remove 9 dead IPC channels (print-preview*, command-palette, open-*/show-* ascii/table) 2026-06-06 14:09:25 +05:30
amitwh 054ce2351a refactor(main): create src/main/{store,index}.js glue layer; new entrypoint at src/main/index.js 2026-06-06 14:08:13 +05:30
amitwh 6651dccfdf refactor(main): move WordTemplateExporter from src/wordTemplateExporter.js to src/main/word-template/ (preserved as single module) 2026-06-06 14:04:16 +05:30
amitwh 848102905e refactor(main): extract window creation to src/main/window/ (main only, ascii/table windows removed) 2026-06-06 14:03:12 +05:30
amitwh 6c7a86c99b refactor(main): extract menu to src/main/menu/ (remove dead print-preview/command-palette/ascii/table sends) 2026-06-06 14:00:29 +05:30
amitwh b46fa9abaa fix(main): remove duplicate list-directory handler from src/main/files/ (kept original in main.js) 2026-06-06 09:52:00 +05:30
amitwh 2a32e702f3 refactor(main): extract file ops to src/main/files/ (search, git, binary) 2026-06-06 09:43:31 +05:30
amitwh 356cfc5cc8 refactor(main): extract path helpers to src/main/utils/paths.js 2026-06-06 09:25:22 +05:30
amitwh a03144df5b docs(plan): Phase 10 polish + delete legacy — 10 tasks, main.js decomposition + 17 legacy file deletions + v5.0.0 2026-06-06 09:22:15 +05:30
amitwh 7fc11aed3b docs(spec): Phase 10 — fix index.html ambiguity (DELETE legacy root src/index.html, not rewrite; src/renderer/index.html is already live) 2026-06-06 09:16:02 +05:30
amitwh 095f77f5f1 docs(spec): Phase 10 polish + delete legacy design — main.js decomposition, file map, v5.0.0 2026-06-06 09:14:39 +05:30
amitwh 80afbb136b test(renderer): Phase 9 tools integration smoke test (6 commands) 2026-06-06 08:38:01 +05:30
amitwh 95bbe171b5 feat(renderer): GitStatusPanel sidebar tab + git.refresh event 2026-06-06 08:28:37 +05:30
amitwh 2d77fa5456 feat(renderer): Breadcrumb shows heading symbols (settings toggleable) 2026-06-06 08:28:24 +05:30
amitwh fbaff37505 feat(renderer): Minimap (custom overview, wired to settings.minimap) 2026-06-06 08:20:48 +05:30
amitwh 1ccde4baa3 feat(renderer): Zen mode (Esc exits, hides chrome) 2026-06-06 08:13:38 +05:30
amitwh 8822c9a2f9 feat(renderer): PrintPreview overlay + App.tsx mount 2026-06-06 08:11:15 +05:30
amitwh 324e5676b8 feat(renderer): ReplPanel (markdown snippet preview) 2026-06-06 08:11:10 +05:30
amitwh a62511e7ed feat(renderer): FindInFilesDialog (cross-file search with regex) 2026-06-06 08:04:49 +05:30
amitwh a6c6e3696e feat(renderer): WordExportDialog (docx export with template picker) 2026-06-06 08:02:19 +05:30
amitwh 1b2ab437d1 feat(renderer): TableGeneratorDialog (markdown table generator) 2026-06-06 07:45:18 +05:30
amitwh efc7709f66 feat(renderer): AsciiGeneratorDialog (figlet text-to-ASCII-art) 2026-06-06 07:40:20 +05:30
amitwh a8f7547c7e feat(renderer): Phase 9 foundation — settings, modal union, ipc, commands, docx-export 2026-06-06 07:33:00 +05:30
amitwh 094b52a2b3 docs(spec): Phase 9 advanced tools design — 10 features across 3 mount strategies 2026-06-06 07:20:17 +05:30
74 changed files with 4383 additions and 17828 deletions
+43
View File
@@ -0,0 +1,43 @@
# Changelog
All notable changes to markdown-converter will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.0.0] - 2026-06-06
### Added
- **Complete React 19 + Vite + TypeScript renderer** replacing the legacy vanilla-JS UI (Phases 1-9 of the React UI redesign)
- Native macOS/Windows/Linux menus with command palette and keyboard shortcuts
- Settings sheet with 5 tabs (editor, theme, keybindings, advanced, about) and 17+ persisted fields
- Modal layer with 13 modal kinds (export PDF/DOCX/HTML/Word, batch, settings, about, welcome, confirm, ASCII gen, table gen, find in files)
- 10 advanced tools: ASCII generator (figlet), table generator, Word export (.docx via `docx` lib), find-in-files (recursive regex), REPL (markdown snippet preview), print preview, zen mode (Esc exits), minimap, breadcrumbs-with-symbols, git status (porcelain parser)
- Sonner toast notifications at 4 wire points (file save, open file/folder, 4 export dialogs)
- 3 mount strategies: ModalLayer dialogs, App.tsx global overlays, editor/sidebar integrations
- `ipc.file.writeBuffer` for renderer-side binary file output (used by Word .docx export)
- `ipc.file.search` (recursive regex), `ipc.file.gitStatus`, `ipc.print.show`, `ipc.app.showSaveDialog`
- 305 unit + integration tests (vitest + React Testing Library)
- Per-package @radix-ui primitives (checkbox, dialog, select, switch, tabs, radio-group, scroll-area, slider, collapsible, label, context-menu)
- shadcn/ui (new-york style) primitives, manually pasted (CLI broken on Node 24)
- Feature-first main process decomposition: `src/main/{files,menu,window,word-template,utils}/` + glue files
### Changed
- **BREAKING**: Renderer is now React-only. The legacy `src/renderer.js` (5319 lines) and all vanilla-JS UI scripts/styles are removed.
- Main process decomposed from a 4311-line `src/main.js` into feature-first modules under `src/main/`
- New entrypoint: `src/main/index.js` (was `src/main.js`)
- `src/index.html` (1667-line legacy orphan) removed; renderer served by `src/renderer/index.html` (Vite root)
- IPC contract: handlers throw on error, `safeCall` catches → returns `{ ok: false, error }`. `result.ok` is at top level, NOT nested in `result.data.ok`
- Settings store: `useSettingsStore` (zustand persist with zod validation), 17+ persisted fields
- Modal state: `useAppStore.modal: ModalState` discriminated union with 13 kinds; `openModal` uses TS conditional types to enforce prop requirements
### Removed
- `src/renderer.js` (legacy vanilla-JS renderer, 5319 lines)
- 8 legacy stylesheets: `src/styles.css`, `src/styles-modern.css`, `src/styles-concreteinfo.css`, `src/styles-sidebar.css`, `src/styles-zen.css`, `src/styles-welcome.css`, `src/fonts.css`
- 5 legacy scripts: `src/command-palette.js`, `src/print-preview.js`, `src/welcome.js`, `src/zen-mode.js`, `src/wordTemplateExporter.js`
- 2 legacy HTMLs: `src/ascii-generator.html`, `src/table-generator.html`
- `src/main.js` (4311-line god file, replaced by `src/main/index.js`)
- `src/index.html` (1667-line legacy orphan at project root, replaced by `src/renderer/index.html`)
- 9 dead IPC channels from `src/preload.js`: `toggle-command-palette`, `print-preview`, `print-preview-styled`, `open-ascii-generator`, `open-table-generator`, `show-ascii-generator`, `show-ascii-generator-window`, `show-table-generator`, `show-table-generator-window`
[5.0.0]: https://github.com/amitwh/markdown-converter/releases/tag/v5.0.0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
# Phase 9 — Advanced Tools Design
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phase 9 is sketched at high level; this spec locks architecture, file map, and contracts so it can be planned task-by-task.)
**Date:** 2026-06-05
**Phase:** 9 of 10 (React + shadcn/ui UI redesign)
**Tag (on completion):** `phase-9-advanced-tools`
---
## 1. Goal & Non-Goals
**Goal:** Add 10 advanced tools to the React renderer. Group 1: Standalone dialogs (ASCII generator, Table generator, Word export, Find-in-files). Group 2: Global overlays (Zen mode, REPL, Print preview). Group 3: Editor/sidebar integrations (Minimap, Breadcrumbs-with-symbols, Git status). All triggered via the Phase 6 command store.
**Non-goals (Phase 9):**
- True undo/redo stack (still not in scope)
- Snippet/template library
- Custom REPL with full JS eval (we chose markdown snippet preview for safety)
- Custom diff/merge UI (just shows git status, no in-app diffing)
- Plug-in extensions
- Multi-cursor editing
- LSP / language server integration
**Decision summary (from brainstorming):**
- REPL = **markdown snippet preview** (no JS eval — safe in renderer)
- Word export = **`.docx` via `docx` lib in renderer**, with both Standard and Custom .dotx template modes
- Find-in-files = **recursive search with result navigation** (new IPC, regex support)
---
## 2. Architecture
### 2.1 Three mount strategies
| Category | Mount | Examples |
|---|---|---|
| **Dialogs** | `<ModalLayer />` (Phase 7 pattern) | ASCII gen, Table gen, Word export, Find-in-files |
| **Global overlays** | Top-level in `App.tsx` (like `<Toaster />`) | Zen mode, REPL panel, Print preview |
| **Editor/sidebar integrations** | Extend existing components | Minimap, Breadcrumbs, Git status |
The ModalLayer is the dispatcher for dialogs. The global overlays are mounted directly in `App.tsx` because they need to participate in the top-level layout (full-window, or pinned to the bottom).
### 2.2 ModalState union extension
The Phase 7 `ModalState` discriminated union (9 kinds) gets 4 new kinds for the new dialogs:
```ts
export type ModalState =
| { kind: null }
| { kind: 'export-pdf'; props: { sourcePath: string } }
| { kind: 'export-docx'; props: { sourcePath: string } }
| { kind: 'export-html'; props: { sourcePath: string } }
| { kind: 'export-batch'; props: { sourcePaths: string[] } }
| { kind: 'export-word'; props: { sourcePath: string } } // NEW
| { kind: 'ascii-generator' } // NEW
| { kind: 'table-generator' } // NEW
| { kind: 'find-in-files' } // NEW
| { kind: 'settings' }
| { kind: 'about' }
| { kind: 'welcome' }
| { kind: 'confirm'; props: ConfirmProps };
```
Three of the four new kinds (ascii, table, find-in-files) take no props — they read from the active buffer via `useExportSource` (for ascii/table) or from `useFileStore.rootPath` (for find-in-files). `export-word` takes `sourcePath` like the other export dialogs.
### 2.3 New commands in command store
| Command ID | Trigger | Opens |
|---|---|---|
| `tools.ascii` | new | `AsciiGeneratorDialog` |
| `tools.table` | new | `TableGeneratorDialog` |
| `tools.exportWord` | new | `WordExportDialog` |
| `tools.findInFiles` | new | `FindInFilesDialog` |
| `tools.repl` | new | toggles REPL panel |
| `view.zenMode` | existing (Phase 6) | toggles Zen mode overlay |
| `file.print` | new | opens `PrintPreview` overlay |
| `git.refresh` | new | re-fetches git status |
All registered in `src/renderer/lib/commands/register-menu-commands.ts`.
### 2.4 New IPC surface
```ts
// src/renderer/lib/ipc.ts (additions)
ipc.file.search({ rootPath, query, isRegex, caseSensitive }: {
rootPath: string;
query: string;
isRegex: boolean;
caseSensitive: boolean;
}): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }>>>;
ipc.file.gitStatus({ rootPath }: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }>>>;
ipc.file.print({ html }: { html: string }): Promise<IpcResult<void>>;
ipc.file.writeBuffer({ path, buffer }: { path: string; buffer: Uint8Array }): Promise<IpcResult<void>>;
```
The main process counterparts are added to `src/main.js` (or a new `src/main/files-search.js`, etc.). For Phase 9, the spec covers the renderer-side design; main-process IPC handlers are assumed to follow the same `ipcMain.handle` pattern as the existing handlers.
### 2.5 Settings additions (`useSettingsStore`)
```ts
// zod schema additions:
docxCustomTemplatePath: z.string().nullable().default(null), // path to user .dotx file
replOpen: z.boolean().default(false), // REPL panel visibility
breadcrumbSymbols: z.boolean().default(true), // breadcrumbs show code symbols
// minimap already exists from Phase 7 (useSettingsStore.minimap: z.boolean().default(true))
```
The `minimap` setting from Phase 7 is now wired (not just stored).
---
## 3. File Map
### 3.1 Created files
**Dialogs (in `src/renderer/components/modals/`):**
- `AsciiGeneratorDialog.tsx` — input textarea, font select, output preview with copy button
- `TableGeneratorDialog.tsx` — rows × cols inputs, header checkbox, output preview
- `WordExportDialog.tsx` — template select (Standard / Custom .dotx), options, preview, export
- `FindInFilesDialog.tsx` — query input, regex/case toggles, results list with click-to-navigate
**Global overlays (in `src/renderer/components/tools/`):**
- `ReplPanel.tsx` — bottom-pinned, textarea + rendered preview
- `PrintPreview.tsx` — full-window print preview
**Editor/sidebar integrations:**
- `src/renderer/components/layout/ZenMode.tsx` — wraps/controls the editor in zen mode (or modify `AppShell.tsx` to hide chrome)
- `src/renderer/components/sidebar/GitStatusPanel.tsx` — file list with status badges
**Lib:**
- `src/renderer/lib/docx-export.ts` — renderer-side `docx` lib integration (markdown → Blob)
- `src/renderer/hooks/use-zen-mode.ts` — small hook that reads `useSettingsStore.zenMode`
**Modifications:**
- `src/renderer/App.tsx` — mount `<ReplPanel />` and `<PrintPreview />` alongside `<ModalLayer />` and `<Toaster />`
- `src/renderer/components/layout/AppShell.tsx` — when `zenMode === true`, hide all chrome except the editor
- `src/renderer/components/editor/CodeMirrorEditor.tsx` — add `@codemirror/minimap` (or `@replit/codemirror-minimap`) when `useSettingsStore.minimap` is true
- `src/renderer/components/layout/Breadcrumb.tsx` — extend to show symbols (headings, code blocks) using `@codemirror/langs-data` or a simple markdown AST walk
- `src/renderer/components/sidebar/Sidebar.tsx` — add a "Git" tab to the existing tab list
- `src/renderer/stores/settings-store.ts` — add 3 new fields
- `src/renderer/lib/validators.ts` — add 3 new fields to `settingsSchema`
- `src/renderer/lib/ipc.ts` — add 4 new IPC methods
- `src/renderer/lib/commands/register-menu-commands.ts` — add 8 new commands
- `src/main.js` (or split files) — add 4 main-process IPC handlers
**Tests:**
- `tests/component/tools/ReplPanel.test.tsx` — smoke test
- `tests/component/tools/PrintPreview.test.tsx` — smoke test
- `tests/component/modals/AsciiGeneratorDialog.test.tsx` — 3 tests
- `tests/component/modals/TableGeneratorDialog.test.tsx` — 3 tests
- `tests/component/modals/WordExportDialog.test.tsx` — 4 tests
- `tests/component/modals/FindInFilesDialog.test.tsx` — 4 tests
- `tests/component/sidebar/GitStatusPanel.test.tsx` — 3 tests
- `tests/component/layout/Breadcrumb.test.tsx` — extend with symbols test
- `tests/integration/phase9-tools-smoke.test.tsx` — 6 tests (one per command-triggered tool)
---
## 4. Data Flow
### 4.1 Word export (most complex)
1. User triggers `tools.exportWord``registerMenuCommands` opens the export-word modal via `useAppStore.openModal('export-word', { sourcePath: activeTabId })`
2. **Wait** — we need a new modal kind `export-word` in the `ModalState` union. Add it.
3. `WordExportDialog` (mounted by ModalLayer) shows:
- Template select: "Standard (bundled)" or "Custom .dotx (your file)" — pre-populated with `useSettingsStore.docxCustomTemplatePath`
- "Choose custom template..." button (file picker, saves to settings)
- Options: embed images (checkbox), include front matter (checkbox)
- Preview area: shows the generated docx structure (sizes, table of contents)
4. On submit:
- Renderer calls `lib/docx-export.ts#generateDocx(source, templatePath, options)` which uses the `docx` lib
- The lib takes a `Document` AST and produces a Blob
- ASCII tables are converted to monospace `<w:r>` runs (using `applyAsciiTransform` from Phase 7)
- Images are extracted from markdown and embedded as base64
- If a custom .dotx path is set, the renderer reads it via `ipc.file.read` and applies its styles (via `docx` lib's style support)
- User picks output path via `ipc.app.showSaveDialog`
- Writes Blob via `ipc.file.writeBuffer` (new)
- Toast success/failure
### 4.2 Find-in-files
1. User triggers `tools.findInFiles` → ModalLayer opens `FindInFilesDialog`
2. User types query, picks regex/literal, case-sensitive toggle
3. On submit: `ipc.file.search({ rootPath: useFileStore.rootPath, query, isRegex, caseSensitive })`
4. Backend walks the disk recursively, applies regex/literal match, returns `Array<{ filePath, line, content }>`
5. Dialog shows result list (file:line:content, clickable)
6. Click a result:
- `useFileStore.openFile(filePath)` if not already open
- Set the cursor in the editor to the matched line
7. Close the dialog
### 4.3 REPL (markdown snippet preview)
1. User triggers `tools.repl` → toggles `useSettingsStore.replOpen` (or a new dedicated `useReplStore`)
2. `ReplPanel` (mounted in App.tsx) is visible when `replOpen === true`
3. The panel has:
- Top half: textarea (user types/pastes markdown)
- Bottom half: rendered preview (uses `lib/markdown.ts` from Phase 1 + DOMPurify)
4. Updates are debounced (300ms) for the preview
5. Pure renderer-side, no IPC
### 4.4 Zen mode
1. User triggers `view.zenMode` → toggles `useSettingsStore.zenMode`
2. `AppShell` reads `zenMode` from store; when true, hides all chrome (header, tabs, toolbar, breadcrumb, status bar, sidebar)
3. Editor goes fullscreen
4. Pressing Esc exits zen mode (keydown listener in `use-zen-mode` hook)
### 4.5 Print preview
1. User triggers `file.print` → opens `PrintPreview` (full-window overlay)
2. The preview renders the current buffer's content as it would appear in print
3. Two buttons: "Print" (calls `ipc.file.print({ html })` which opens native print dialog) and "Close"
### 4.6 Minimap
1. `useSettingsStore.minimap` (existing setting) is read by `CodeMirrorEditor`
2. When true, the editor's extension includes `@replit/codemirror-minimap` (or a simpler custom implementation)
3. The minimap shows a shrunk version of the document on the right side of the editor
4. Toggling the setting adds/removes the extension dynamically
### 4.7 Breadcrumbs-with-symbols
1. The current `Breadcrumb` shows the file path. Extend it to also show markdown symbols (headings, code blocks)
2. Use a simple AST walk of the current buffer to extract heading levels
3. Show as a path-like navigation: `file.md > H1: Title > H2: Section > #line`
4. The `useSettingsStore.breadcrumbSymbols` toggle (default true) controls whether symbols are shown
### 4.8 Git status
1. On sidebar Git tab open, fetch `ipc.file.gitStatus({ rootPath: useFileStore.rootPath })`
2. The main process runs `git status --porcelain` (or equivalent) and returns the list
3. Show file list with status badges (M, A, D, ?)
4. Click a file opens it via `useFileStore.openFile`
5. `git.refresh` re-fetches
---
## 5. Error Handling
- **Word export failures:** `lib/docx-export.ts` catches `docx` lib errors → toast.error
- **Word export missing .dotx:** if custom template path is set but file doesn't exist, show inline banner in the dialog
- **Find-in-files failures:** if `ipc.file.search` throws (regex syntax error, IO error), show inline banner in the dialog
- **REPL:** no IPC, no error handling needed
- **Git status:** if folder isn't a git repo, return empty array. Panel shows "Not a git repository" message
- **Print:** if `ipc.file.print` fails (no printer, user cancels), toast.error or silent close
- **Minimap, Breadcrumbs:** renderer-side, no errors
---
## 6. Testing Strategy
- **Unit tests:** `lib/docx-export.ts` (markdown → docx conversion, ASCII handling), `lib/ascii-table.ts` (already tested)
- **Component tests:** 1-2 smoke tests per new component
- **Integration test:** `phase9-tools-smoke.test.tsx` covering the 6 command-triggered tools (dispatch opens correct modal/overlay)
- **Editor integrations:** minimap toggle, breadcrumbs render with symbols, git status panel render
- **Target: +30-40 new tests**, total ~290
---
## 7. Risks & Open Questions
**Risks:**
- **`docx` lib bundle size (~500KB).** Acceptable for a desktop app but worth noting. Lazy-load if it becomes a concern.
- **Word export custom template parsing** — the `.dotx` format is a zip with XML inside. The `docx` lib may not fully support reading existing templates. v1 may need a simpler "styles only" extraction.
- **Find-in-files regex compilation** — invalid regex would throw. Validate on the renderer before calling IPC.
- **Minimap performance** — for large files, the minimap can slow down scrolling. CodeMirror's built-in minimap has performance options to configure.
**Open questions (deferrable):**
- **REPL persistence** — should the textarea content survive across modal close/reopen? → Decision: NO for v1. Keep simple.
- **Git status auto-refresh** — should it auto-refresh every N seconds? → Decision: NO for v1. Manual via `git.refresh` command.
- **Find-in-files result count limit** — what if there are 10,000 matches? → Decision: limit to 500 results for v1, show "X more matches" message.
---
## 8. Out of Scope (deferred to Phase 10 or later)
- Custom undo/redo (still not in scope)
- Snippet/template library
- Multi-cursor editing
- LSP / language server
- Phase 10 will delete legacy files including the old `src/print-preview.js`, `src/wordTemplateExporter.js`, `src/welcome.js`, `src/zen-mode.js`, etc.
---
## 9. Success Criteria
Phase 9 is complete when:
- All 10 features implemented and accessible via the command store
- `lib/docx-export.ts` generates valid `.docx` files
- Find-in-files returns results from a recursive disk walk
- REPL renders markdown snippet previews
- Zen mode hides all chrome and Esc exits
- Print preview opens native print dialog
- Minimap appears when `useSettingsStore.minimap` is true
- Breadcrumbs show symbols by default
- Git status panel shows modified/added/deleted/untracked files
- ~+30-40 new tests, total ~290
- `npx vite build` succeeds
- Branch tagged `phase-9-advanced-tools` and pushed to origin
@@ -0,0 +1,333 @@
# Phase 10 — Polish + Delete Legacy Design
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phase 10 is sketched at high level; this spec locks architecture, file map, deletion targets, and version strategy.)
**Date:** 2026-06-06
**Phase:** 10 of 10 (React + shadcn/ui UI redesign) — **FINAL**
**Tag (on completion):** `v5.0.0` (the first release tag, all prior phases were `phase-N-*` working tags)
---
## 1. Goal & Non-Goals
**Goal:** Finalize the React UI redesign. Decompose the legacy 146KB `src/main.js` into a feature-first modular structure under `src/main/`, remove the legacy vanilla-JS renderer and all dead IPC bridges, then ship **v5.0.0** with a CHANGELOG.
**Non-goals (Phase 10):**
- Adding new features (Phases 1-9 shipped all of them)
- A full main-process test suite (the existing main process is largely untested; adding tests is Phase 11+)
- Backwards compat shims for the legacy renderer
- Renderer-side refactor (already done)
- Migrating the renderer build pipeline further (vite configs are in place)
**Success criteria:**
- `src/main.js` is gone; `src/main/index.js` is the new entrypoint
- `package.json#main` points to `src/main/index.js`
- All 12 legacy renderer files are deleted
- All 9 dead IPC channels are removed from `preload.js` and `main.js`
- `src/index.html` is reduced to ~50 lines (just the Vite bootstrap)
- `package.json#version` is `5.0.0`
- `CHANGELOG.md` exists in Keep a Changelog 1.1.0 format
- `git grep -E "renderer\.js|command-palette|print-preview|welcome\.js|zen-mode|wordTemplate|ascii-generator|table-generator|styles\.css"` returns zero results
- All 305 tests still pass
- `npx vite build --config vite.renderer.config.ts` succeeds
- `npx electron .` launches and main window renders
- Tag `v5.0.0` pushed to origin
---
## 2. Target Architecture
### 2.1 `src/main/` (feature-first decomposition)
```
src/main/
├── index.js # entrypoint: bootstraps store, app, window, ipc
├── store.js # electron-store wrapper (preferences + wordTemplatePath)
├── ipc.js # ipcMain.handle registration (composes from modules)
├── files/
│ ├── index.js # file ops facade (read/write/list/pickFolder/pickFile)
│ ├── search.js # recursive regex search (used by find-in-files)
│ ├── git.js # git status porcelain parser
│ └── binary.js # writeBuffer helper (Uint8Array → file)
├── menu/
│ ├── index.js # buildMenu() — composes the app menu
│ └── items.js # individual menu items (File, Edit, View, etc.)
├── window/
│ ├── index.js # createMainWindow ONLY (createAsciiWindow/createTableWindow are DEAD)
│ └── state.js # window state persistence
├── word-template/
│ ├── index.js # WordTemplateExporter facade
│ ├── parser.js # .dotx parsing
│ ├── converter.js # markdown → docx
│ └── apply.js # apply template styles to converted docx
└── utils/
├── paths.js # path helpers
├── logger.js # structured logging
└── download.js # tool downloader
```
**Module rules:**
- Each file has one clear responsibility (no god files; CLAUDE.md says >300 lines is the cap)
- `index.js` is the public face of a folder; deeper files are private
- `src/main/index.js` (top-level) wires everything together
- Cross-folder imports go through `index.js`, not directly to internals
### 2.2 Entry point change
**Before:**
```json
// package.json
"main": "src/main.js"
```
**After:**
```json
// package.json
"main": "src/main/index.js"
```
The new `src/main/index.js` runs the same `app.whenReady().then(...)` flow that the current `src/main.js` does, but composes the decomposed modules.
### 2.3 What becomes dead code (removed during decomposition)
When the legacy renderer files are deleted, these main-process functions become orphaned and are removed too:
| Function | File | Why dead |
|---|---|---|
| `createAsciiWindow()` | `src/main/window/index.js` | Loads deleted `src/ascii-generator.html` |
| `createTableWindow()` | `src/main/window/index.js` | Loads deleted `src/table-generator.html` |
| `ipcMain.on('open-ascii-generator', ...)` | `src/main/index.js` (after decomposition) | Replaced by React `<AsciiGeneratorDialog>` |
| `ipcMain.on('open-table-generator', ...)` | `src/main/index.js` (after decomposition) | Replaced by React `<TableGeneratorDialog>` |
| `webContents.send('print-preview')` | `src/main/menu/items.js` | Replaced by React `<PrintPreview>` |
| `webContents.send('print-preview-styled')` | `src/main/menu/items.js` | Replaced by React `<PrintPreview>` |
| `webContents.send('toggle-command-palette')` | `src/main/menu/items.js` | Replaced by `useCommandStore` |
| `require('./wordTemplateExporter')` | `src/main/index.js` (after decomposition) | Replaced by `src/main/word-template/` + renderer-side `lib/docx-export.ts` |
---
## 3. Legacy Deletion Map
### 3.1 Files to delete from `src/` (12 files, ~14,426 lines)
| File | Size | Lines | Replaced by |
|---|---|---|---|
| `src/renderer.js` | 213KB | 5319 | `src/renderer/` (Phases 1-9) |
| `src/styles.css` | 74KB | 3723 | `src/renderer/index.css` + Tailwind |
| `src/styles-modern.css` | 71KB | 2625 | Tailwind + shadcn/ui |
| `src/styles-concreteinfo.css` | 21KB | 969 | Tailwind theme tokens |
| `src/styles-sidebar.css` | 9.7KB | 304 | `<Sidebar>` component |
| `src/styles-zen.css` | 2.1KB | 102 | Zen mode in AppShell |
| `src/styles-welcome.css` | 2.2KB | 24 | Welcome dialog |
| `src/fonts.css` | 1.8KB | (imported elsewhere) | Tailwind font config |
| `src/command-palette.js` | (small) | 109 | `useCommandStore` |
| `src/print-preview.js` | (small) | 138 | `<PrintPreview>` |
| `src/welcome.js` | (small) | 78 | `<Welcome>` modal |
| `src/zen-mode.js` | (small) | 292 | `use-zen-mode` hook + AppShell |
| `src/wordTemplateExporter.js` | (small) | 743 | `src/main/word-template/` + renderer `lib/docx-export.ts` |
| `src/ascii-generator.html` | 34KB | (HTML) | `<AsciiGeneratorDialog>` |
| `src/table-generator.html` | 18KB | (HTML) | `<TableGeneratorDialog>` |
| `src/index.html` | 103KB | 1667 | `src/renderer/index.html` (already exists, Vite root) |
**Total: 13 files = ~16,093 lines / ~548KB**
`src/renderer/index.html` (the live Vite template) is NOT deleted.
### 3.2 Dead IPC channels to remove from `src/preload.js`
- `toggle-command-palette` (line 238)
- `open-ascii-generator` (line 89)
- `open-table-generator` (line 92)
- `print-preview` (line 173)
- `print-preview-styled` (line 174)
- `show-table-generator` (line 180)
- `show-ascii-generator-window` (line 217)
- `show-ascii-generator` (line 218)
- `show-table-generator-window` (line 221)
And from the exposed API surface:
- `openAscii: () => ipcRenderer.send('open-ascii-generator')` (line 445)
- `openTable: () => ipcRenderer.send('open-table-generator')` (line 446)
**9 channel names + 2 API entries to remove.**
### 3.3 Legacy references in `src/index.html` (16 places) — **the LEGACY file at project root, 1667 lines**
- `<link rel="stylesheet" href="styles.css">` (line ~6)
- `<link rel="stylesheet" href="styles-welcome.css">` (line ~7)
- `<div id="print-preview-overlay" class="modal hidden" ...>` block (~10 lines)
- `<div class="command-palette-overlay hidden" id="command-palette-overlay">` block (~5 lines)
- `<script src="renderer.js"></script>` (final script tag)
**All of these are removed by deleting the file `src/index.html` entirely.**
The CURRENT live renderer template is **`src/renderer/index.html`** (already correct: minimal, CSP, Plus Jakarta Sans font, `<script type="module" src="./main.tsx">`). This file is **NOT touched** in Phase 10. Vite's `root` is set to `src/renderer/` in `vite.renderer.config.ts`, so `src/renderer/index.html` is the HTML template Vite uses. The `src/index.html` at the project root is an orphan from the pre-Vite era.
**Verification:** after deleting `src/index.html`, the renderer build still works because Vite doesn't look at the project root — it uses `src/renderer/index.html`.
### 3.4 Main process rewiring
- Remove `require('./wordTemplateExporter')` from main entrypoint
- Remove `webContents.send('print-preview*')` and `webContents.send('toggle-command-palette')` from menu items
- Remove `ipcMain.on('open-ascii-generator', ...)` and `ipcMain.on('open-table-generator', ...)` handlers
- Remove `asciiGeneratorWindow` and `tableGeneratorWindow` global state (and any references to `loadFile(...ascii-generator.html)` / `loadFile(...table-generator.html)`)
---
## 4. `src/index.html` — DELETE (not rewrite)
The current `src/index.html` is 1667 lines of legacy markup and is **DELETED** (not rewritten). The renderer is already correctly served by `src/renderer/index.html` (the Vite root). Deleting the legacy root-level `src/index.html` is the action — no replacement file is needed.
`src/renderer/index.html` (already correct) is NOT modified:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-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:..." rel="stylesheet">
</head>
<body class="font-sans antialiased">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
```
---
## 5. Version Bump + CHANGELOG
### 5.1 `package.json`
```diff
- "version": "4.4.2",
+ "version": "5.0.0",
```
Major version bump because the legacy renderer removal is a breaking change for anyone who had plugins/integrations pointing at `src/renderer.js`.
### 5.2 `CHANGELOG.md` (new file, Keep a Changelog 1.1.0 format)
```markdown
# Changelog
All notable changes to markdown-converter will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.0.0] - 2026-06-06
### Added
- **Complete React 19 + Vite + TypeScript renderer** replacing the legacy vanilla-JS UI
- Native macOS/Windows/Linux menus with command palette and keyboard shortcuts
- Settings sheet (5 tabs: editor, theme, keybindings, advanced, about)
- Modal layer with 13 modal kinds (export PDF/DOCX/HTML/Word, batch, settings, about, welcome, confirm, ASCII gen, table gen, find in files)
- 10 advanced tools: ASCII generator, table generator, Word export, find-in-files, REPL, print preview, zen mode, minimap, breadcrumbs-with-symbols, git status
- Sonner toast notifications at 4 wire points
- 3 mount strategies: ModalLayer dialogs, App.tsx overlays, editor/sidebar integrations
- `ipc.file.writeBuffer` for renderer-side binary output
- `ipc.file.search` (recursive regex), `ipc.file.gitStatus`, `ipc.print.show`, `ipc.app.showSaveDialog`
- 305 unit + integration tests (vitest + React Testing Library)
- Per-package @radix-ui primitives
- shadcn/ui (new-york style) primitives
### Changed
- **BREAKING**: Renderer is now React-only.
- Main process decomposed from 146KB `src/main.js` into feature-first modules under `src/main/`
- `src/index.html` reduced from 1667 to ~50 lines
- IPC contract: handlers throw, `safeCall` catches → `{ ok, error }`
- Settings store: `useSettingsStore` (zustand persist with zod)
- Modal state: `useAppStore.modal: ModalState` discriminated union
### Removed
- `src/renderer.js` (legacy vanilla-JS renderer, 5319 lines) — and `src/index.html` (1667 lines of legacy markup, replaced by `src/renderer/index.html` which is already the live Vite template)
- 7 legacy stylesheets (styles.css, styles-modern.css, styles-concreteinfo.css, styles-sidebar.css, styles-zen.css, styles-welcome.css, fonts.css)
- 5 legacy scripts (command-palette.js, print-preview.js, welcome.js, zen-mode.js, wordTemplateExporter.js)
- 2 legacy HTMLs (ascii-generator.html, table-generator.html)
- 9 dead IPC channels (toggle-command-palette, open-*/show-* ascii/table, print-preview*)
```
---
## 6. Task Decomposition
**10 tasks** (each independently verifiable, one feature per commit, no bundling):
1. **Decompose main.js — `src/main/files/`** (file ops facade, search, git, binary)
2. **Decompose main.js — `src/main/menu/`** (buildMenu + items, with dead sends removed)
3. **Decompose main.js — `src/main/window/`** (createMainWindow ONLY — ascii/table windows removed)
4. **Decompose main.js — `src/main/word-template/`** (WordTemplateExporter split into parser/converter/apply)
5. **Decompose main.js — `src/main/utils/` + `store.js` + `ipc.js` + `index.js`** (glue + new entrypoint)
6. **Trim preload.js** — remove 9 dead IPC channels + 2 exposed API entries
7. **Delete legacy renderer files** (13 files: renderer.js, 7 styles, fonts.css, 4 scripts, 3 htmls)
8. **Verify src/renderer/index.html is the live template** (no action needed if it's already correct)
9. **Bump package.json version to 5.0.0 + write CHANGELOG.md**
10. **Final verification + tag v5.0.0** (build, tests, grep, electron smoke)
Tasks 1-5 are subagent-driven (decomposition is mechanical given a clear target structure). Tasks 6-9 are direct edits. Task 10 is a verification gate.
---
## 7. Verification Strategy
**Per task:**
- After each decomposition step: `npx vitest run` (305 still green)
- After main.js decomposition: `npx electron .` smoke — app starts, opens a file
- After deletions: `git grep -E "renderer\.js|command-palette|print-preview|welcome\.js|zen-mode|wordTemplate|ascii-generator|table-generator|styles\.css"` returns ZERO results
**Final (Task 10):**
- `npx vitest run` — 305 passing
- `npx vite build --config vite.renderer.config.ts` — succeeds
- `npx vite build --config vite.preload.config.ts` — succeeds
- `npx electron .` — app launches, main window renders
- Manual smoke: open a `.md` file, edit it, export to PDF/DOCX, use the command palette
- `git tag -a v5.0.0 -m "..."` and `git push origin v5.0.0`
---
## 8. Risks & Open Questions
**Risks:**
- **Decomposition regressions.** Splitting a 146KB main.js into 7+ files has surface area for missed imports / circular deps. Mitigation: tests stay green; electron smoke after each step.
- **Hidden legacy references.** `git grep` won't catch references inside minified bundles or in node_modules. Mitigation: vite build will fail if there's a dangling import.
- **`src/main/index.js` entrypoint change.** Anything in the build pipeline (electron-builder, scripts) that hardcodes `src/main.js` needs to be updated. Mitigation: grep all of `package.json`, `scripts/`, `vite.*.config.ts` for `main.js`.
**Open questions (resolved during brainstorming):**
- ~~Main process scope~~ → Full rewrite
- ~~Folder shape~~ → Feature-first
- ~~IPC migration~~ → Delete dead channels
- ~~Version strategy~~ → v5.0.0 with Keep a Changelog format
- ~~Order of work~~ → Decompose first, delete last
---
## 9. Out of Scope (deferred to Phase 11+)
- Main process test suite
- Renderer-side further refactor
- New features
- Migration to tauri (separate plan in `docs/plans/2026-03-15-react-tauri-pwa.md`)
---
## 10. Success Criteria
Phase 10 is complete when:
- All 10 tasks are committed, one per commit, no bundling
- `src/main/index.js` is the new entrypoint
- All 12 legacy files are deleted
- All 9 dead IPC channels are removed
- `package.json#version` is `5.0.0`, `package.json#main` is `src/main/index.js`
- `CHANGELOG.md` exists with a complete v5.0.0 entry
- 305 tests still pass
- `npx vite build` succeeds for both renderer and preload configs
- `npx electron .` launches and the app works end-to-end
- `git grep` for legacy references returns zero results
- Tag `v5.0.0` is created and pushed to origin
- Phase 10 is the LAST phase of the React UI redesign
+1 -2
View File
@@ -19,8 +19,7 @@ module.exports = {
// Coverage configuration // Coverage configuration
collectCoverageFrom: [ collectCoverageFrom: [
'src/**/*.js', 'src/**/*.js',
'!src/main.js', // Main process needs electron-mock '!src/main/**', // Main process needs electron-mock
'!src/renderer.js', // Large renderer file with duplicate declarations
'!src/preload.js', // Electron preload requires contextBridge '!src/preload.js', // Electron preload requires contextBridge
'!**/node_modules/**' '!**/node_modules/**'
], ],
+37 -4
View File
@@ -42,11 +42,12 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"core-util-is": "^1.0.3", "core-util-is": "^1.0.3",
"docx": "^9.6.0", "docx": "^9.7.1",
"docx4js": "^2.0.1", "docx4js": "^2.0.1",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"figlet": "^1.11.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"immer": "^11.1.8", "immer": "^11.1.8",
@@ -76,6 +77,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/figlet": "^1.7.0",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -5737,6 +5739,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/figlet": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@types/figlet/-/figlet-1.7.0.tgz",
"integrity": "sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/fs-extra": { "node_modules/@types/fs-extra": {
"version": "9.0.13", "version": "9.0.13",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
@@ -9161,9 +9170,9 @@
} }
}, },
"node_modules/docx": { "node_modules/docx": {
"version": "9.6.1", "version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
@@ -10241,6 +10250,30 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/figlet": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/figlet/-/figlet-1.11.0.tgz",
"integrity": "sha512-EEx3OS/l2bFqcUNN2NM9FPJp8vAMrgbCxsbl2hbcJNNxOEwVe3mEzrhan7TbJQViZa8mMqhihlbCaqD+LyYKTQ==",
"license": "MIT",
"dependencies": {
"commander": "^14.0.0"
},
"bin": {
"figlet": "bin/index.js"
},
"engines": {
"node": ">= 17.0.0"
}
},
"node_modules/figlet/node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+5 -3
View File
@@ -1,8 +1,8 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.4.2", "version": "5.0.0",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main/index.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"test": "jest", "test": "jest",
@@ -51,6 +51,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/figlet": "^1.7.0",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -113,11 +114,12 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"core-util-is": "^1.0.3", "core-util-is": "^1.0.3",
"docx": "^9.6.0", "docx": "^9.7.1",
"docx4js": "^2.0.1", "docx4js": "^2.0.1",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"figlet": "^1.11.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"immer": "^11.1.8", "immer": "^11.1.8",
-601
View File
@@ -1,601 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
<style>
:root {
--ci-dark-gray: #464646;
--ci-medium-gray: #9a9696;
--ci-accent: #e5461f;
--ci-light-gray: #e3e3e3;
--ci-black: #0d0b09;
--ci-white: #ffffff;
--ci-bg: #f5f5f5;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--ci-bg);
color: var(--ci-dark-gray);
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
padding: 16px 24px;
border-bottom: 3px solid var(--ci-accent);
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
color: var(--ci-white);
font-size: 1.25rem;
font-weight: 600;
}
.container {
padding: 24px;
max-width: 100%;
}
.section {
background: var(--ci-white);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.section-title {
font-weight: 600;
margin-bottom: 16px;
color: var(--ci-dark-gray);
font-size: 0.95rem;
}
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.mode-tab {
padding: 10px 20px;
border: 2px solid var(--ci-light-gray);
background: var(--ci-white);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.mode-tab:hover {
border-color: var(--ci-accent);
}
.mode-tab.active {
background: var(--ci-accent);
border-color: var(--ci-accent);
color: var(--ci-white);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--ci-dark-gray);
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid var(--ci-light-gray);
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--ci-accent);
}
.form-textarea {
min-height: 80px;
resize: vertical;
}
.preview-container {
background: var(--ci-black);
border-radius: 8px;
padding: 16px;
min-height: 200px;
max-height: 350px;
overflow: auto;
}
.preview-content {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.3;
color: #00ff00;
white-space: pre;
}
.template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.template-btn {
padding: 8px 12px;
border: 2px solid var(--ci-light-gray);
background: var(--ci-white);
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.template-btn:hover {
border-color: var(--ci-accent);
background: rgba(229, 70, 31, 0.05);
}
.template-btn.active {
border-color: var(--ci-accent);
background: rgba(229, 70, 31, 0.1);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
background: var(--ci-white);
border-top: 1px solid var(--ci-light-gray);
position: sticky;
bottom: 0;
}
.btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: var(--ci-accent);
color: var(--ci-white);
}
.btn-primary:hover {
background: #c93a18;
transform: translateY(-1px);
}
.btn-secondary {
background: var(--ci-light-gray);
color: var(--ci-dark-gray);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.mode-section {
display: none;
}
.mode-section.active {
display: block;
}
.template-category {
margin-bottom: 16px;
}
.template-category-title {
font-size: 0.8rem;
color: var(--ci-medium-gray);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div class="header">
<h1>ASCII Art Generator</h1>
</div>
<div class="container">
<div class="section">
<div class="mode-tabs">
<button class="mode-tab active" data-mode="text">Text Banner</button>
<button class="mode-tab" data-mode="box">Box/Frame</button>
<button class="mode-tab" data-mode="templates">Templates</button>
</div>
<!-- Text Banner Mode -->
<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">
</div>
<div class="form-group">
<label class="form-label">Style</label>
<select id="font-style" class="form-select">
<option value="standard">Standard</option>
<option value="banner">Banner</option>
<option value="block">Block</option>
<option value="bubble">Bubble</option>
<option value="digital">Digital</option>
</select>
</div>
</div>
<!-- Box/Frame Mode -->
<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>
</div>
<div class="form-group">
<label class="form-label">Box Style</label>
<select id="box-style" class="form-select">
<option value="single">Single Line</option>
<option value="double">Double Line</option>
<option value="rounded">Rounded</option>
<option value="bold">Bold</option>
<option value="ascii">ASCII (+|-)</option>
</select>
</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;">
</div>
</div>
<!-- Templates Mode -->
<div id="templates-mode" class="mode-section">
<div class="template-category">
<div class="template-category-title">Arrows & Flow</div>
<div class="template-grid">
<button class="template-btn" data-template="arrow-right">Arrow Right</button>
<button class="template-btn" data-template="arrow-down">Arrow Down</button>
<button class="template-btn" data-template="decision">Decision</button>
<button class="template-btn" data-template="process">Process Flow</button>
</div>
</div>
<div class="template-category">
<div class="template-category-title">Diagrams</div>
<div class="template-grid">
<button class="template-btn" data-template="flowchart">Flowchart</button>
<button class="template-btn" data-template="sequence">Sequence</button>
<button class="template-btn" data-template="network">Network</button>
<button class="template-btn" data-template="hierarchy">Hierarchy</button>
</div>
</div>
<div class="template-category">
<div class="template-category-title">Boxes & Containers</div>
<div class="template-grid">
<button class="template-btn" data-template="header">Header</button>
<button class="template-btn" data-template="note">Note Box</button>
<button class="template-btn" data-template="warning">Warning</button>
<button class="template-btn" data-template="info">Info Box</button>
</div>
</div>
<div class="template-category">
<div class="template-category-title">Decorative</div>
<div class="template-grid">
<button class="template-btn" data-template="divider">Divider</button>
<button class="template-btn" data-template="separator">Separator</button>
<button class="template-btn" data-template="banner">Banner</button>
<button class="template-btn" data-template="checklist">Checklist</button>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Preview</div>
<div class="preview-container">
<div id="preview" class="preview-content"></div>
</div>
</div>
</div>
<div class="footer">
<button class="btn btn-secondary" id="btn-generate">Generate Preview</button>
<button class="btn btn-primary" id="btn-insert">Insert to Editor</button>
</div>
<script>
// ASCII Art Generator Logic
const FONTS = {
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': ['-----', ' / ', ' / ', ' / ', '-----'],
' ': [' ', ' ', ' ', ' ', ' '],
'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': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
'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': ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
' ': [' ', ' ', ' ', ' ', ' ', ' ']
}
}
};
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'
};
const BOX_STYLES = {
single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
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: '|' }
};
let currentMode = 'text';
let currentTemplate = null;
// Mode switching
document.querySelectorAll('.mode-tab').forEach(tab => {
tab.addEventListener('click', () => {
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.getElementById(currentMode + '-mode').classList.add('active');
generatePreview();
});
});
// Template selection
document.querySelectorAll('.template-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTemplate = btn.dataset.template;
generatePreview();
});
});
// Generate text banner
function generateTextBanner(text, style) {
const font = FONTS[style] || FONTS.standard;
const lines = Array(font.height).fill('');
for (const char of text.toUpperCase()) {
const charArt = font.chars[char] || font.chars[' '];
if (charArt) {
for (let i = 0; i < font.height; i++) {
lines[i] += charArt[i] + ' ';
}
}
}
return lines.join('\n');
}
// Generate box
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;
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
// Add padding lines at top
for (let i = 0; i < Math.floor(padding / 2); i++) {
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
}
// Add text lines
for (const line of lines) {
const paddedLine = ' '.repeat(padding) + line.padEnd(maxLen - padding) + ' ';
result += box.v + ' ' + paddedLine + box.v + '\n';
}
// Add padding lines at bottom
for (let i = 0; i < Math.floor(padding / 2); i++) {
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
}
result += box.bl + box.h.repeat(maxLen + 2) + box.br;
return result;
}
// Generate preview
function generatePreview() {
let result = '';
if (currentMode === 'text') {
const text = document.getElementById('text-input').value || 'HELLO';
const style = document.getElementById('font-style').value;
result = generateTextBanner(text, style);
} else if (currentMode === 'box') {
const text = document.getElementById('box-text').value || 'Your text here';
const style = document.getElementById('box-style').value;
const padding = parseInt(document.getElementById('box-padding').value) || 2;
result = generateBox(text, style, padding);
} else if (currentMode === 'templates') {
result = currentTemplate ? TEMPLATES[currentTemplate] : 'Select a template...';
}
document.getElementById('preview').textContent = result;
}
// Event listeners for live preview
document.getElementById('text-input').addEventListener('input', generatePreview);
document.getElementById('font-style').addEventListener('change', generatePreview);
document.getElementById('box-text').addEventListener('input', generatePreview);
document.getElementById('box-style').addEventListener('change', generatePreview);
document.getElementById('box-padding').addEventListener('input', generatePreview);
document.getElementById('btn-generate').addEventListener('click', generatePreview);
document.getElementById('btn-insert').addEventListener('click', () => {
const content = document.getElementById('preview').textContent;
if (content && window.electronAPI) {
// Wrap in code block for markdown
const wrapped = '```\n' + content + '\n```';
window.electronAPI.send('insert-generated-content', wrapped);
window.close();
}
});
// Initial preview
generatePreview();
</script>
</body>
</html>
-109
View File
@@ -1,109 +0,0 @@
class CommandPalette {
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.results = document.getElementById('command-palette-results');
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
this.setupEventListeners();
}
register(label, shortcut, action) {
this.commands.push({ label, shortcut, action });
}
open() {
this.overlay.classList.remove('hidden');
this.input.value = '';
this.input.focus();
this.selectedIndex = 0;
this.renderResults('');
}
close() {
this.overlay.classList.add('hidden');
}
isOpen() {
return !this.overlay.classList.contains('hidden');
}
setupEventListeners() {
this.input.addEventListener('input', () => {
this.selectedIndex = 0;
this.renderResults(this.input.value);
});
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
}
});
}
renderResults(query) {
this.filteredCommands = query
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
: [...this.commands];
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('');
this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
this.filteredCommands[idx].action();
this.close();
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index);
this.updateSelection();
});
});
}
highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<strong>$1</strong>');
}
updateSelection() {
this.results.querySelectorAll('.command-item').forEach((el, i) => {
el.classList.toggle('selected', i === this.selectedIndex);
});
// Scroll selected into view
const selected = this.results.querySelector('.command-item.selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}
executeSelected() {
if (this.filteredCommands[this.selectedIndex]) {
this.filteredCommands[this.selectedIndex].action();
this.close();
}
}
}
module.exports = { CommandPalette };
-75
View File
@@ -1,75 +0,0 @@
/* Local Font Definitions for MarkdownConverter */
/* Inter Font Family */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono Font Family - For code, markdown editor, and ASCII art */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
-1667
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
// src/main/files/binary.js
// Binary file write handler (used by Word .docx export)
const { ipcMain } = require('electron');
const fs = require('fs').promises;
function register() {
ipcMain.handle('write-buffer', async (_event, { path: filePath, buffer }) => {
await fs.writeFile(filePath, Buffer.from(buffer));
return { ok: true };
});
}
module.exports = { register };
+28
View File
@@ -0,0 +1,28 @@
// src/main/files/git.js
// Git IPC handlers — thin wrapper over GitOperations
const { ipcMain } = require('electron');
const GitOperations = require('../GitOperations');
function register(currentFileRef) {
ipcMain.handle('git-status', async () => {
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
return GitOperations.getStatus(dir);
});
ipcMain.handle('git-stage', async (_event, { files }) => {
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
return GitOperations.stage(dir, files);
});
ipcMain.handle('git-commit', async (_event, { message }) => {
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
return GitOperations.commit(dir, message);
});
ipcMain.handle('git-log', async () => {
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
return GitOperations.log(dir);
});
}
module.exports = { register };
+157
View File
@@ -0,0 +1,157 @@
// src/main/files/index.js
// File ops facade — registers all file-related IPC handlers
const { ipcMain, dialog, shell } = require('electron');
const fs = require('fs');
const path = require('path');
const { register: registerGit } = require('./git');
const { register: registerBinary } = require('./binary');
function register({ validatePath, resolveWritablePath, isPathAccessible, currentFileRef, mainWindow }) {
// pick-folder
ipcMain.handle('pick-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
// pick-file
ipcMain.handle('pick-file', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
// read-file
ipcMain.handle('read-file', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
throw new Error(validation.error || 'Invalid file path');
}
return fs.readFileSync(validation.resolved, 'utf-8');
});
// write-file
ipcMain.handle('write-file', async (event, payload) => {
const validation = resolveWritablePath(payload?.path);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid file path');
}
fs.mkdirSync(path.dirname(validation.resolved), { recursive: true });
fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8');
return { path: validation.resolved };
});
// delete-file
ipcMain.handle('delete-file', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
throw new Error(validation.error || 'Invalid file path');
}
fs.rmSync(validation.resolved, { recursive: true, force: false });
return true;
});
// ensure-directory
ipcMain.handle('ensure-directory', async (event, dirPath) => {
const validation = resolveWritablePath(dirPath);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid directory path');
}
fs.mkdirSync(validation.resolved, { recursive: true });
return validation.resolved;
});
// path-exists
ipcMain.handle('path-exists', async (event, filePath) => {
const validation = resolveWritablePath(filePath);
return validation.valid ? fs.existsSync(validation.resolved) : false;
});
// is-directory
ipcMain.handle('is-directory', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
return false;
}
return fs.statSync(validation.resolved).isDirectory();
});
// copy-path
ipcMain.handle('copy-path', async (event, payload) => {
const sourceValidation = validatePath(payload?.source);
const destinationValidation = resolveWritablePath(payload?.destination);
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
throw new Error(sourceValidation.error || 'Invalid source path');
}
if (!destinationValidation.valid) {
throw new Error(destinationValidation.error || 'Invalid destination path');
}
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
});
// move-path
ipcMain.handle('move-path', async (event, payload) => {
const sourceValidation = validatePath(payload?.source);
const destinationValidation = resolveWritablePath(payload?.destination);
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
throw new Error(sourceValidation.error || 'Invalid source path');
}
if (!destinationValidation.valid) {
throw new Error(destinationValidation.error || 'Invalid destination path');
}
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
try {
fs.renameSync(sourceValidation.resolved, destinationValidation.resolved);
} catch (error) {
if (error.code !== 'EXDEV') {
throw error;
}
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
fs.rmSync(sourceValidation.resolved, { recursive: true, force: false });
}
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
});
// open-file-path
ipcMain.on('open-file-path', (event, filePath) => {
try {
const validation = validatePath(filePath);
if (!validation.valid) {
console.error('[SECURITY] Invalid file path:', validation.error);
return;
}
if (!isPathAccessible(validation.resolved)) {
return;
}
const stat = fs.statSync(validation.resolved);
if (stat.size > 50 * 1024 * 1024) return;
const content = fs.readFileSync(validation.resolved, 'utf-8');
// Emit set-current-file back to main process so it updates its currentFile variable
mainWindow.webContents.send('set-current-file', validation.resolved);
mainWindow.webContents.send('file-opened', { path: validation.resolved, content });
} catch (err) {
console.error('open-file-path error:', err);
}
});
// Register sub-modules
registerGit(currentFileRef);
registerBinary();
}
module.exports = { register };
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
// src/main/menu/index.js
// buildMenu() composes the app menu from items; register() sets it on the app
const { Menu } = require('electron');
const {
fileItems,
editItems,
viewItems,
batchItems,
convertItems,
pdfEditorItems,
toolsItems,
helpItems
} = require('./items');
function buildMenu(mainWindow) {
const template = [
{ label: '&File', submenu: fileItems(mainWindow) },
{ label: '&Edit', submenu: editItems(mainWindow) },
{ label: '&View', submenu: viewItems(mainWindow) },
{ label: '&Batch', submenu: batchItems(mainWindow) },
{ label: '&Convert', submenu: convertItems(mainWindow) },
{ label: 'PDF Editor', submenu: pdfEditorItems(mainWindow) },
{ label: '&Tools', submenu: toolsItems(mainWindow) },
{ label: '&Help', submenu: helpItems(mainWindow) }
];
return Menu.buildFromTemplate(template);
}
function register(mainWindow) {
Menu.setApplicationMenu(buildMenu(mainWindow));
}
module.exports = { register, buildMenu };
+522
View File
@@ -0,0 +1,522 @@
// src/main/menu/items.js
// Individual menu items — pure functions that take (mainWindow) and return menu item arrays
const { app, dialog, shell } = require('electron');
const path = require('path');
const fs = require('fs');
// Helper: build recent files submenu
function buildRecentFilesMenu(mainWindow) {
try {
const recentFilesPath = path.join(app.getPath('userData'), 'recent-files.json');
if (!fs.existsSync(recentFilesPath)) return [{ label: 'No Recent Files', enabled: false }];
const recentFiles = JSON.parse(fs.readFileSync(recentFilesPath, 'utf-8'));
const existing = recentFiles.filter(file => fs.existsSync(file));
if (existing.length === 0) return [{ label: 'No Recent Files', enabled: false }];
const items = existing.map(file => ({
label: path.basename(file),
click: () => {
const { openFileFromPath } = require('../main');
openFileFromPath(file);
}
}));
items.push(
{ type: 'separator' },
{
label: 'Clear Recent Files',
click: () => {
if (mainWindow) {
mainWindow.webContents.send('clear-recent-files');
}
}
}
);
return items;
} catch (e) {
return [{ label: 'No Recent Files', enabled: false }];
}
}
function fileItems(mainWindow) {
return [
{
label: 'New',
accelerator: 'CmdOrCtrl+N',
click: () => mainWindow.webContents.send('file-new')
},
{
label: 'Open',
accelerator: 'CmdOrCtrl+O',
click: () => {
const { openFile } = require('../main');
openFile();
}
},
{
label: 'Open PDF',
accelerator: 'CmdOrCtrl+Shift+O',
click: () => {
const { openPdfFile } = require('../main');
openPdfFile();
}
},
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => mainWindow.webContents.send('file-save')
},
{
label: 'Save As',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => {
const { saveAsFile } = require('../main');
saveAsFile();
}
},
{ type: 'separator' },
// NOTE: Print Preview submenu removed — handled by React <PrintPreview> overlay
{ type: 'separator' },
{
label: 'Recent Files',
submenu: buildRecentFilesMenu(mainWindow)
},
{ type: 'separator' },
{
label: 'New from Template',
submenu: [
{ label: 'Blog Post', click: () => mainWindow.webContents.send('load-template-menu', 'blog-post.md') },
{ label: 'Meeting Notes', click: () => mainWindow.webContents.send('load-template-menu', 'meeting-notes.md') },
{ label: 'Technical Spec', click: () => mainWindow.webContents.send('load-template-menu', 'technical-spec.md') },
{ label: 'Changelog', click: () => mainWindow.webContents.send('load-template-menu', 'changelog.md') },
{ label: 'README', click: () => mainWindow.webContents.send('load-template-menu', 'readme.md') },
{ label: 'Project Plan', click: () => mainWindow.webContents.send('load-template-menu', 'project-plan.md') },
{ label: 'API Documentation', click: () => mainWindow.webContents.send('load-template-menu', 'api-docs.md') },
{ label: 'Tutorial', click: () => mainWindow.webContents.send('load-template-menu', 'tutorial.md') },
{ label: 'Release Notes', click: () => mainWindow.webContents.send('load-template-menu', 'release-notes.md') },
{ label: 'Comparison', click: () => mainWindow.webContents.send('load-template-menu', 'comparison.md') }
]
},
{ type: 'separator' },
{
label: 'Import Document...',
accelerator: 'CmdOrCtrl+I',
click: () => {
const { importDocument } = require('../main');
importDocument();
}
},
{
label: 'Export',
submenu: [
{
label: 'HTML', click: () => {
const { exportFile } = require('../main');
exportFile('html');
}
},
{
label: 'PDF', click: () => {
const { exportFile } = require('../main');
exportFile('pdf');
}
},
{
label: 'PDF (Enhanced)', click: () => {
const { exportPDFViaWordTemplate } = require('../main');
exportPDFViaWordTemplate();
}, accelerator: 'Ctrl+Shift+P'
},
{
label: 'DOCX', click: () => {
const { exportFile } = require('../main');
exportFile('docx');
}
},
{
label: 'DOCX (Enhanced)', click: () => {
const { exportWordWithTemplate } = require('../main');
exportWordWithTemplate();
}, accelerator: 'Ctrl+Shift+W'
},
{ label: 'LaTeX', click: () => { const { exportFile } = require('../main'); exportFile('latex'); } },
{ label: 'RTF', click: () => { const { exportFile } = require('../main'); exportFile('rtf'); } },
{ label: 'ODT', click: () => { const { exportFile } = require('../main'); exportFile('odt'); } },
{ label: 'EPUB', click: () => { const { exportFile } = require('../main'); exportFile('epub'); } },
{ type: 'separator' },
{ label: 'PowerPoint (PPTX)', click: () => { const { exportFile } = require('../main'); exportFile('pptx'); } },
{ label: 'OpenDocument Presentation (ODP)', click: () => { const { exportFile } = require('../main'); exportFile('odp'); } },
{ type: 'separator' },
{ label: 'CSV (Tables)', click: () => { const { exportSpreadsheet } = require('../main'); exportSpreadsheet('csv'); } },
{ type: 'separator' },
{ label: 'JSON (.json)', click: () => { const { exportFile } = require('../main'); exportFile('json'); } },
{ label: 'YAML (.yaml)', click: () => { const { exportFile } = require('../main'); exportFile('yaml'); } },
{ label: 'XML (.xml)', click: () => { const { exportFile } = require('../main'); exportFile('xml'); } },
{ type: 'separator' },
{ label: 'Confluence Wiki (.txt)', click: () => { const { exportFile } = require('../main'); exportFile('confluence'); } },
{ label: 'MOBI E-book (.mobi)', click: () => { const { exportFile } = require('../main'); exportFile('mobi'); } }
]
},
{ type: 'separator' },
{
label: 'Select Word Template...',
click: () => {
const { selectWordTemplate } = require('../main');
selectWordTemplate();
}
},
{
label: 'Template Settings...',
click: () => {
const { showTemplateSettings } = require('../main');
showTemplateSettings();
}
},
{
label: 'Header & Footer Settings...',
click: () => {
if (mainWindow) {
mainWindow.webContents.send('open-header-footer-dialog');
}
}
},
{ type: 'separator' },
{
label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => app.quit()
}
];
}
function editItems(mainWindow) {
return [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
click: () => mainWindow.webContents.send('undo')
},
{
label: 'Redo',
accelerator: 'CmdOrCtrl+Shift+Z',
click: () => mainWindow.webContents.send('redo')
},
{ type: 'separator' },
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
{ type: 'separator' },
{
label: 'Find & Replace',
accelerator: 'CmdOrCtrl+F',
click: () => mainWindow.webContents.send('toggle-find')
}
];
}
function viewItems(mainWindow) {
return [
{
label: 'Toggle Preview',
accelerator: 'CmdOrCtrl+Shift+V',
click: () => mainWindow.webContents.send('toggle-preview')
},
// NOTE: Command Palette removed — handled by useCommandStore
{ type: 'separator' },
{
label: 'Sidebar',
submenu: [
{ label: 'File Explorer', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') },
{ label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
{ label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') }
]
},
{
label: 'Bottom Panel (REPL)',
click: () => mainWindow.webContents.send('toggle-bottom-panel')
},
{ type: 'separator' },
{
label: 'Theme',
submenu: [
{ label: 'Atom One Light (Default)', click: () => { const { setTheme } = require('../main'); setTheme('atomonelight'); } },
{ label: 'GitHub Light', click: () => { const { setTheme } = require('../main'); setTheme('github'); } },
{ label: 'Light', click: () => { const { setTheme } = require('../main'); setTheme('light'); } },
{ label: 'Solarized Light', click: () => { const { setTheme } = require('../main'); setTheme('solarized'); } },
{ label: 'Gruvbox Light', click: () => { const { setTheme } = require('../main'); setTheme('gruvbox-light'); } },
{ label: 'Ayu Light', click: () => { const { setTheme } = require('../main'); setTheme('ayu-light'); } },
{ label: 'Sepia', click: () => { const { setTheme } = require('../main'); setTheme('sepia'); } },
{ label: 'Paper', click: () => { const { setTheme } = require('../main'); setTheme('paper'); } },
{ label: 'Rose Pine Dawn', click: () => { const { setTheme } = require('../main'); setTheme('rosepine-dawn'); } },
{ label: 'Concrete Light', click: () => { const { setTheme } = require('../main'); setTheme('concrete-light'); } },
{ type: 'separator' },
{ label: 'Dark', click: () => { const { setTheme } = require('../main'); setTheme('dark'); } },
{ label: 'One Dark', click: () => { const { setTheme } = require('../main'); setTheme('onedark'); } },
{ label: 'Dracula', click: () => { const { setTheme } = require('../main'); setTheme('dracula'); } },
{ label: 'Nord', click: () => { const { setTheme } = require('../main'); setTheme('nord'); } },
{ label: 'Monokai', click: () => { const { setTheme } = require('../main'); setTheme('monokai'); } },
{ label: 'Material', click: () => { const { setTheme } = require('../main'); setTheme('material'); } },
{ label: 'Gruvbox Dark', click: () => { const { setTheme } = require('../main'); setTheme('gruvbox-dark'); } },
{ label: 'Tokyo Night', click: () => { const { setTheme } = require('../main'); setTheme('tokyonight'); } },
{ label: 'Palenight', click: () => { const { setTheme } = require('../main'); setTheme('palenight'); } },
{ label: 'Ayu Dark', click: () => { const { setTheme } = require('../main'); setTheme('ayu-dark'); } },
{ label: 'Ayu Mirage', click: () => { const { setTheme } = require('../main'); setTheme('ayu-mirage'); } },
{ label: 'Oceanic Next', click: () => { const { setTheme } = require('../main'); setTheme('oceanic-next'); } },
{ label: 'Cobalt2', click: () => { const { setTheme } = require('../main'); setTheme('cobalt2'); } },
{ label: 'Concrete Dark', click: () => { const { setTheme } = require('../main'); setTheme('concrete-dark'); } },
{ label: 'Concrete Warm', click: () => { const { setTheme } = require('../main'); setTheme('concrete-warm'); } }
]
},
{ type: 'separator' },
{
label: 'Font Size',
submenu: [
{
label: 'Increase Font Size',
accelerator: 'CmdOrCtrl+Shift+Plus',
click: () => mainWindow.webContents.send('adjust-font-size', 'increase')
},
{
label: 'Decrease Font Size',
accelerator: 'CmdOrCtrl+Shift+-',
click: () => mainWindow.webContents.send('adjust-font-size', 'decrease')
},
{
label: 'Reset Font Size',
accelerator: 'CmdOrCtrl+Shift+0',
click: () => mainWindow.webContents.send('adjust-font-size', 'reset')
}
]
},
{ type: 'separator' },
{
label: 'Spell Check',
type: 'checkbox',
checked: true,
click: (menuItem) => {
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
}
},
{ type: 'separator' },
{
label: 'Custom Preview CSS',
submenu: [
{
label: 'Load Custom Preview CSS...',
click: () => mainWindow.webContents.send('load-custom-css')
},
{
label: 'Clear Custom Preview CSS',
click: () => mainWindow.webContents.send('clear-custom-css')
}
]
},
{ type: 'separator' },
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
{ type: 'separator' },
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' }
];
}
function batchItems(mainWindow) {
return [
{
label: 'Convert Markdown Folder...',
click: () => {
const { showBatchConversionDialog } = require('../main');
showBatchConversionDialog();
}
},
{ type: 'separator' },
{
label: 'Batch Image Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'image')
},
{
label: 'Batch Audio Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'audio')
},
{
label: 'Batch Video Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'video')
},
{
label: 'Batch PDF Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'pdf')
}
];
}
function convertItems(mainWindow) {
return [
{
label: 'Universal File Converter...',
accelerator: 'CmdOrCtrl+Shift+C',
click: () => {
const { showUniversalConverterDialog } = require('../main');
showUniversalConverterDialog();
}
}
];
}
function pdfEditorItems(mainWindow) {
return [
{
label: 'Open PDF File...',
accelerator: 'CmdOrCtrl+Shift+O',
click: () => {
const { openPdfFile } = require('../main');
openPdfFile();
}
},
{ type: 'separator' },
{
label: 'Merge PDFs...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('merge');
}
},
{
label: 'Split PDF...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('split');
}
},
{
label: 'Compress PDF...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('compress');
}
},
{ type: 'separator' },
{
label: 'Rotate Pages...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('rotate');
}
},
{
label: 'Delete Pages...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('delete');
}
},
{
label: 'Reorder Pages...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('reorder');
}
},
{ type: 'separator' },
{
label: 'Add Watermark...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('watermark');
}
},
{ type: 'separator' },
{
label: 'Security',
submenu: [
{
label: 'Add Password Protection...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('encrypt');
}
},
{
label: 'Remove Password...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('decrypt');
}
},
{
label: 'Set Permissions...',
click: () => {
const { showPDFEditorDialog } = require('../main');
showPDFEditorDialog('permissions');
}
}
]
},
{ type: 'separator' },
{
label: 'About PDF Editor',
click: () => {
const { showAboutDialog } = require('../main');
showAboutDialog();
}
}
];
}
function toolsItems(mainWindow) {
// NOTE: Table Generator and ASCII Art Generator removed — handled by React dialogs
return [
// Removed: Table Generator (Cmd+Ctrl+Shift+T) — now React <TableGeneratorDialog>
// Removed: ASCII Art Generator (Cmd+Ctrl+Shift+A) — now React <AsciiGeneratorDialog>
{ type: 'separator' },
{
label: 'Document Compare',
click: () => mainWindow.webContents.send('show-document-compare')
}
];
}
function helpItems(mainWindow) {
return [
{
label: 'About MarkdownConverter',
click: () => {
const { showAboutDialog } = require('../main');
showAboutDialog();
}
},
{ type: 'separator' },
{
label: 'Dependencies & Requirements',
click: () => {
const { showDependenciesDialog } = require('../main');
showDependenciesDialog();
}
},
{ type: 'separator' },
{
label: 'Documentation',
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter')
},
{
label: 'Report Issue',
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues')
},
{
label: 'Check for Updates',
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases')
}
];
}
module.exports = {
fileItems,
editItems,
viewItems,
batchItems,
convertItems,
pdfEditorItems,
toolsItems,
helpItems
};
+31
View File
@@ -0,0 +1,31 @@
// src/main/store.js
// Simple JSON-file preferences store (replaces electron-store)
const { app } = require('electron');
const path = require('path');
const fs = require('fs');
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
const store = {
get(key, defaultValue) {
try {
const data = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(data);
return settings[key] !== undefined ? settings[key] : defaultValue;
} catch {
return defaultValue;
}
},
set(key, value) {
let settings = {};
try {
const data = fs.readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(data);
} catch {}
settings[key] = value;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
}
};
module.exports = store;
+107
View File
@@ -0,0 +1,107 @@
// src/main/utils/paths.js
// Path helpers — extracted from src/main.js lines 109-207
const { app } = require('electron');
const path = require('path');
const fs = require('fs');
function getAllowedDirectories() {
const dirs = [
app.getPath('documents'),
app.getPath('desktop'),
app.getPath('downloads'),
app.getPath('home'),
process.cwd() // Current working directory
].filter(Boolean); // Remove any undefined paths
return dirs;
}
/**
* Validates that a file path is safe and doesn't attempt path traversal
* @param {string} filePath - The path to validate
* @returns {{ valid: boolean, resolved: string, error?: string }}
*/
function validatePath(filePath) {
if (!filePath || typeof filePath !== 'string') {
return { valid: false, resolved: '', error: 'Invalid path' };
}
// Resolve to absolute path (handles .., ., symlinks)
let resolved;
try {
resolved = path.resolve(filePath);
} catch (err) {
return { valid: false, resolved: '', error: 'Invalid path format' };
}
// Normalize path separators
resolved = path.normalize(resolved);
// Check for null bytes (path injection)
if (resolved.includes('\0')) {
return { valid: false, resolved: '', error: 'Null byte in path' };
}
// Check if path exists
if (!fs.existsSync(resolved)) {
return { valid: false, resolved, error: 'Path does not exist' };
}
return { valid: true, resolved };
}
/**
* Resolves a path for operations where the target may not exist yet.
* Validates string shape and blocks obviously sensitive locations.
* @param {string} filePath
* @returns {{ valid: boolean, resolved: string, error?: string }}
*/
function resolveWritablePath(filePath) {
if (!filePath || typeof filePath !== 'string') {
return { valid: false, resolved: '', error: 'Invalid path' };
}
let resolved;
try {
resolved = path.normalize(path.resolve(filePath));
} catch (err) {
return { valid: false, resolved: '', error: 'Invalid path format' };
}
if (resolved.includes('\0')) {
return { valid: false, resolved: '', error: 'Null byte in path' };
}
if (!isPathAccessible(resolved)) {
return { valid: false, resolved, error: 'Path is not accessible' };
}
return { valid: true, resolved };
}
/**
* Checks if a resolved path is within allowed directories
* For an editor app, we allow access to all user-accessible paths
* but log any suspicious access attempts
* @param {string} resolvedPath - The resolved absolute path
* @returns {boolean}
*/
function isPathAccessible(resolvedPath) {
// Block access to sensitive system directories
const blockedPaths = [
'/etc/passwd', '/etc/shadow', '/root',
'C:\\Windows\\System32', 'C:\\Windows\\System',
'/System', '/private/etc'
];
const normalizedPath = resolvedPath.toLowerCase();
for (const blocked of blockedPaths) {
if (normalizedPath.startsWith(blocked.toLowerCase())) {
console.warn('[SECURITY] Blocked access to sensitive path:', resolvedPath);
return false;
}
}
return true;
}
module.exports = { getAllowedDirectories, validatePath, resolveWritablePath, isPathAccessible };
+80
View File
@@ -0,0 +1,80 @@
// src/main/window/index.js
// Main window creation
const { BrowserWindow } = require('electron');
const path = require('path');
const state = require('./state');
const menu = require('../menu');
function createMainWindow() {
const bounds = state.load();
const win = new BrowserWindow({
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
spellcheck: true
},
icon: path.join(__dirname, '../../assets/icon.png')
});
win.loadFile(path.join(__dirname, '../../renderer/index.html'));
// Show window only after content is ready — avoids blank flash
win.once('ready-to-show', () => {
win.show();
});
menu.register(win);
win.on('closed', () => {
state.save(win);
});
// Spell check context menu
win.webContents.on('context-menu', (event, params) => {
const { Menu, MenuItem } = require('electron');
const ctxMenu = new Menu();
// Add spell check suggestions
if (params.misspelledWord) {
for (const suggestion of params.dictionarySuggestions) {
ctxMenu.append(new MenuItem({
label: suggestion,
click: () => win.webContents.replaceMisspelling(suggestion)
}));
}
if (params.dictionarySuggestions.length > 0) {
ctxMenu.append(new MenuItem({ type: 'separator' }));
}
ctxMenu.append(new MenuItem({
label: 'Add to Dictionary',
click: () => win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
}));
ctxMenu.append(new MenuItem({ type: 'separator' }));
}
// Standard context menu items
ctxMenu.append(new MenuItem({ role: 'cut' }));
ctxMenu.append(new MenuItem({ role: 'copy' }));
ctxMenu.append(new MenuItem({ role: 'paste' }));
ctxMenu.append(new MenuItem({ role: 'selectAll' }));
ctxMenu.popup();
});
// Wait for the page to fully load before sending file data
win.webContents.on('did-finish-load', () => {
console.log('Window finished loading');
// Don't open file here - wait for renderer-ready signal
// The renderer will send renderer-ready when TabManager is initialized
});
return win;
}
module.exports = { createMainWindow };
+27
View File
@@ -0,0 +1,27 @@
// src/main/window/state.js
// Window state persistence (size, position)
const { app } = require('electron');
const path = require('path');
const fs = require('fs');
const stateFile = path.join(app.getPath('userData'), 'window-state.json');
function load() {
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
} catch {
return { width: 1200, height: 800 };
}
}
function save(win) {
const bounds = win.getBounds();
try {
fs.writeFileSync(stateFile, JSON.stringify(bounds));
} catch {
/* ignore */
}
}
module.exports = { load, save };
-23
View File
@@ -85,12 +85,6 @@ const ALLOWED_SEND_CHANNELS = [
'get-pdf-page-count', 'get-pdf-page-count',
'select-pdf-folder', 'select-pdf-folder',
// ASCII generator (separate window)
'open-ascii-generator',
// Table generator (separate window)
'open-table-generator',
// Insert generated content // Insert generated content
'insert-generated-content', 'insert-generated-content',
@@ -169,15 +163,10 @@ const ALLOWED_RECEIVE_CHANNELS = [
// Font // Font
'adjust-font-size', 'adjust-font-size',
// Print
'print-preview',
'print-preview-styled',
// Export dialogs // Export dialogs
'show-export-dialog', 'show-export-dialog',
'show-batch-dialog', 'show-batch-dialog',
'show-universal-converter-dialog', 'show-universal-converter-dialog',
'show-table-generator',
'show-pdf-editor-dialog', 'show-pdf-editor-dialog',
// Converter dialogs // Converter dialogs
@@ -213,12 +202,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'pdf-operation-complete', 'pdf-operation-complete',
'pdf-operation-error', 'pdf-operation-error',
// ASCII Art Generator
'show-ascii-generator-window',
'show-ascii-generator',
// Table Generator // Table Generator
'show-table-generator-window',
// Header/Footer dialog // Header/Footer dialog
'open-header-footer-dialog', 'open-header-footer-dialog',
@@ -235,7 +219,6 @@ const ALLOWED_RECEIVE_CHANNELS = [
// v4 menu-triggered events // v4 menu-triggered events
'load-template-menu', 'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel', 'toggle-sidebar-panel',
'toggle-bottom-panel', 'toggle-bottom-panel',
@@ -440,12 +423,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
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')
},
getAppVersion: () => ipcRenderer.invoke('get-app-version') getAppVersion: () => ipcRenderer.invoke('get-app-version')
}); });
-138
View File
@@ -1,138 +0,0 @@
class PrintPreview {
constructor() {
this.overlay = document.getElementById('print-preview-overlay');
this.modal = window.modals?.printPreviewModal;
this._lastContent = '';
this.setupEventListeners();
}
open(htmlContent) {
this._lastContent = htmlContent;
if (this.modal) {
this.modal.open();
} else {
this.overlay.classList.remove('hidden');
}
this.updatePreview(htmlContent);
this.updateScaleLabel();
}
close() {
if (this.modal) {
this.modal.close();
} else {
this.overlay.classList.add('hidden');
}
}
setupEventListeners() {
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
// Update preview on option changes
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
});
// Scale slider
const scaleSlider = document.getElementById('print-scale');
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
// Page range toggle
document.getElementById('print-pages')?.addEventListener('change', (e) => {
const rangeInput = document.getElementById('print-page-range');
if (rangeInput) {
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
}
});
// Note: Backdrop click and Escape key are now handled by ModalManager
}
updateScaleLabel() {
const scale = document.getElementById('print-scale')?.value || 100;
const label = document.getElementById('print-scale-value');
if (label) label.textContent = `${scale}%`;
}
updatePreview(htmlContent) {
const frame = document.getElementById('print-preview-frame');
if (!frame) return;
this._lastContent = htmlContent;
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
// 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' },
};
const size = sizes[paperSize] || sizes['A4'];
const width = orientation === 'landscape' ? size.height : size.width;
const height = orientation === 'landscape' ? size.width : size.height;
const previewHtml = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.6;
}
@page { size: ${width} ${height}; }
pre { background: #f5f5f5; padding: 12px; border-radius: 6px; overflow-x: auto; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 16px; color: #666; }
img { max-width: 100%; }
h1, h2, h3 { margin-top: 1.5em; }
</style>
</head>
<body>${htmlContent || ''}</body>
</html>
`;
frame.srcdoc = previewHtml;
}
refreshPreview() {
if (this._lastContent) {
this.updatePreview(this._lastContent);
}
}
getOptions() {
return {
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
orientation: document.getElementById('print-orientation')?.value || 'portrait',
margins: document.getElementById('print-margins')?.value || 'default',
scale: parseInt(document.getElementById('print-scale')?.value || '100'),
headers: document.getElementById('print-headers')?.checked ?? true,
background: document.getElementById('print-background')?.checked ?? true,
pages: document.getElementById('print-pages')?.value || 'all',
pageRange: document.getElementById('print-page-range')?.value || '',
};
}
executePrint() {
const options = this.getOptions();
const { ipcRenderer } = require('electron');
ipcRenderer.send('do-print-with-options', options);
this.close();
}
}
module.exports = { PrintPreview };
-5319
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -1,15 +1,28 @@
import { useState, useEffect } from 'react';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { ModalLayer } from './components/modals/ModalLayer'; import { ModalLayer } from './components/modals/ModalLayer';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
import { ReplPanel } from './components/tools/ReplPanel';
import { PrintPreview } from './components/tools/PrintPreview';
import { useWelcomeTrigger } from './hooks/use-welcome-trigger'; import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
function App() { function App() {
useWelcomeTrigger(); useWelcomeTrigger();
const [printOpen, setPrintOpen] = useState(false);
useEffect(() => {
const handler = () => setPrintOpen(true);
window.addEventListener('mc:print', handler);
return () => window.removeEventListener('mc:print', handler);
}, []);
return ( return (
<> <>
<AppShell /> <AppShell />
<ModalLayer /> <ModalLayer />
<Toaster /> <Toaster />
<ReplPanel />
{printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />}
</> </>
); );
} }
@@ -9,6 +9,8 @@ import { oneDark } from '@codemirror/theme-one-dark';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { lightTheme, lightHighlight } from './themes/light'; import { lightTheme, lightHighlight } from './themes/light';
import { useEditorStore } from '@/stores/editor-store'; import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
import { Minimap } from './Minimap';
interface Props { interface Props {
bufferId: string; bufferId: string;
@@ -24,6 +26,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const updateContent = useEditorStore((s) => s.updateContent); const updateContent = useEditorStore((s) => s.updateContent);
const setCursor = useEditorStore((s) => s.setCursor); const setCursor = useEditorStore((s) => s.setCursor);
const minimap = useSettingsStore((s) => s.minimap);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
@@ -76,5 +79,10 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
}); });
}, [resolvedTheme]); }, [resolvedTheme]);
return <div ref={ref} className="h-full overflow-hidden" />; return (
<div className="relative h-full overflow-hidden">
<div ref={ref} className="h-full overflow-hidden" />
{minimap && <Minimap content={initialContent} />}
</div>
);
} }
@@ -0,0 +1,33 @@
interface Props {
content: string;
scrollRatio?: number; // 0-1, where the viewport is
visibleRatio?: number; // 0-1, what fraction of content is visible
}
export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
const lines = content.split('\n');
// Compute viewport position in the minimap
const viewportTop = Math.round(scrollRatio * 100);
const viewportHeight = Math.round(visibleRatio * 100);
return (
<div
data-testid="minimap"
className="pointer-events-none absolute right-0 top-0 h-full w-[100px] overflow-hidden border-l border-border bg-card/30 p-1 font-mono text-[6px] leading-[8px] text-muted-foreground"
aria-hidden="true"
>
<pre className="w-full truncate whitespace-pre">
{lines.map((line, i) => (
<div key={i} data-testid="minimap-line">
{line || ' '}
</div>
))}
</pre>
<div
data-testid="minimap-viewport"
className="pointer-events-none absolute left-0 right-0 bg-brand/15"
style={{ top: `${viewportTop}%`, height: `${Math.max(viewportHeight, 5)}%` }}
/>
</div>
);
}
@@ -11,13 +11,43 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/componen
import { useFileShortcuts } from '@/hooks/use-file-shortcuts'; import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder'; import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands'; import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
import { useZenMode } from '@/hooks/use-zen-mode';
export function AppShell() { export function AppShell() {
useFileShortcuts(); useFileShortcuts();
useRestoreLastFolder(); useRestoreLastFolder();
useRegisterMenuCommands(); useRegisterMenuCommands();
useBridgeNativeMenu(); useBridgeNativeMenu();
useZenMode();
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore(); const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
const zenMode = useAppStore((s) => s.zenMode);
if (zenMode) {
return (
<main className="h-screen w-screen overflow-hidden bg-background">
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })}
>
<ResizablePanel defaultSize={previewVisible ? 50 : 100} minSize={20}>
<section className="h-full bg-background">
<EditorPane />
</section>
</ResizablePanel>
{previewVisible && (
<>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20}>
<section className="h-full border-l border-border bg-card/10">
<PreviewPane />
</section>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</main>
);
}
return ( return (
<div className="flex h-screen flex-col bg-background text-foreground"> <div className="flex h-screen flex-col bg-background text-foreground">
+33 -2
View File
@@ -1,7 +1,38 @@
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
function extractHeadings(content: string): { level: number; text: string }[] {
const lines = content.split('\n');
const headings: { level: number; text: string }[] = [];
for (const line of lines) {
const m = line.match(/^(#{1,6})\s+(.+)$/);
if (m) headings.push({ level: m[1].length, text: m[2].trim() });
}
return headings;
}
export function Breadcrumb() { export function Breadcrumb() {
const activeTabId = useFileStore((s) => s.activeTabId);
const openTabs = useFileStore((s) => s.openTabs);
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
return ( return (
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"> <nav
<span>No file selected</span> aria-label="File path"
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
>
<span>{tab ? tab.title : 'No file selected'}</span>
{headings.map((h, i) => (
<span key={i} className="flex items-center gap-1">
<span aria-hidden="true"></span>
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span>
</span>
))}
</nav> </nav>
); );
} }
@@ -0,0 +1,77 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
import { figletText, FIGLET_FONTS, type FigletFont } from '@/lib/figlet';
export function AsciiGeneratorDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [text, setText] = useState('Hello');
const [font, setFont] = useState<FigletFont>('Standard');
const [output, setOutput] = useState('');
useEffect(() => {
let cancelled = false;
figletText(text || ' ', font)
.then((result) => { if (!cancelled) setOutput(result); })
.catch(() => { if (!cancelled) setOutput('(render error)'); });
return () => { cancelled = true; };
}, [text, font]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(output);
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy');
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="ascii-desc" className="max-w-2xl">
<DialogHeader>
<DialogTitle>ASCII generator</DialogTitle>
<DialogDescription id="ascii-desc">Type text, pick a font, see ASCII art</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="ascii-text">Text</Label>
<Textarea
id="ascii-text"
aria-label="Text"
value={text}
onChange={(e) => setText(e.target.value)}
rows={2}
/>
</div>
<div>
<Label htmlFor="ascii-font">Font</Label>
<Select value={font} onValueChange={(v) => setFont(v as FigletFont)}>
<SelectTrigger id="ascii-font" aria-label="Font"><SelectValue /></SelectTrigger>
<SelectContent>
{FIGLET_FONTS.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Output</Label>
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="ascii-output">
{output}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal}>Close</Button>
<Button onClick={handleCopy}>Copy</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppStore } from '@/stores/app-store';
import { useFileStore } from '@/stores/file-store';
import { ipc } from '@/lib/ipc';
interface SearchResult {
filePath: string;
line: number;
content: string;
}
export function FindInFilesDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const rootPath = useFileStore((s) => s.rootPath);
const openFile = useFileStore((s) => s.openFile);
const [query, setQuery] = useState('');
const [isRegex, setIsRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [results, setResults] = useState<SearchResult[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = async () => {
if (!query || !rootPath) return;
setSubmitting(true);
setError(null);
const result = await ipc.file.search({ rootPath, query, isRegex, caseSensitive });
if (!result.ok) {
setError(result.error.message);
setSubmitting(false);
return;
}
setResults(result.data ?? []);
setSubmitting(false);
};
const handleResultClick = (filePath: string) => {
openFile(filePath);
closeModal();
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="find-desc" className="max-w-2xl">
<DialogHeader>
<DialogTitle>Find in files</DialogTitle>
<DialogDescription id="find-desc">
{rootPath ? `Search in ${rootPath}` : 'No folder open'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="find-query">Query</Label>
<Input
id="find-query"
aria-label="Query"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<Checkbox checked={isRegex} onCheckedChange={(c) => setIsRegex(!!c)} aria-label="Regex" />
Regex
</label>
<label className="flex items-center gap-2">
<Checkbox checked={caseSensitive} onCheckedChange={(c) => setCaseSensitive(!!c)} aria-label="Case sensitive" />
Case sensitive
</label>
</div>
{error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
{error}
</div>
)}
{results.length > 0 && (
<div>
<Label>{results.length} result{results.length === 1 ? '' : 's'}</Label>
<div className="max-h-64 overflow-auto rounded border border-border bg-card/20 text-xs">
{results.map((r, i) => (
<button
key={`${r.filePath}:${r.line}:${i}`}
onClick={() => handleResultClick(r.filePath)}
className="block w-full truncate border-b border-border/30 px-2 py-1 text-left hover:bg-card/50"
data-testid="find-result"
>
<span className="font-mono text-muted-foreground">{r.filePath}:{r.line}</span>
<span className="ml-2">{r.content}</span>
</button>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Close</Button>
<Button onClick={handleSearch} disabled={submitting || !query}>
{submitting ? 'Searching…' : 'Search'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,12 +1,16 @@
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { AboutDialog } from './AboutDialog'; import { AboutDialog } from './AboutDialog';
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog';
import { ExportBatchDialog } from './ExportBatchDialog'; import { ExportBatchDialog } from './ExportBatchDialog';
import { ExportDocxDialog } from './ExportDocxDialog'; import { ExportDocxDialog } from './ExportDocxDialog';
import { ExportHtmlDialog } from './ExportHtmlDialog'; import { ExportHtmlDialog } from './ExportHtmlDialog';
import { ExportPdfDialog } from './ExportPdfDialog'; import { ExportPdfDialog } from './ExportPdfDialog';
import { FindInFilesDialog } from './FindInFilesDialog';
import { SettingsSheet } from './SettingsSheet'; import { SettingsSheet } from './SettingsSheet';
import { TableGeneratorDialog } from './TableGeneratorDialog';
import { WelcomeDialog } from './WelcomeDialog'; import { WelcomeDialog } from './WelcomeDialog';
import { WordExportDialog } from './WordExportDialog';
export function ModalLayer() { export function ModalLayer() {
const modal = useAppStore((s) => s.modal); const modal = useAppStore((s) => s.modal);
@@ -29,5 +33,13 @@ export function ModalLayer() {
return <WelcomeDialog />; return <WelcomeDialog />;
case 'confirm': case 'confirm':
return <ConfirmDialog {...modal.props} />; return <ConfirmDialog {...modal.props} />;
case 'export-word':
return <WordExportDialog sourcePath={modal.props.sourcePath} />;
case 'ascii-generator':
return <AsciiGeneratorDialog />;
case 'table-generator':
return <TableGeneratorDialog />;
case 'find-in-files':
return <FindInFilesDialog />;
} }
} }
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
function pad(s: string, w: number) {
return s.padEnd(w);
}
function generateTable(rows: number, cols: number, hasHeader: boolean, headers: string[], data: string[][]): string {
const width = cols;
const colWidths: number[] = Array.from({ length: width }, (_, c) => {
const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')];
return Math.max(...all.map((s) => s.length), 3);
});
const lines: string[] = [];
if (hasHeader) {
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])).join(' | ') + ' |');
lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
}
for (const row of data) {
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') + ' |');
}
// Add empty rows if data has fewer
for (let i = data.length; i < rows; i++) {
lines.push('| ' + colWidths.map((w) => pad('', w)).join(' | ') + ' |');
}
return lines.join('\n');
}
export function TableGeneratorDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [rows, setRows] = useState(3);
const [cols, setCols] = useState(3);
const [hasHeader, setHasHeader] = useState(true);
const [headers, setHeaders] = useState<string[]>(['Col 1', 'Col 2', 'Col 3']);
const output = generateTable(rows, cols, hasHeader, headers, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(output);
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy');
}
};
const updateHeadersCount = (newCols: number) => {
setCols(newCols);
setHeaders((prev) => {
if (prev.length < newCols) return [...prev, ...Array(newCols - prev.length).fill('').map((_, i) => `Col ${prev.length + i + 1}`)];
return prev.slice(0, newCols);
});
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="table-desc" className="max-w-2xl">
<DialogHeader>
<DialogTitle>Table generator</DialogTitle>
<DialogDescription id="table-desc">Specify rows × columns to generate a markdown table</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1">
<Label htmlFor="table-rows">Rows</Label>
<Input
id="table-rows"
aria-label="Rows"
type="number"
min={1}
max={50}
value={rows}
onChange={(e) => setRows(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
/>
</div>
<div className="flex-1">
<Label htmlFor="table-cols">Columns</Label>
<Input
id="table-cols"
aria-label="Cols"
type="number"
min={1}
max={20}
value={cols}
onChange={(e) => updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
/>
</div>
</div>
<label className="flex items-center gap-2">
<Checkbox checked={hasHeader} onCheckedChange={(c) => setHasHeader(!!c)} aria-label="Header" />
Include header row
</label>
{hasHeader && (
<div>
<Label>Header names</Label>
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }, (_, i) => (
<Input
key={i}
aria-label={`Header ${i + 1}`}
value={headers[i] ?? ''}
onChange={(e) => {
const next = [...headers];
next[i] = e.target.value;
setHeaders(next);
}}
/>
))}
</div>
</div>
)}
<div>
<Label>Output</Label>
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="table-output">
{output}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal}>Close</Button>
<Button onClick={handleCopy}>Copy</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source';
import { generateDocx } from '@/lib/docx-export';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
const closeModal = useAppStore((s) => s.closeModal);
const setSetting = useSettingsStore((s) => s.setSetting);
const docxCustomTemplatePath = useSettingsStore((s) => s.docxCustomTemplatePath);
const source = useExportSource();
const [templateMode, setTemplateMode] = useState<'standard' | 'custom'>(docxCustomTemplatePath ? 'custom' : 'standard');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setTemplateMode(docxCustomTemplatePath ? 'custom' : 'standard');
}, [docxCustomTemplatePath]);
const handleChooseTemplate = async () => {
const result = await ipc.app.showSaveDialog?.({ title: 'Choose template path' });
if (result?.ok && result.data) {
setSetting('docxCustomTemplatePath', result.data);
}
};
const handleSubmit = async () => {
if (!source) { setError('No file open.'); return; }
setSubmitting(true);
setError(null);
try {
const blob = await generateDocx({
source: source.source,
title: source.title,
customTemplatePath: templateMode === 'custom' ? docxCustomTemplatePath : null,
});
const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') });
if (!saveResult?.ok || !saveResult.data) {
setSubmitting(false);
return;
}
const buffer = new Uint8Array(await blob.arrayBuffer());
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
if (!writeResult.ok) {
setError(writeResult.error.message);
setSubmitting(false);
return;
}
toast.success(`Exported ${source.title} to ${saveResult.data}`);
closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="word-desc">
<DialogHeader>
<DialogTitle>Export to Word (.docx)</DialogTitle>
<DialogDescription id="word-desc">{sourcePath}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<div>
<Label>Template</Label>
<RadioGroup value={templateMode} onValueChange={(v) => setTemplateMode(v as 'standard' | 'custom')}>
<div className="flex items-center gap-2">
<RadioGroupItem value="standard" id="template-standard" />
<Label htmlFor="template-standard">Standard (bundled)</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="custom" id="template-custom" />
<Label htmlFor="template-custom">Custom .dotx</Label>
</div>
</RadioGroup>
</div>
{templateMode === 'custom' && (
<div className="rounded border border-border bg-card/20 p-2 text-xs">
{docxCustomTemplatePath ? (
<span>Template path: <code>{docxCustomTemplatePath}</code></span>
) : (
<span className="text-muted-foreground">No template selected. Click "Choose template..." to pick a .dotx file.</span>
)}
<Button variant="ghost" size="sm" onClick={handleChooseTemplate} className="ml-2">
Choose template...
</Button>
</div>
)}
{error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Exporting…' : 'Export'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { RefreshCw, FileX, FilePlus, FileEdit, FileQuestion } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useFileStore } from '@/stores/file-store';
import { ipc } from '@/lib/ipc';
interface GitStatus {
filePath: string;
status: 'modified' | 'added' | 'deleted' | 'untracked';
}
const STATUS_ICON: Record<GitStatus['status'], JSX.Element> = {
modified: <FileEdit className="h-3 w-3 text-warning" />,
added: <FilePlus className="h-3 w-3 text-success" />,
deleted: <FileX className="h-3 w-3 text-destructive" />,
untracked: <FileQuestion className="h-3 w-3 text-muted-foreground" />,
};
const STATUS_LABEL: Record<GitStatus['status'], string> = {
modified: 'M',
added: 'A',
deleted: 'D',
untracked: '?',
};
export function GitStatusPanel() {
const rootPath = useFileStore((s) => s.rootPath);
const openFile = useFileStore((s) => s.openFile);
const [status, setStatus] = useState<GitStatus[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = async () => {
if (!rootPath) {
setStatus([]);
return;
}
setLoading(true);
setError(null);
const result = await ipc.file.gitStatus({ rootPath });
if (!result.ok) {
setError(result.error.message);
setStatus([]);
setLoading(false);
return;
}
setStatus(result.data ?? []);
setLoading(false);
};
useEffect(() => {
load();
// Listen for git.refresh command via custom event
const handler = () => load();
window.addEventListener('mc:git-refresh', handler);
return () => window.removeEventListener('mc:git-refresh', handler);
}, [rootPath]);
if (!rootPath) {
return <div className="p-3 text-xs text-muted-foreground">No folder open</div>;
}
if (error) {
return (
<div className="p-3 text-xs">
<div className="text-destructive">Error: {error}</div>
<p className="mt-1 text-muted-foreground">Not a git repository, or git not installed.</p>
<Button size="sm" variant="ghost" onClick={load} className="mt-2">Retry</Button>
</div>
);
}
if (status.length === 0 && !loading) {
return <div className="p-3 text-xs text-muted-foreground">Working tree clean</div>;
}
return (
<div className="p-2 text-xs">
<div className="flex items-center justify-between px-1 py-1">
<span className="font-semibold">{status.length} changed file{status.length === 1 ? '' : 's'}</span>
<Button size="sm" variant="ghost" onClick={load} aria-label="Refresh">
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div className="space-y-0.5">
{status.map((s) => (
<button
key={s.filePath}
onClick={() => openFile(s.filePath)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50"
data-testid="git-status-row"
>
{STATUS_ICON[s.status]}
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
</button>
))}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { useFileStore } from '@/stores/file-store'; import { useFileStore } from '@/stores/file-store';
import { FileTree } from './FileTree'; import { FileTree } from './FileTree';
import { Outline } from './Outline'; import { Outline } from './Outline';
import { GitStatusPanel } from './GitStatusPanel';
export function Sidebar() { export function Sidebar() {
const tree = useFileStore((s) => s.tree); const tree = useFileStore((s) => s.tree);
@@ -49,6 +50,21 @@ export function Sidebar() {
</ScrollArea> </ScrollArea>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Git section */}
<Collapsible defaultOpen>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
<ChevronRight size={12} className="rotate-90" />
Git
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="h-[calc(100vh-240px)]">
<GitStatusPanel />
</ScrollArea>
</CollapsibleContent>
</Collapsible>
</div> </div>
); );
} }
@@ -0,0 +1,57 @@
import { X, Printer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MarkdownRenderer } from '@/components/preview/MarkdownRenderer';
import { useExportSource } from '@/hooks/use-export-source';
import { ipc } from '@/lib/ipc';
interface Props {
onClose: () => void;
}
export function PrintPreview({ onClose }: Props) {
const source = useExportSource();
const handlePrint = async () => {
if (!source) return;
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${source.title}</title></head><body><pre>${source.source.replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c] ?? c))}</pre></body></html>`;
await ipc.print({ html });
};
if (!source) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
<h2 className="font-semibold">Print preview</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-1 items-center justify-center text-muted-foreground">
No file open
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
<h2 className="font-semibold">Print preview {source.title}</h2>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="mr-2 h-4 w-4" />
Print
</Button>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto bg-card/20 p-8">
<div className="mx-auto max-w-3xl rounded border border-border bg-background p-8 shadow-lg">
<MarkdownRenderer source={source.source} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { useSettingsStore } from '@/stores/settings-store';
import { renderMarkdown } from '@/lib/markdown';
export function ReplPanel() {
const replOpen = useSettingsStore((s) => s.replOpen);
const setSetting = useSettingsStore((s) => s.setSetting);
const [source, setSource] = useState('# Markdown preview\n\nType here…');
const [debouncedSource, setDebouncedSource] = useState(source);
useEffect(() => {
const t = setTimeout(() => setDebouncedSource(source), 300);
return () => clearTimeout(t);
}, [source]);
if (!replOpen) return null;
const html = renderMarkdown(debouncedSource);
return (
<div
role="region"
aria-label="REPL"
className="fixed bottom-0 left-0 right-0 z-40 flex h-[30vh] flex-col border-t border-border bg-card/95 shadow-2xl backdrop-blur"
data-testid="repl-panel"
>
<div className="flex h-8 items-center justify-between border-b border-border bg-card/50 px-3 text-xs">
<span className="font-semibold">REPL Markdown snippet preview</span>
<Button
variant="ghost"
size="icon"
aria-label="Close REPL"
onClick={() => setSetting('replOpen', false)}
className="h-6 w-6"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex flex-1 overflow-hidden">
<Textarea
value={source}
onChange={(e) => setSource(e.target.value)}
className="h-full flex-1 resize-none rounded-none border-r border-border font-mono text-xs"
aria-label="Markdown source"
/>
<div
className="h-full flex-1 overflow-auto p-3 text-sm"
dangerouslySetInnerHTML={{ __html: html }}
data-testid="repl-preview"
/>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAppStore } from '@/stores/app-store';
/**
* Mounts a global keydown listener that exits zen mode when the user
* presses Escape. Should be called once at the App root.
*/
export function useZenMode() {
const zenMode = useAppStore((s) => s.zenMode);
const setZenMode = useAppStore((s) => s.setZenMode);
useEffect(() => {
if (!zenMode) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setZenMode(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [zenMode, setZenMode]);
}
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useCommandStore } from '@/stores/command-store'; import { useCommandStore } from '@/stores/command-store';
import { useFileStore } from '@/stores/file-store'; import { useFileStore } from '@/stores/file-store';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useMenuAction } from '@/hooks/use-menu-action'; import { useMenuAction } from '@/hooks/use-menu-action';
/** /**
@@ -45,6 +46,28 @@ export function registerMenuCommands(): void {
'app.quit': () => { 'app.quit': () => {
/* stub — wired in later phase */ /* stub — wired in later phase */
}, },
'tools.ascii': () => useAppStore.getState().openModal('ascii-generator'),
'tools.table': () => useAppStore.getState().openModal('table-generator'),
'tools.findInFiles': () => useAppStore.getState().openModal('find-in-files'),
'tools.exportWord': () => {
const activeTabId = useFileStore.getState().activeTabId;
if (!activeTabId) return;
useAppStore.getState().openModal('export-word', { sourcePath: activeTabId });
},
'tools.repl': () => {
const current = useSettingsStore.getState().replOpen;
useSettingsStore.getState().setSetting('replOpen', !current);
},
'view.zenMode': () => {
const current = useAppStore.getState().zenMode;
useAppStore.getState().setZenMode(!current);
},
'file.print': () => {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print'));
},
'git.refresh': () => {
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:git-refresh'));
},
}); });
const { register } = useCommandStore.getState(); const { register } = useCommandStore.getState();
+31
View File
@@ -0,0 +1,31 @@
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
import { applyAsciiTransform } from './ascii-table';
export interface DocxOptions {
source: string;
title?: string;
customTemplatePath?: string | null; // for future use
}
export async function generateDocx(options: DocxOptions): Promise<Blob> {
// Apply ASCII transform (convert markdown tables to preformatted)
const transformed = applyAsciiTransform(options.source);
// Parse the source into a list of Paragraphs
// For v1, simple line-by-line: each line is a paragraph
// Headings (# ## ###) get heading styles
const lines = transformed.split('\n');
const children = lines.map((line) => {
if (line.startsWith('### ')) return new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 });
if (line.startsWith('## ')) return new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 });
if (line.startsWith('# ')) return new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 });
if (line.startsWith('```')) return new Paragraph({ text: line, alignment: AlignmentType.CENTER });
return new Paragraph({ children: [new TextRun(line)] });
});
const doc = new Document({
sections: [{ children }],
});
return await Packer.toBlob(doc);
}
+14
View File
@@ -0,0 +1,14 @@
import figlet from 'figlet';
import type { Fonts } from 'figlet';
export const FIGLET_FONTS = ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'] as const;
export type FigletFont = typeof FIGLET_FONTS[number];
export function figletText(text: string, font: FigletFont): Promise<string> {
return new Promise((resolve, reject) => {
figlet.text(text, { font }, (err, result) => {
if (err) reject(err);
else resolve(result ?? '');
});
});
}
+10
View File
@@ -76,7 +76,15 @@ export const ipc = {
} }
return window.electronAPI.file.onChange(cb); return window.electronAPI.file.onChange(cb);
}, },
search: (args: { rootPath: string; query: string; isRegex: boolean; caseSensitive: boolean }): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>> =>
safeCall('file', 'search', args),
gitStatus: (args: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }> | ChannelMissing>> =>
safeCall('file', 'gitStatus', args),
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('file', 'writeBuffer', args),
}, },
print: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('print', 'show', args),
export: { export: {
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> => pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
safeCall('export', 'pdf', opts), safeCall('export', 'pdf', opts),
@@ -94,6 +102,8 @@ export const ipc = {
safeCall('app', 'openExternal', url), safeCall('app', 'openExternal', url),
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> => showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
safeCall('app', 'showItemInFolder', path), safeCall('app', 'showItemInFolder', path),
showSaveDialog: (args?: { title?: string; defaultPath?: string }): Promise<IpcResult<string | null | ChannelMissing>> =>
safeCall('app', 'showSaveDialog', args),
}, },
menu: { menu: {
/** /**
+3
View File
@@ -13,6 +13,9 @@ export const settingsSchema = z.object({
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'), pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
pdfEmbedFonts: z.boolean().default(true), pdfEmbedFonts: z.boolean().default(true),
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'), docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
docxCustomTemplatePath: z.string().nullable().default(null),
replOpen: z.boolean().default(false),
breadcrumbSymbols: z.boolean().default(true),
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'), htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
renderTablesAsAscii: z.boolean().default(false), renderTablesAsAscii: z.boolean().default(false),
welcomeDismissed: z.boolean().default(false), welcomeDismissed: z.boolean().default(false),
+5 -1
View File
@@ -26,7 +26,11 @@ export type ModalState =
| { kind: 'settings' } | { kind: 'settings' }
| { kind: 'about' } | { kind: 'about' }
| { kind: 'welcome' } | { kind: 'welcome' }
| { kind: 'confirm'; props: ConfirmProps }; | { kind: 'confirm'; props: ConfirmProps }
| { kind: 'export-word'; props: { sourcePath: string } }
| { kind: 'ascii-generator' }
| { kind: 'table-generator' }
| { kind: 'find-in-files' };
export type ModalKind = ModalState['kind']; export type ModalKind = ModalState['kind'];
-969
View File
@@ -1,969 +0,0 @@
/**
* ConcreteInfo Theme for MarkdownConverter
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
* Version: 4.1.0
*/
/* ============================================
CSS Variables - ConcreteInfo Theme
============================================ */
:root {
/* Primary Colors from Logo Palette */
--ci-dark-gray: #464646;
--ci-medium-gray: #9a9696;
--ci-accent: #e5461f;
--ci-light-gray: #e3e3e3;
--ci-black: #0d0b09;
/* Extended Palette */
--ci-accent-hover: #c93a18;
--ci-accent-light: rgba(229, 70, 31, 0.1);
--ci-white: #ffffff;
--ci-bg: #f5f5f5;
--ci-border: #d0d0d0;
/* Semantic Colors */
--ci-success: #28a745;
--ci-warning: #ffc107;
--ci-danger: #dc3545;
--ci-info: #17a2b8;
/* Typography */
--font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
}
/* ============================================
ConcreteInfo Theme Application
============================================ */
body.theme-concreteinfo {
background: var(--ci-bg);
color: var(--ci-dark-gray);
}
body.theme-concreteinfo .toolbar {
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
border-bottom: 3px solid var(--ci-accent);
}
body.theme-concreteinfo .toolbar button {
color: var(--ci-white);
background: transparent;
border: 1px solid transparent;
transition: all var(--transition-fast);
}
body.theme-concreteinfo .toolbar button:hover {
background: var(--ci-accent);
border-color: var(--ci-accent);
transform: translateY(-1px);
}
body.theme-concreteinfo .tab-bar {
background: var(--ci-dark-gray);
border-bottom: 2px solid var(--ci-accent);
}
body.theme-concreteinfo .tab {
background: var(--ci-medium-gray);
color: var(--ci-white);
border: none;
margin: 2px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
transition: all var(--transition-fast);
}
body.theme-concreteinfo .tab:hover {
background: var(--ci-accent-light);
}
body.theme-concreteinfo .tab.active {
background: var(--ci-accent);
color: var(--ci-white);
}
body.theme-concreteinfo .editor-textarea {
background: var(--ci-white);
color: var(--ci-black);
border: 1px solid var(--ci-border);
font-family: var(--font-mono);
}
body.theme-concreteinfo .preview-content {
background: var(--ci-white);
color: var(--ci-dark-gray);
border-left: 3px solid var(--ci-accent);
}
body.theme-concreteinfo .status-bar {
background: var(--ci-dark-gray);
color: var(--ci-light-gray);
border-top: 2px solid var(--ci-accent);
}
/* ============================================
Modern Modal Styles
============================================ */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(13, 11, 9, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: all var(--transition-normal);
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-content {
background: var(--ci-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
max-width: 600px;
width: 90%;
max-height: 85vh;
overflow: hidden;
transform: scale(0.9) translateY(20px);
transition: transform var(--transition-normal);
}
.modal-overlay.active .modal-content {
transform: scale(1) translateY(0);
}
.modal-header {
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
color: var(--ci-white);
padding: var(--spacing-md) var(--spacing-lg);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid var(--ci-accent);
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
font-family: var(--font-sans);
}
.modal-close {
background: transparent;
border: none;
color: var(--ci-white);
font-size: 1.5rem;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.modal-close:hover {
background: var(--ci-accent);
transform: rotate(90deg);
}
.modal-body {
padding: var(--spacing-lg);
overflow-y: auto;
max-height: 60vh;
}
.modal-footer {
padding: var(--spacing-md) var(--spacing-lg);
background: var(--ci-light-gray);
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
border-top: 1px solid var(--ci-border);
}
/* ============================================
Button Styles
============================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-primary {
background: var(--ci-accent);
color: var(--ci-white);
}
.btn-primary:hover {
background: var(--ci-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--ci-medium-gray);
color: var(--ci-white);
}
.btn-secondary:hover {
background: var(--ci-dark-gray);
}
.btn-outline {
background: transparent;
border: 2px solid var(--ci-accent);
color: var(--ci-accent);
}
.btn-outline:hover {
background: var(--ci-accent);
color: var(--ci-white);
}
.btn-ghost {
background: transparent;
color: var(--ci-dark-gray);
}
.btn-ghost:hover {
background: var(--ci-light-gray);
}
/* ============================================
Form Controls
============================================ */
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
color: var(--ci-dark-gray);
font-size: 0.875rem;
}
.form-input,
.form-select {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
font-family: var(--font-sans);
font-size: 0.875rem;
border: 2px solid var(--ci-border);
border-radius: var(--radius-md);
background: var(--ci-white);
color: var(--ci-dark-gray);
transition: all var(--transition-fast);
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--ci-accent);
box-shadow: 0 0 0 3px var(--ci-accent-light);
}
.form-input::placeholder {
color: var(--ci-medium-gray);
}
/* ============================================
App Header with Logo
============================================ */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
background: #ffffff;
border-bottom: 2px solid #e1e4e8;
min-height: 36px;
}
.app-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.app-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.app-logo {
height: 22px;
width: auto;
}
.app-title {
color: #24292e;
font-size: 0.95rem;
font-weight: 600;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.app-version {
color: #6a737d;
font-size: 0.7rem;
font-family: 'SFMono-Regular', Consolas, monospace;
}
/* Dark theme header adjustments */
body.theme-dark .app-header,
body.theme-dracula .app-header,
body.theme-onedark .app-header,
body.theme-tokyonight .app-header,
body.theme-monokai .app-header,
body.theme-cobalt2 .app-header,
body.theme-gruvbox-dark .app-header,
body.theme-ayu-dark .app-header,
body.theme-ayu-mirage .app-header,
body.theme-palenight .app-header,
body.theme-oceanic-next .app-header,
body.theme-nord .app-header,
body.theme-concrete-dark .app-header {
background: #1f2937;
border-bottom-color: #374151;
}
body.theme-dark .app-title,
body.theme-dracula .app-title,
body.theme-onedark .app-title,
body.theme-tokyonight .app-title,
body.theme-monokai .app-title,
body.theme-cobalt2 .app-title,
body.theme-gruvbox-dark .app-title,
body.theme-ayu-dark .app-title,
body.theme-ayu-mirage .app-title,
body.theme-palenight .app-title,
body.theme-oceanic-next .app-title,
body.theme-nord .app-title,
body.theme-concrete-dark .app-title {
color: #f3f4f6;
}
body.theme-dark .app-version,
body.theme-dracula .app-version,
body.theme-onedark .app-version,
body.theme-tokyonight .app-version,
body.theme-monokai .app-version,
body.theme-cobalt2 .app-version,
body.theme-gruvbox-dark .app-version,
body.theme-ayu-dark .app-version,
body.theme-ayu-mirage .app-version,
body.theme-palenight .app-version,
body.theme-oceanic-next .app-version,
body.theme-nord .app-version,
body.theme-concrete-dark .app-version {
color: #9ca3af;
}
body.theme-dark .app-logo,
body.theme-dracula .app-logo,
body.theme-onedark .app-logo,
body.theme-tokyonight .app-logo,
body.theme-monokai .app-logo,
body.theme-cobalt2 .app-logo,
body.theme-gruvbox-dark .app-logo,
body.theme-ayu-dark .app-logo,
body.theme-ayu-mirage .app-logo,
body.theme-palenight .app-logo,
body.theme-oceanic-next .app-logo,
body.theme-nord .app-logo,
body.theme-concrete-dark .app-logo {
filter: none;
}
/* ============================================
Converter Cards
============================================ */
.converter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-md);
padding: var(--spacing-md);
}
.converter-card {
background: var(--ci-white);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
border: 2px solid var(--ci-border);
transition: all var(--transition-fast);
cursor: pointer;
}
.converter-card:hover {
border-color: var(--ci-accent);
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.converter-card-icon {
font-size: 2.5rem;
margin-bottom: var(--spacing-md);
}
.converter-card-title {
font-weight: 600;
color: var(--ci-dark-gray);
margin-bottom: var(--spacing-xs);
}
.converter-card-desc {
font-size: 0.875rem;
color: var(--ci-medium-gray);
}
/* ============================================
Progress Bar
============================================ */
.progress-bar {
height: 8px;
background: var(--ci-light-gray);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--ci-accent) 0%, var(--ci-accent-hover) 100%);
border-radius: var(--radius-full);
transition: width var(--transition-normal);
}
/* ============================================
File Drop Zone
============================================ */
.drop-zone {
border: 2px dashed var(--ci-border);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
text-align: center;
transition: all var(--transition-fast);
cursor: pointer;
}
.drop-zone:hover,
.drop-zone.drag-over {
border-color: var(--ci-accent);
background: var(--ci-accent-light);
}
.drop-zone-icon {
font-size: 3rem;
color: var(--ci-medium-gray);
margin-bottom: var(--spacing-md);
}
.drop-zone-text {
color: var(--ci-dark-gray);
font-weight: 500;
}
.drop-zone-hint {
color: var(--ci-medium-gray);
font-size: 0.875rem;
margin-top: var(--spacing-xs);
}
/* ============================================
Syntax Highlighting for Editor
============================================ */
.editor-syntax .md-heading {
color: var(--ci-accent);
font-weight: bold;
}
.editor-syntax .md-bold {
color: var(--ci-dark-gray);
font-weight: bold;
}
.editor-syntax .md-italic {
color: var(--ci-dark-gray);
font-style: italic;
}
.editor-syntax .md-code {
background: var(--ci-light-gray);
color: var(--ci-accent);
font-family: var(--font-mono);
padding: 2px 4px;
border-radius: var(--radius-sm);
}
.editor-syntax .md-link {
color: var(--ci-info);
text-decoration: underline;
}
.editor-syntax .md-list {
color: var(--ci-accent);
}
.editor-syntax .md-blockquote {
color: var(--ci-medium-gray);
border-left: 3px solid var(--ci-accent);
padding-left: var(--spacing-md);
}
/* ============================================
ASCII Art Generator Styles
============================================ */
.ascii-preview {
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.2;
background: var(--ci-black);
color: #00ff00;
padding: var(--spacing-md);
border-radius: var(--radius-md);
overflow-x: auto;
white-space: pre;
}
.ascii-controls {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
/* ============================================
Table Generator Styles
============================================ */
.table-generator-grid {
display: grid;
gap: 1px;
background: var(--ci-border);
border: 1px solid var(--ci-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.table-generator-cell {
background: var(--ci-white);
padding: var(--spacing-sm);
min-width: 80px;
min-height: 32px;
}
.table-generator-cell input {
width: 100%;
border: none;
background: transparent;
text-align: center;
font-family: var(--font-mono);
}
.table-generator-cell.header {
background: var(--ci-light-gray);
font-weight: 600;
}
/* ============================================
PDF Viewer/Editor Styles
============================================ */
.pdf-viewer {
background: var(--ci-dark-gray);
padding: var(--spacing-md);
border-radius: var(--radius-md);
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.pdf-page {
background: var(--ci-white);
box-shadow: var(--shadow-lg);
max-width: 100%;
}
.pdf-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--ci-light-gray);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
}
/* ============================================
Dark Mode for ConcreteInfo Theme
============================================ */
body.theme-concreteinfo-dark {
--ci-bg: #1a1a1a;
--ci-white: #2a2a2a;
--ci-light-gray: #3a3a3a;
--ci-border: #4a4a4a;
}
body.theme-concreteinfo-dark .editor-textarea,
body.theme-concreteinfo-dark .preview-content {
background: #2a2a2a;
color: #e0e0e0;
}
body.theme-concreteinfo-dark .modal-content {
background: #2a2a2a;
color: #e0e0e0;
}
body.theme-concreteinfo-dark .modal-body {
background: #2a2a2a;
}
body.theme-concreteinfo-dark .modal-footer {
background: #1a1a1a;
}
/* ============================================
Animations
============================================ */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.animate-fade-in {
animation: fadeIn var(--transition-normal);
}
.animate-slide-up {
animation: slideUp var(--transition-normal);
}
.animate-pulse {
animation: pulse 2s infinite;
}
/* ============================================
Responsive Design
============================================ */
@media (max-width: 768px) {
.modal-content {
width: 95%;
max-height: 90vh;
}
.converter-grid {
grid-template-columns: 1fr;
}
.app-header {
flex-wrap: wrap;
}
}
/* ============================================
Concrete Dark Theme
============================================ */
body.theme-concrete-dark {
background: #1a1a1a;
color: #e3e3e3;
}
body.theme-concrete-dark .toolbar {
background: linear-gradient(135deg, #0d0b09 0%, #1a1a1a 100%);
border-bottom: 3px solid #e5461f;
}
body.theme-concrete-dark .toolbar button {
color: #e3e3e3;
}
body.theme-concrete-dark .toolbar button:hover {
background: #e5461f;
}
body.theme-concrete-dark .tab-bar {
background: #0d0b09;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .tab {
background: #2a2a2a;
color: #e3e3e3;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-dark .tab.active {
background: #464646;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .editor-textarea,
body.theme-concrete-dark .editor-pane {
background: #1a1a1a;
color: #e3e3e3;
}
body.theme-concrete-dark .preview-content {
background: #1a1a1a;
color: #e3e3e3;
}
body.theme-concrete-dark .status-bar {
background: #0d0b09;
color: #9a9696;
border-top: 1px solid #464646;
}
body.theme-concrete-dark .export-dialog-content,
body.theme-concrete-dark .batch-dialog-content {
background: #2a2a2a;
color: #e3e3e3;
}
body.theme-concrete-dark .export-dialog-header,
body.theme-concrete-dark .batch-dialog-header {
background: #1a1a1a;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .export-dialog-footer,
body.theme-concrete-dark .batch-dialog-footer {
background: #1a1a1a;
}
body.theme-concrete-dark input,
body.theme-concrete-dark select,
body.theme-concrete-dark textarea {
background: #1a1a1a;
color: #e3e3e3;
border-color: #464646;
}
body.theme-concrete-dark input:focus,
body.theme-concrete-dark select:focus,
body.theme-concrete-dark textarea:focus {
border-color: #e5461f;
}
body.theme-concrete-dark .line-numbers {
background: #0d0b09;
color: #9a9696;
}
/* ============================================
Concrete Light Theme
============================================ */
body.theme-concrete-light {
background: #f5f5f5;
color: #464646;
}
body.theme-concrete-light .toolbar {
background: #ffffff;
border-bottom: 3px solid #e5461f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
body.theme-concrete-light .toolbar button {
color: #464646;
}
body.theme-concrete-light .toolbar button:hover {
background: #e5461f;
color: #ffffff;
}
body.theme-concrete-light .tab-bar {
background: #ffffff;
border-bottom: 2px solid #e3e3e3;
}
body.theme-concrete-light .tab {
background: #f5f5f5;
color: #464646;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-light .tab.active {
background: #ffffff;
border-bottom: 2px solid #e5461f;
color: #e5461f;
}
body.theme-concrete-light .editor-textarea,
body.theme-concrete-light .editor-pane {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .preview-content {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .status-bar {
background: #ffffff;
color: #9a9696;
border-top: 1px solid #e3e3e3;
}
body.theme-concrete-light .export-dialog-content,
body.theme-concrete-light .batch-dialog-content {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .export-dialog-header,
body.theme-concrete-light .batch-dialog-header {
background: #f5f5f5;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-light .line-numbers {
background: #f5f5f5;
color: #9a9696;
}
/* ============================================
Concrete Warm Theme
============================================ */
body.theme-concrete-warm {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .toolbar {
background: linear-gradient(135deg, #464646 0%, #5a5a5a 100%);
border-bottom: 3px solid #e5461f;
}
body.theme-concrete-warm .toolbar button {
color: #faf8f5;
}
body.theme-concrete-warm .toolbar button:hover {
background: #e5461f;
}
body.theme-concrete-warm .tab-bar {
background: #464646;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm .tab {
background: #6a6a6a;
color: #faf8f5;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-warm .tab.active {
background: #faf8f5;
color: #e5461f;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm .editor-textarea,
body.theme-concrete-warm .editor-pane {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .preview-content {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .status-bar {
background: #f0ebe4;
color: #9a9696;
border-top: 1px solid #e3e3e3;
}
body.theme-concrete-warm .export-dialog-content,
body.theme-concrete-warm .batch-dialog-content {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .export-dialog-header,
body.theme-concrete-warm .batch-dialog-header {
background: #f0ebe4;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm h1,
body.theme-concrete-warm h2,
body.theme-concrete-warm h3,
body.theme-concrete-warm h4,
body.theme-concrete-warm h5,
body.theme-concrete-warm h6 {
color: #e5461f;
}
body.theme-concrete-warm a {
color: #e5461f;
}
body.theme-concrete-warm .line-numbers {
background: #f0ebe4;
color: #9a9696;
}
File diff suppressed because it is too large Load Diff
-304
View File
@@ -1,304 +0,0 @@
/* Sidebar */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.sidebar {
display: flex;
flex-shrink: 0;
height: 100%;
transition: width 0.2s ease;
}
.sidebar.collapsed {
width: 48px;
}
.sidebar:not(.collapsed) {
width: 328px; /* 48px icons + 280px panel */
}
.sidebar-icons {
display: flex;
flex-direction: column;
width: 48px;
background: var(--gray-100, #f3f4f6);
border-right: 1px solid var(--gray-200, #e5e7eb);
padding: 8px 0;
gap: 4px;
align-items: center;
}
.sidebar-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: var(--gray-500, #6b7280);
transition: all 0.15s ease;
}
.sidebar-icon:hover {
background: var(--gray-200, #e5e7eb);
color: var(--gray-700, #374151);
}
.sidebar-icon.active {
background: var(--gray-200, #e5e7eb);
color: var(--primary-dark, #5661b3);
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
}
.sidebar-icon:focus-visible {
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
}
.sidebar-panel {
width: 280px;
background: var(--gray-50, #f9fafb);
border-right: 1px solid var(--gray-200, #e5e7eb);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar.collapsed .sidebar-panel {
display: none;
}
.sidebar-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-200, #e5e7eb);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-600, #4b5563);
}
.sidebar-panel-close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--gray-400, #9ca3af);
padding: 0 4px;
border-radius: 4px;
}
.sidebar-panel-close:hover {
background: var(--gray-200, #e5e7eb);
color: var(--gray-600, #4b5563);
}
.sidebar-panel-close:focus-visible {
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
}
.sidebar-panel-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* Dark theme support */
body[class*="dark"] .sidebar-icons,
body[class*="dark"] .sidebar-panel {
background: #1e1e1e;
border-color: #333;
}
body[class*="dark"] .sidebar-icon {
color: #888;
}
body[class*="dark"] .sidebar-icon:hover {
background: #333;
color: #ccc;
}
body[class*="dark"] .sidebar-icon.active {
background: #333;
color: #8b9aff;
box-shadow: inset 3px 0 0 #8b9aff;
}
body[class*="dark"] .sidebar-panel-header {
border-color: #333;
color: #ccc;
}
/* Panel list items (templates, etc.) */
.panel-list-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
}
.panel-list-item:hover {
background: var(--gray-200, #e5e7eb);
}
.panel-list-item-title {
font-size: 13px;
font-weight: 500;
color: var(--gray-800, #1f2937);
}
.panel-list-item-desc {
font-size: 11px;
color: var(--gray-500, #6b7280);
margin-top: 2px;
}
body[class*="dark"] .panel-list-item:hover {
background: #333;
}
body[class*="dark"] .panel-list-item-title {
color: #ddd;
}
body[class*="dark"] .panel-list-item-desc {
color: #888;
}
/* Explorer Panel */
.explorer-toolbar {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.explorer-path {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
font-size: 12px;
background: white;
overflow: hidden;
text-overflow: ellipsis;
}
.explorer-browse-btn {
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
background: white;
cursor: pointer;
padding: 4px 8px;
}
.tree-item {
padding: 3px 0;
cursor: pointer;
font-size: 13px;
user-select: none;
}
.tree-item:hover {
background: var(--gray-100, #f3f4f6);
border-radius: 4px;
}
.tree-icon {
margin-right: 4px;
font-size: 12px;
}
.tree-children {
padding-left: 16px;
}
.tree-folder.collapsed .tree-children {
display: none;
}
.tree-name {
font-size: 13px;
}
/* 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); }
/* 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; }
/* 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 {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .git-commit-input {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .snippet-item {
border-color: #444;
}
body[class*="dark"] .snippet-preview {
background: #2d2d2d;
}
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 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; }
-24
View File
@@ -1,24 +0,0 @@
.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; }
-102
View File
@@ -1,102 +0,0 @@
/* Zen Mode — Distraction-free writing styles */
/* Hide chrome elements */
body.zen-mode .app-header,
body.zen-mode .tab-bar,
body.zen-mode .toolbar,
body.zen-mode .status-bar,
body.zen-mode .sidebar,
body.zen-mode .breadcrumb-bar {
display: none !important;
}
/* Main content fills viewport */
body.zen-mode .main-content {
height: 100vh;
overflow: hidden;
}
/* Editor container takes full width */
body.zen-mode .editor-container {
width: 100% !important;
height: 100% !important;
}
/* Hide preview panes */
body.zen-mode .preview-container,
body.zen-mode .preview-pane,
body.zen-mode .pane-resizer {
display: none !important;
}
/* Center and style editor content for comfortable reading width */
body.zen-mode .cm-content {
max-width: 700px;
margin: 0 auto;
font-size: 18px;
line-height: 1.8;
padding-top: 80px;
}
body.zen-mode .cm-line {
padding: 4px 0;
}
body.zen-mode .editor-wrapper {
display: flex;
justify-content: center;
}
/* Floating HUD pill */
.zen-hud {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: rgba(255, 255, 255, 0.8);
padding: 10px 24px;
border-radius: 24px;
font-size: 13px;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
z-index: 9999;
pointer-events: none;
display: flex;
align-items: center;
gap: 16px;
transition: opacity 0.3s ease;
}
.zen-hud-stat {
opacity: 0.7;
}
.zen-hud-sep {
opacity: 0.3;
margin: 0 4px;
}
/* Word goal progress bar */
.zen-progress-container {
width: 120px;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
display: none;
}
.zen-progress-bar {
height: 100%;
background: rgba(255, 178, 70, 0.85);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Dark mode — HUD stays dark in both themes */
body[class*="dark"].zen-mode .zen-hud {
background: rgba(0, 0, 0, 0.7);
}
-3723
View File
File diff suppressed because it is too large Load Diff
-545
View File
@@ -1,545 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
<style>
:root {
--ci-dark-gray: #464646;
--ci-medium-gray: #9a9696;
--ci-accent: #e5461f;
--ci-light-gray: #e3e3e3;
--ci-black: #0d0b09;
--ci-white: #ffffff;
--ci-bg: #f5f5f5;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--ci-bg);
color: var(--ci-dark-gray);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
padding: 16px 24px;
border-bottom: 3px solid var(--ci-accent);
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
color: var(--ci-white);
font-size: 1.25rem;
font-weight: 600;
}
.container {
padding: 24px;
flex: 1;
overflow: auto;
}
.section {
background: var(--ci-white);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.section-title {
font-weight: 600;
margin-bottom: 16px;
color: var(--ci-dark-gray);
font-size: 0.95rem;
}
.controls-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--ci-medium-gray);
}
.form-input, .form-select {
padding: 8px 12px;
border: 2px solid var(--ci-light-gray);
border-radius: 6px;
font-size: 0.875rem;
font-family: inherit;
transition: border-color 0.2s;
min-width: 100px;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--ci-accent);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
cursor: pointer;
}
.checkbox-label input {
width: 16px;
height: 16px;
accent-color: var(--ci-accent);
}
.table-container {
overflow-x: auto;
margin-bottom: 20px;
}
.editable-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.editable-table th,
.editable-table td {
border: 1px solid var(--ci-light-gray);
padding: 0;
}
.editable-table th {
background: var(--ci-dark-gray);
color: var(--ci-white);
}
.editable-table input {
width: 100%;
padding: 10px 12px;
border: none;
font-family: inherit;
font-size: 0.875rem;
background: transparent;
}
.editable-table input:focus {
outline: 2px solid var(--ci-accent);
outline-offset: -2px;
background: rgba(229, 70, 31, 0.05);
}
.editable-table th input {
color: var(--ci-white);
font-weight: 600;
}
.editable-table th input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.preview-container {
background: var(--ci-black);
border-radius: 8px;
padding: 16px;
max-height: 250px;
overflow: auto;
}
.preview-content {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.4;
color: #00ff00;
white-space: pre;
}
.footer {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 24px;
background: var(--ci-white);
border-top: 1px solid var(--ci-light-gray);
}
.footer-left {
display: flex;
gap: 8px;
}
.footer-right {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: var(--ci-accent);
color: var(--ci-white);
}
.btn-primary:hover {
background: #c93a18;
transform: translateY(-1px);
}
.btn-secondary {
background: var(--ci-light-gray);
color: var(--ci-dark-gray);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.btn-outline {
background: transparent;
border: 2px solid var(--ci-accent);
color: var(--ci-accent);
}
.btn-outline:hover {
background: var(--ci-accent);
color: var(--ci-white);
}
.btn-sm {
padding: 6px 12px;
font-size: 0.75rem;
}
.quick-templates {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.template-chip {
padding: 6px 12px;
background: var(--ci-light-gray);
border-radius: 20px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.template-chip:hover {
background: var(--ci-accent);
color: var(--ci-white);
}
</style>
</head>
<body>
<div class="header">
<h1>Table Generator</h1>
</div>
<div class="container">
<div class="section">
<div class="section-title">Table Settings</div>
<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;">
</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;">
</div>
<div class="control-group">
<span class="control-label">Alignment</span>
<select id="alignment" class="form-select">
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
<div class="control-group" style="justify-content: flex-end;">
<label class="checkbox-label">
<input type="checkbox" id="has-header" checked>
Include Header Row
</label>
</div>
</div>
<div class="quick-templates">
<button class="template-chip" data-rows="3" data-cols="2">2-Column (3 rows)</button>
<button class="template-chip" data-rows="5" data-cols="3">3-Column (5 rows)</button>
<button class="template-chip" data-rows="4" data-cols="4">4x4 Grid</button>
<button class="template-chip" data-rows="10" data-cols="3">Data Table (10 rows)</button>
<button class="template-chip" data-rows="2" data-cols="5">Wide Table</button>
</div>
</div>
<div class="section">
<div class="section-title">Edit Table Content</div>
<div class="table-container">
<table class="editable-table" id="editable-table">
<!-- Generated dynamically -->
</table>
</div>
</div>
<div class="section">
<div class="section-title">Markdown Preview</div>
<div class="preview-container">
<div id="preview" class="preview-content"></div>
</div>
</div>
</div>
<div class="footer">
<div class="footer-left">
<button class="btn btn-secondary btn-sm" id="btn-clear">Clear All</button>
<button class="btn btn-outline btn-sm" id="btn-fill-sample">Fill Sample Data</button>
</div>
<div class="footer-right">
<button class="btn btn-secondary" id="btn-copy">Copy Markdown</button>
<button class="btn btn-primary" id="btn-insert">Insert to Editor</button>
</div>
</div>
<script>
let currentRows = 4;
let currentCols = 4;
let hasHeader = true;
let alignment = 'left';
// Generate the editable table
function generateEditableTable() {
const table = document.getElementById('editable-table');
hasHeader = document.getElementById('has-header').checked;
alignment = document.getElementById('alignment').value;
let html = '';
for (let i = 0; i < currentRows; i++) {
html += '<tr>';
for (let j = 0; j < currentCols; j++) {
const isHeader = hasHeader && i === 0;
const tag = isHeader ? 'th' : 'td';
const placeholder = isHeader ? `Header ${j + 1}` : `Cell ${i},${j + 1}`;
html += `<${tag}><input type="text" data-row="${i}" data-col="${j}" placeholder="${placeholder}"></${tag}>`;
}
html += '</tr>';
}
table.innerHTML = html;
// Add event listeners for live preview
table.querySelectorAll('input').forEach(input => {
input.addEventListener('input', updatePreview);
});
updatePreview();
}
// Get table data
function getTableData() {
const data = [];
for (let i = 0; i < currentRows; i++) {
const row = [];
for (let j = 0; j < currentCols; j++) {
const input = document.querySelector(`input[data-row="${i}"][data-col="${j}"]`);
row.push(input ? input.value || '' : '');
}
data.push(row);
}
return data;
}
// Generate markdown table
function generateMarkdown(data) {
if (data.length === 0 || data[0].length === 0) return '';
// Calculate column widths
const colWidths = [];
for (let j = 0; j < data[0].length; j++) {
let maxWidth = 3; // Minimum width for separator
for (let i = 0; i < data.length; i++) {
maxWidth = Math.max(maxWidth, (data[i][j] || '').length);
}
colWidths.push(maxWidth);
}
// Generate alignment function
const alignCell = (text, width) => {
text = text || '';
const padding = width - text.length;
if (alignment === 'center') {
const left = Math.floor(padding / 2);
const right = padding - left;
return ' '.repeat(left) + text + ' '.repeat(right);
} else if (alignment === 'right') {
return ' '.repeat(padding) + text;
}
return text + ' '.repeat(padding);
};
// Generate separator based on alignment
const getSeparator = (width) => {
if (alignment === 'center') {
return ':' + '-'.repeat(width - 2) + ':';
} else if (alignment === 'right') {
return '-'.repeat(width - 1) + ':';
}
return '-'.repeat(width);
};
let markdown = '';
const startRow = hasHeader ? 0 : -1;
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';
} else {
const row = data[i];
const cells = row.map((cell, j) => alignCell(cell, colWidths[j]));
markdown += '| ' + cells.join(' | ') + ' |\n';
}
// Add separator after first row
if ((hasHeader && i === 0) || (!hasHeader && i === -1)) {
const separators = colWidths.map(w => getSeparator(w));
markdown += '| ' + separators.join(' | ') + ' |\n';
}
}
return markdown;
}
// Update preview
function updatePreview() {
const data = getTableData();
const markdown = generateMarkdown(data);
document.getElementById('preview').textContent = markdown;
}
// Event listeners
document.getElementById('rows').addEventListener('change', (e) => {
currentRows = parseInt(e.target.value) || 4;
generateEditableTable();
});
document.getElementById('cols').addEventListener('change', (e) => {
currentCols = parseInt(e.target.value) || 4;
generateEditableTable();
});
document.getElementById('alignment').addEventListener('change', () => {
updatePreview();
});
document.getElementById('has-header').addEventListener('change', () => {
generateEditableTable();
});
// Quick templates
document.querySelectorAll('.template-chip').forEach(chip => {
chip.addEventListener('click', () => {
currentRows = parseInt(chip.dataset.rows);
currentCols = parseInt(chip.dataset.cols);
document.getElementById('rows').value = currentRows;
document.getElementById('cols').value = currentCols;
generateEditableTable();
});
});
// Clear all
document.getElementById('btn-clear').addEventListener('click', () => {
document.querySelectorAll('#editable-table input').forEach(input => {
input.value = '';
});
updatePreview();
});
// Fill sample data
document.getElementById('btn-fill-sample').addEventListener('click', () => {
const sampleHeaders = ['Name', 'Description', 'Status', 'Priority', 'Due Date'];
const sampleData = [
['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']
];
for (let i = 0; i < currentRows; i++) {
for (let j = 0; j < currentCols; j++) {
const input = document.querySelector(`input[data-row="${i}"][data-col="${j}"]`);
if (input) {
if (hasHeader && i === 0) {
input.value = sampleHeaders[j % sampleHeaders.length];
} else {
const dataRow = hasHeader ? i - 1 : i;
if (dataRow < sampleData.length && j < sampleData[dataRow].length) {
input.value = sampleData[dataRow][j];
} else {
input.value = `Data ${i}-${j + 1}`;
}
}
}
}
}
updatePreview();
});
// Copy markdown
document.getElementById('btn-copy').addEventListener('click', () => {
const markdown = document.getElementById('preview').textContent;
navigator.clipboard.writeText(markdown).then(() => {
const btn = document.getElementById('btn-copy');
btn.textContent = 'Copied!';
setTimeout(() => {
btn.textContent = 'Copy Markdown';
}, 2000);
});
});
// Insert to editor
document.getElementById('btn-insert').addEventListener('click', () => {
const content = document.getElementById('preview').textContent;
if (content && window.electronAPI) {
window.electronAPI.send('insert-generated-content', '\n' + content + '\n');
window.close();
}
});
// Initialize
generateEditableTable();
</script>
</body>
</html>
-78
View File
@@ -1,78 +0,0 @@
function createWelcomeContent(recentFiles = [], appVersion = '') {
const recentHtml = recentFiles.length
? 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('')
: '<p class="welcome-muted">No recent files</p>';
return `
<div class="welcome-container">
<div class="welcome-hero">
<h1 class="welcome-title">MarkdownConverter</h1>
<p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p>
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
</div>
<div class="welcome-grid">
<div class="welcome-section">
<h2>Quick Start</h2>
<div class="welcome-cards">
<div class="welcome-card" data-action="new-file">
<div class="welcome-card-icon">+</div>
<h3>New Document</h3>
<p>Create a blank document</p>
<kbd>Ctrl+N</kbd>
</div>
<div class="welcome-card" data-action="open-file">
<div class="welcome-card-icon">&#128194;</div>
<h3>Open File</h3>
<p>Open an existing file</p>
<kbd>Ctrl+O</kbd>
</div>
<div class="welcome-card" data-action="open-template">
<div class="welcome-card-icon">&#128203;</div>
<h3>From Template</h3>
<p>Start from a template</p>
</div>
<div class="welcome-card" data-action="command-palette">
<div class="welcome-card-icon">&#8984;</div>
<h3>Command Palette</h3>
<p>Search all actions</p>
<kbd>Ctrl+Shift+P</kbd>
</div>
</div>
</div>
<div class="welcome-section">
<h2>What's New in v4.x</h2>
<ul class="welcome-features">
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
<li><strong>Command Palette</strong> — Quick access to all actions</li>
<li><strong>Code Execution</strong> — Run JS, Python, Bash from code blocks</li>
<li><strong>Print Preview</strong> — Full print customization dialog</li>
<li><strong>New Formats</strong> — Reveal.js, YAML, JSON, Confluence, and more</li>
<li><strong>Spell Checking</strong> — System dictionary with suggestions</li>
<li><strong>Markdown Extensions</strong> — Footnotes, admonitions, TOC</li>
<li><strong>Image Handling</strong> — Paste and drag-drop images</li>
<li><strong>PlantUML</strong> — Diagram rendering in preview</li>
</ul>
</div>
<div class="welcome-section">
<h2>Recent Files</h2>
<div class="welcome-recent">${recentHtml}</div>
</div>
</div>
<div class="welcome-footer">
<label class="welcome-checkbox">
<input type="checkbox" id="show-welcome-startup" checked>
Show this tab on startup
</label>
</div>
</div>`;
}
module.exports = { createWelcomeContent };
-292
View File
@@ -1,292 +0,0 @@
/**
* Zen Mode — distraction-free writing mode
* Provides typewriter scrolling, line dimming, centered content, and a floating HUD.
* Toggle with F11, exit with Escape.
*/
const {
EditorView,
ViewPlugin,
Decoration,
DecorationSet,
} = require('@codemirror/view');
const { RangeSetBuilder } = require('@codemirror/state');
class ZenMode {
/**
* @param {import('./renderer').TabManager} tabManager
*/
constructor(tabManager) {
this.tabManager = tabManager;
this.active = false;
this._hud = null;
this._timerInterval = null;
this._sessionStart = null;
this._extensions = [];
}
activate() {
if (this.active) return;
this.active = true;
document.body.classList.add('zen-mode');
// Collapse sidebar
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.classList.add('collapsed');
// Hide preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.add('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.add('full-width');
// Add CM6 extensions to the active editor
if (tab.editorView) {
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,
// we store a reference and the CSS + updateListener handle the rest.
// The typewriter + dimming plugins are applied via a compartment approach
// would be ideal, but for simplicity we use DOM + updateListener.
this._applyTypewriterBehavior(tab.editorView);
}
}
// Create HUD
this._createHUD();
// Start session timer
this._sessionStart = Date.now();
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
// Focus editor
if (tab?.editorView) tab.editorView.focus();
}
deactivate() {
if (!this.active) return;
this.active = false;
document.body.classList.remove('zen-mode');
// Restore preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.remove('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.remove('full-width');
// Remove typewriter listener
if (tab.editorView && this._selectionListener) {
window.removeEventListener('zen-typewriter', this._selectionListener);
this._selectionListener = null;
}
}
// Remove HUD
if (this._hud && this._hud.parentNode) {
this._hud.parentNode.removeChild(this._hud);
this._hud = null;
}
// Stop timer
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
this._sessionStart = null;
this._extensions = [];
}
toggle() {
if (this.active) {
this.deactivate();
} else {
this.activate();
}
}
_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;
requestAnimationFrame(() => {
view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
});
};
// 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) {
clearInterval(this._scrollInterval);
this._scrollInterval = null;
return;
}
// Typewriter scroll: center the cursor line
try {
const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
const scroller = view.scrollDOM;
if (coords && scroller) {
const scrollerRect = scroller.getBoundingClientRect();
const cursorCenter = coords.top + (coords.bottom - coords.top) / 2;
const viewCenter = scrollerRect.top + scrollerRect.height / 2;
const diff = cursorCenter - viewCenter;
if (Math.abs(diff) > 30) {
scroller.scrollTop += diff;
}
}
} catch (e) {
// Ignore errors from destroyed views
}
}, 200);
// Initial scroll to center
scrollFn();
}
_createHUD() {
const hud = document.createElement('div');
hud.className = 'zen-hud';
hud.innerHTML = `
<span class="zen-hud-item" id="zen-words">0 words</span>
<span class="zen-hud-sep">&middot;</span>
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
<span class="zen-hud-sep">&middot;</span>
<span class="zen-hud-item" id="zen-timer">00:00</span>
<div class="zen-progress-container" id="zen-progress-container">
<div class="zen-progress-bar" id="zen-progress-bar"></div>
</div>
`;
document.body.appendChild(hud);
this._hud = hud;
this._updateHUD();
}
_updateHUD() {
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 readingTime = Math.max(1, Math.ceil(words / 200));
const wordsEl = document.getElementById('zen-words');
const readingEl = document.getElementById('zen-reading-time');
const timerEl = document.getElementById('zen-timer');
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
if (readingEl) readingEl.textContent = `${readingTime} min read`;
// Session timer
if (this._sessionStart) {
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
const seconds = String(elapsed % 60).padStart(2, '0');
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
}
// Word goal progress
const goalStr = localStorage.getItem('zen-word-goal');
const progressBar = document.getElementById('zen-progress-bar');
const progressContainer = document.getElementById('zen-progress-container');
if (progressBar && progressContainer) {
if (goalStr) {
const goal = parseInt(goalStr, 10);
if (goal > 0) {
const pct = Math.min(100, Math.round((words / goal) * 100));
progressBar.style.width = `${pct}%`;
progressContainer.style.display = 'block';
} else {
progressContainer.style.display = 'none';
}
} else {
progressContainer.style.display = 'none';
}
}
}
}
// Typewriter scroll ViewPlugin
const typewriterScrollExtension = ViewPlugin.fromClass(class {
constructor(view) {
this.view = view;
}
update(update) {
if (update.selectionSet || update.docChanged) {
const pos = update.state.selection.main.head;
requestAnimationFrame(() => {
if (this.view.dom && this.view.dom.isConnected) {
this.view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
}
});
}
}
});
// Line dimming ViewPlugin
function getOpacity(distance) {
if (distance === 0) return 1.0;
if (distance === 1) return 0.7;
if (distance === 2) return 0.6;
if (distance === 3) return 0.45;
return 0.3;
}
const lineDimmingExtension = ViewPlugin.fromClass(class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const builder = new RangeSetBuilder();
const pos = view.state.selection.main.head;
const activeLine = view.state.doc.lineAt(pos).number;
const doc = view.state.doc;
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
const line = doc.line(lineNum);
const distance = Math.abs(lineNum - activeLine);
const opacity = getOpacity(distance);
const deco = Decoration.line({
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
});
builder.add(line.from, line.from, deco);
}
return builder.finish();
}
}, {
decorations: v => v.decorations,
});
module.exports = { ZenMode };
-144
View File
@@ -1,144 +0,0 @@
/**
* @jest-environment jsdom
*/
/**
* Tests for CommandPalette
* Tests command registration, filtering, execution, and keyboard navigation
*/
describe('CommandPalette', () => {
let CommandPalette;
beforeEach(() => {
document.body.innerHTML = `
<div class="command-palette-overlay hidden" id="command-palette-overlay">
<div class="command-palette">
<input type="text" id="command-palette-input">
<div id="command-palette-results"></div>
</div>
</div>
`;
CommandPalette = require('../src/command-palette').CommandPalette;
});
test('starts hidden', () => {
const palette = new CommandPalette();
expect(palette.isOpen()).toBe(false);
});
test('opens and focuses input', () => {
const palette = new CommandPalette();
palette.open();
expect(palette.isOpen()).toBe(true);
});
test('closes', () => {
const palette = new CommandPalette();
palette.open();
palette.close();
expect(palette.isOpen()).toBe(false);
});
test('registers and renders commands', () => {
const palette = new CommandPalette();
palette.register('Test Command', 'Ctrl+T', () => {});
palette.register('Another Command', '', () => {});
palette.open();
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('filters commands by search', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.register('Open File', 'Ctrl+O', () => {});
palette.register('Bold Text', 'Ctrl+B', () => {});
palette.renderResults('file');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('executes command', () => {
const palette = new CommandPalette();
const action = jest.fn();
palette.register('Test', '', action);
palette.open();
palette.executeSelected();
expect(action).toHaveBeenCalled();
});
test('highlights matching text', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', 'save');
expect(result).toContain('<strong>');
expect(result).toContain('Save');
});
test('highlightMatch returns original text when no query', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', '');
expect(result).toBe('Save File');
});
test('filters case-insensitively', () => {
const palette = new CommandPalette();
palette.register('Save File', '', () => {});
palette.register('Open File', '', () => {});
palette.renderResults('SAVE');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(1);
});
test('shows all commands when query is empty', () => {
const palette = new CommandPalette();
palette.register('Cmd1', '', () => {});
palette.register('Cmd2', '', () => {});
palette.register('Cmd3', '', () => {});
palette.renderResults('');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(3);
});
test('displays shortcut when provided', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).not.toBeNull();
expect(shortcut.textContent).toBe('Ctrl+S');
});
test('does not display shortcut when empty', () => {
const palette = new CommandPalette();
palette.register('Test Command', '', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).toBeNull();
});
test('closes on overlay click', () => {
const palette = new CommandPalette();
palette.open();
// Simulate click on the overlay itself
const event = new Event('click', { bubbles: true });
Object.defineProperty(event, 'target', { value: palette.overlay });
palette.overlay.dispatchEvent(event);
expect(palette.isOpen()).toBe(false);
});
test('executeSelected does nothing when no commands', () => {
const palette = new CommandPalette();
palette.open();
// Should not throw
expect(() => palette.executeSelected()).not.toThrow();
});
test('closes after executing command', () => {
const palette = new CommandPalette();
palette.register('Test', '', jest.fn());
palette.open();
palette.executeSelected();
expect(palette.isOpen()).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Minimap } from '@/components/editor/Minimap';
describe('Minimap', () => {
it('renders lines of content as shrunk text', () => {
const content = 'line 1\nline 2\nline 3';
render(<Minimap content={content} />);
const lines = screen.getAllByTestId('minimap-line');
expect(lines).toHaveLength(3);
});
it('renders a viewport indicator', () => {
render(
<Minimap content={'line 1\nline 2\nline 3'} scrollRatio={0.5} visibleRatio={0.5} />
);
const indicator = screen.getByTestId('minimap-viewport');
expect(indicator).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Breadcrumb } from '@/components/layout/Breadcrumb';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
describe('Breadcrumb', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: true });
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2', dirty: false }]]) } as any);
});
it('shows "No file selected" when no tab is active', () => {
useFileStore.setState({ activeTabId: null, openTabs: [] } as any);
render(<Breadcrumb />);
expect(screen.getByText('No file selected')).toBeInTheDocument();
});
it('shows tab title when a tab is active', () => {
render(<Breadcrumb />);
expect(screen.getByText('test.md')).toBeInTheDocument();
});
it('shows heading symbols when breadcrumbSymbols is true', () => {
render(<Breadcrumb />);
expect(screen.getByText('test.md')).toBeInTheDocument();
expect(screen.getByText(/# H1/)).toBeInTheDocument();
});
it('does not show heading symbols when breadcrumbSymbols is false', () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: false });
render(<Breadcrumb />);
expect(screen.getByText('test.md')).toBeInTheDocument();
expect(screen.queryByText(/# H1/)).not.toBeInTheDocument();
});
it('limits heading symbols to 3', () => {
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2\n### H3\n#### H4', dirty: false }]]) } as any);
render(<Breadcrumb />);
// Should show H1, H2, H3 but not H4
expect(screen.getByText(/# H1/)).toBeInTheDocument();
expect(screen.getByText(/# H2/)).toBeInTheDocument();
expect(screen.getByText(/# H3/)).toBeInTheDocument();
expect(screen.queryByText(/# H4/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsciiGeneratorDialog } from '@/components/modals/AsciiGeneratorDialog';
vi.mock('@/lib/figlet', () => ({
figletText: vi.fn().mockResolvedValue(' MOCKED ASCII '),
FIGLET_FONTS: ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'],
}));
describe('AsciiGeneratorDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with an input textarea and font selector', () => {
render(<AsciiGeneratorDialog />);
expect(screen.getByText(/ascii generator/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /font/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /text/i })).toBeInTheDocument();
});
it('renders the ASCII output as the user types', async () => {
render(<AsciiGeneratorDialog />);
const input = screen.getByRole('textbox', { name: /text/i });
await userEvent.type(input, 'hi');
const pre = await screen.findByTestId('ascii-output');
expect(pre).toHaveTextContent('MOCKED ASCII');
});
it('shows a copy button next to the output', () => {
render(<AsciiGeneratorDialog />);
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FindInFilesDialog } from '@/components/modals/FindInFilesDialog';
import { useFileStore } from '@/stores/file-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
search: vi.fn(),
},
},
}));
import { ipc } from '@/lib/ipc';
import { useAppStore } from '@/stores/app-store';
describe('FindInFilesDialog', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useFileStore.setState({ rootPath: '/project' } as any);
useAppStore.setState({ modal: { kind: null } } as any);
});
it('renders with a query input and toggles', () => {
render(<FindInFilesDialog />);
expect(screen.getByText(/find in files/i)).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /query/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /regex/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /case/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument();
});
it('submits a search and shows results', async () => {
(ipc.file.search as any).mockResolvedValueOnce({
ok: true,
data: [
{ filePath: '/project/a.md', line: 5, content: 'matching line' },
{ filePath: '/project/b.md', line: 12, content: 'another match' },
],
});
render(<FindInFilesDialog />);
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
await userEvent.click(screen.getByRole('button', { name: /search/i }));
expect(await screen.findByText('/project/a.md:5')).toBeInTheDocument();
expect(await screen.findByText(/another match/)).toBeInTheDocument();
});
it('clicking a result calls useFileStore.openFile', async () => {
(ipc.file.search as any).mockResolvedValueOnce({
ok: true,
data: [{ filePath: '/project/a.md', line: 5, content: 'matching line' }],
});
const openFileSpy = vi.fn();
useFileStore.setState({ openFile: openFileSpy, rootPath: '/project' } as any);
render(<FindInFilesDialog />);
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
await userEvent.click(screen.getByRole('button', { name: /search/i }));
const result = await screen.findByText('/project/a.md:5');
await userEvent.click(result);
// openFile is called with the filePath; the test verifies it was called
expect(openFileSpy).toHaveBeenCalled();
});
it('shows an error banner when search fails', async () => {
(ipc.file.search as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'regex invalid' } });
render(<FindInFilesDialog />);
await userEvent.paste('[invalid', { initialSelectionStart: 0, initialSelectionEnd: 0 });
await userEvent.click(screen.getByRole('button', { name: /search/i }));
expect(await screen.findByText(/regex invalid/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TableGeneratorDialog } from '@/components/modals/TableGeneratorDialog';
describe('TableGeneratorDialog', () => {
beforeEach(() => {
localStorage.clear();
});
it('renders with rows, cols, and header inputs', () => {
render(<TableGeneratorDialog />);
expect(screen.getByText(/table generator/i)).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /rows/i })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: /cols/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /header/i })).toBeInTheDocument();
});
it('generates a markdown table on rows/cols change', async () => {
render(<TableGeneratorDialog />);
const rowsInput = screen.getByRole('spinbutton', { name: /rows/i });
const colsInput = screen.getByRole('spinbutton', { name: /cols/i });
await userEvent.clear(rowsInput);
await userEvent.type(rowsInput, '2');
await userEvent.clear(colsInput);
await userEvent.type(colsInput, '3');
// Output should have header + separator + 2 rows = 4 lines for a 3-col table with header
const output = screen.getByTestId('table-output');
expect(output.textContent).toContain('|');
});
it('shows a copy button next to the output', () => {
render(<TableGeneratorDialog />);
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { WordExportDialog } from '@/components/modals/WordExportDialog';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/lib/docx-export', () => ({
generateDocx: vi.fn().mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })),
}));
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/lib/ipc', () => ({
ipc: {
app: {
showSaveDialog: vi.fn(),
},
file: {
writeBuffer: vi.fn(),
},
},
}));
import { generateDocx } from '@/lib/docx-export';
import { toast } from '@/lib/toast';
import { ipc } from '@/lib/ipc';
describe('WordExportDialog', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
(ipc.app.showSaveDialog as any).mockResolvedValue({ ok: true, data: '/out.docx' });
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
});
it('renders with template selector (standard / custom)', () => {
render(<WordExportDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to word/i)).toBeInTheDocument();
expect(screen.getByText(/test\.md/)).toBeInTheDocument();
});
it('submitting calls generateDocx and writes the file', async () => {
render(<WordExportDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(generateDocx).toHaveBeenCalledTimes(1);
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md'));
});
it('shows error message on write failure', async () => {
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: false, error: { code: 'E', message: 'write failed' } });
render(<WordExportDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(await screen.findByText(/write failed/i)).toBeInTheDocument();
});
it('switches to custom template path when "Custom" is selected', async () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), docxCustomTemplatePath: '/my.dotx' });
render(<WordExportDialog sourcePath="/test.md" />);
expect(screen.getByText(/\/my\.dotx/)).toBeInTheDocument();
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
import { useFileStore } from '@/stores/file-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
gitStatus: vi.fn(),
},
},
}));
import { ipc } from '@/lib/ipc';
describe('GitStatusPanel', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it('shows "No folder open" when rootPath is null', () => {
useFileStore.setState({ rootPath: null } as any);
render(<GitStatusPanel />);
expect(screen.getByText(/no folder open/i)).toBeInTheDocument();
});
it('fetches and shows git status', async () => {
(ipc.file.gitStatus as any).mockResolvedValueOnce({
ok: true,
data: [
{ filePath: '/project/a.md', status: 'modified' },
{ filePath: '/project/b.md', status: 'added' },
],
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
expect(await screen.findByText('a.md')).toBeInTheDocument();
expect(await screen.findByText('b.md')).toBeInTheDocument();
});
it('shows "Working tree clean" when no changes', async () => {
(ipc.file.gitStatus as any).mockResolvedValueOnce({ ok: true, data: [] });
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
expect(await screen.findByText(/working tree clean/i)).toBeInTheDocument();
});
it('shows error state when gitStatus fails', async () => {
(ipc.file.gitStatus as any).mockResolvedValueOnce({
ok: false,
error: { code: 'IPC_ERROR', message: 'Not a git repository' },
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
// The helper text appears in a <p> element distinct from the error heading
expect(await screen.findByText('Not a git repository, or git not installed.')).toBeInTheDocument();
});
it('opens file on click', async () => {
const openFile = vi.fn();
(ipc.file.gitStatus as any).mockResolvedValueOnce({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
});
useFileStore.setState({ rootPath: '/project', openFile } as any);
render(<GitStatusPanel />);
const row = await screen.findByTestId('git-status-row');
await userEvent.click(row);
expect(openFile).toHaveBeenCalledWith('/project/a.md');
});
});
+1
View File
@@ -13,6 +13,7 @@ vi.mock('@/lib/ipc', () => ({
pickFile: vi.fn(), pickFile: vi.fn(),
read: vi.fn(), read: vi.fn(),
write: vi.fn(), write: vi.fn(),
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
}, },
}, },
})); }));
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PrintPreview } from '@/components/tools/PrintPreview';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
print: vi.fn().mockResolvedValue({ ok: true }),
},
}));
import { ipc } from '@/lib/ipc';
describe('PrintPreview', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useFileStore.setState({
activeTabId: '/test.md',
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
} as any);
useEditorStore.setState({
buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]),
} as any);
});
it('renders the buffer content', () => {
render(<PrintPreview onClose={() => {}} />);
expect(screen.getByRole('heading', { name: /hi/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
it('Print button calls ipc.print', async () => {
const onClose = vi.fn();
render(<PrintPreview onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /print/i }));
expect(ipc.print).toHaveBeenCalledTimes(1);
});
it('Close button calls onClose', async () => {
const onClose = vi.fn();
render(<PrintPreview onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReplPanel } from '@/components/tools/ReplPanel';
import { useSettingsStore } from '@/stores/settings-store';
describe('ReplPanel', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: true });
});
it('renders the textarea and preview when replOpen is true', () => {
render(<ReplPanel />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByText(/repl/i)).toBeInTheDocument();
});
it('does not render when replOpen is false', () => {
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: false });
const { container } = render(<ReplPanel />);
expect(container.firstChild).toBeNull();
});
it('typing in the textarea updates the preview', async () => {
render(<ReplPanel />);
const ta = screen.getByRole('textbox');
await userEvent.clear(ta);
await userEvent.type(ta, '# Hello');
// Wait for 300ms debounce to fire
await new Promise((r) => setTimeout(r, 400));
expect(screen.getByRole('heading', { name: /hello/i })).toBeInTheDocument();
});
});
+9 -4
View File
@@ -36,6 +36,7 @@ vi.mock('@/lib/ipc', () => ({
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }), pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }), pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
onChange: vi.fn(() => () => {}), onChange: vi.fn(() => () => {}),
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
}, },
menu: { menu: {
on: vi.fn(() => () => {}), on: vi.fn(() => () => {}),
@@ -54,6 +55,8 @@ describe('Phase 5 integration', () => {
activeTabId: null, activeTabId: null,
}); });
useEditorStore.setState({ buffers: new Map(), activeId: null }); useEditorStore.setState({ buffers: new Map(), activeId: null });
// Also clear the active buffer so breadcrumb symbols won't show stale heading text
useEditorStore.getState().buffers.clear();
useAppStore.setState({ useAppStore.setState({
sidebarVisible: true, sidebarVisible: true,
previewVisible: true, previewVisible: true,
@@ -128,12 +131,14 @@ describe('Phase 5 integration', () => {
], ],
activeTabId: '/a.md', activeTabId: '/a.md',
}); });
// Use a buffer content that won't conflict with tab title in breadcrumb symbols
useEditorStore.setState({ buffers: new Map([['/a.md', { id: '/a.md', path: '/a.md', content: '# Hello', dirty: false }], ['/b.md', { id: '/b.md', path: '/b.md', content: '# World', dirty: false }]]), activeId: '/a.md' });
render(<AppShell />); render(<AppShell />);
const aTab = screen.getByText('a.md').closest('[role="tab"]')!; // Use role="tab" to find tabs specifically, avoiding breadcrumb "a.md" text
const bTab = screen.getByText('b.md').closest('[role="tab"]')!; const tabs = screen.getAllByRole('tab');
expect(aTab).toHaveAttribute('aria-current', 'page'); expect(tabs[0]).toHaveAttribute('aria-current', 'page');
await act(async () => { await act(async () => {
fireEvent.click(bTab); fireEvent.click(tabs[1]);
}); });
expect(useFileStore.getState().activeTabId).toBe('/b.md'); expect(useFileStore.getState().activeTabId).toBe('/b.md');
}); });
+1
View File
@@ -35,6 +35,7 @@ vi.mock('@/lib/ipc', () => ({
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }), pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }), pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
onChange: vi.fn(() => () => {}), onChange: vi.fn(() => () => {}),
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
}, },
menu: { menu: {
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => { on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '@/App';
import { useAppStore } from '@/stores/app-store';
import { useCommandStore } from '@/stores/command-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
vi.mock('@/lib/ipc', () => ({
ipc: {
app: {
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
openExternal: vi.fn().mockResolvedValue({ ok: true }),
showSaveDialog: vi.fn().mockResolvedValue({ ok: true, data: '/out.docx' }),
},
file: {
read: vi.fn(),
write: vi.fn(),
writeBuffer: vi.fn().mockResolvedValue({ ok: true }),
list: vi.fn(),
pickFolder: vi.fn(),
pickFile: vi.fn(),
onChange: vi.fn(),
search: vi.fn().mockResolvedValue({ ok: true, data: [] }),
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
},
menu: { on: vi.fn(() => () => {}) },
print: { show: vi.fn().mockResolvedValue({ ok: true }) },
},
}));
describe('Phase 9 tools integration', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useAppStore.setState({ modal: { kind: null }, sidebarVisible: true, previewVisible: true, zenMode: false, paneSizes: { sidebar: 20, editor: 50, preview: 30 } } as any);
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), welcomeDismissed: true });
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]) } as any);
});
it('tools.ascii opens ascii-generator modal', () => {
registerMenuCommands();
render(<App />);
useCommandStore.getState().dispatch('tools.ascii');
expect(useAppStore.getState().modal).toEqual({ kind: 'ascii-generator' });
});
it('tools.table opens table-generator modal', () => {
registerMenuCommands();
render(<App />);
useCommandStore.getState().dispatch('tools.table');
expect(useAppStore.getState().modal).toEqual({ kind: 'table-generator' });
});
it('tools.findInFiles opens find-in-files modal', () => {
registerMenuCommands();
render(<App />);
useCommandStore.getState().dispatch('tools.findInFiles');
expect(useAppStore.getState().modal).toEqual({ kind: 'find-in-files' });
});
it('tools.exportWord opens export-word modal with active path', () => {
registerMenuCommands();
render(<App />);
useCommandStore.getState().dispatch('tools.exportWord');
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
});
it('tools.repl toggles replOpen setting', () => {
registerMenuCommands();
render(<App />);
expect(useSettingsStore.getState().replOpen).toBe(false);
useCommandStore.getState().dispatch('tools.repl');
expect(useSettingsStore.getState().replOpen).toBe(true);
});
it('view.zenMode toggles zenMode', () => {
registerMenuCommands();
render(<App />);
expect(useAppStore.getState().zenMode).toBe(false);
useCommandStore.getState().dispatch('view.zenMode');
expect(useAppStore.getState().zenMode).toBe(true);
});
});
-9
View File
@@ -48,8 +48,6 @@ describe('Preload Security', () => {
'process-pdf-operation', 'process-pdf-operation',
'get-pdf-page-count', 'get-pdf-page-count',
'select-pdf-folder', 'select-pdf-folder',
'open-ascii-generator',
'open-table-generator',
'insert-generated-content', 'insert-generated-content',
'save-pasted-image', 'save-pasted-image',
'load-template', 'load-template',
@@ -91,12 +89,9 @@ describe('Preload Security', () => {
'undo', 'undo',
'redo', 'redo',
'adjust-font-size', 'adjust-font-size',
'print-preview',
'print-preview-styled',
'show-export-dialog', 'show-export-dialog',
'show-batch-dialog', 'show-batch-dialog',
'show-universal-converter-dialog', 'show-universal-converter-dialog',
'show-table-generator',
'show-pdf-editor-dialog', 'show-pdf-editor-dialog',
'show-image-converter', 'show-image-converter',
'show-audio-converter', 'show-audio-converter',
@@ -119,13 +114,9 @@ describe('Preload Security', () => {
'pdf-operation-complete', 'pdf-operation-complete',
'pdf-operation-error', 'pdf-operation-error',
'pdf-operation-progress', 'pdf-operation-progress',
'show-ascii-generator-window',
'show-ascii-generator',
'show-table-generator-window',
'open-header-footer-dialog', 'open-header-footer-dialog',
'insert-content', 'insert-content',
'load-template-menu', 'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel', 'toggle-sidebar-panel',
'toggle-bottom-panel' 'toggle-bottom-panel'
]; ];
-109
View File
@@ -1,109 +0,0 @@
/**
* @jest-environment jsdom
*/
/**
* Tests for PrintPreview
* Tests print dialog options, open/close, and preview rendering
*/
describe('PrintPreview', () => {
beforeEach(() => {
// Mock electron require for executePrint
jest.mock('electron', () => ({
ipcRenderer: { send: jest.fn(), invoke: jest.fn() }
}), { virtual: true });
document.body.innerHTML = `
<div class="dialog-overlay hidden" id="print-preview-overlay">
<button id="print-preview-close"></button>
<button id="print-cancel"></button>
<button id="print-execute"></button>
<select id="print-paper-size"><option value="A4">A4</option><option value="Letter">Letter</option></select>
<select id="print-orientation"><option value="portrait">Portrait</option><option value="landscape">Landscape</option></select>
<select id="print-margins"><option value="default">Default</option></select>
<input type="range" id="print-scale" value="100">
<span id="print-scale-value">100%</span>
<input type="checkbox" id="print-headers" checked>
<input type="checkbox" id="print-background" checked>
<select id="print-pages"><option value="all">All</option><option value="custom">Custom</option></select>
<input type="text" id="print-page-range" class="hidden">
<iframe id="print-preview-frame"></iframe>
</div>
`;
});
test('getOptions returns default values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
const options = preview.getOptions();
expect(options.paperSize).toBe('A4');
expect(options.orientation).toBe('portrait');
expect(options.scale).toBe(100);
expect(options.headers).toBe(true);
expect(options.background).toBe(true);
expect(options.pages).toBe('all');
expect(options.margins).toBe('default');
expect(options.pageRange).toBe('');
});
test('opens and closes', () => {
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);
preview.close();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
});
test('updateScaleLabel reflects slider value', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
document.getElementById('print-scale').value = '75';
preview.updateScaleLabel();
expect(document.getElementById('print-scale-value').textContent).toBe('75%');
});
test('close button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
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);
});
test('cancel button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
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);
});
test('getOptions reflects changed values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
// Change paper size to Letter
const paperSelect = document.getElementById('print-paper-size');
paperSelect.value = 'Letter';
// Change scale
document.getElementById('print-scale').value = '50';
// Uncheck headers
document.getElementById('print-headers').checked = false;
const options = preview.getOptions();
expect(options.paperSize).toBe('Letter');
expect(options.scale).toBe(50);
expect(options.headers).toBe(false);
});
test('stores last content for refresh', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Hello World</p>');
expect(preview._lastContent).toBe('<p>Hello World</p>');
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
import { useCommandStore } from '@/stores/command-store';
import { useAppStore } from '@/stores/app-store';
import { useFileStore } from '@/stores/file-store';
import { useSettingsStore } from '@/stores/settings-store';
describe('Phase 9 commands', () => {
beforeEach(() => {
localStorage.clear();
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useAppStore.setState({ modal: { kind: null } } as any);
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
// Use resetToDefaults to restore store methods (setSetting/resetToDefaults)
useSettingsStore.getState().resetToDefaults();
});
it('tools.ascii opens ascii-generator modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('tools.ascii');
expect(useAppStore.getState().modal).toEqual({ kind: 'ascii-generator' });
});
it('tools.table opens table-generator modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('tools.table');
expect(useAppStore.getState().modal).toEqual({ kind: 'table-generator' });
});
it('tools.findInFiles opens find-in-files modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('tools.findInFiles');
expect(useAppStore.getState().modal).toEqual({ kind: 'find-in-files' });
});
it('tools.exportWord opens export-word modal with active path', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('tools.exportWord');
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
});
it('tools.repl toggles replOpen setting', () => {
registerMenuCommands();
expect(useSettingsStore.getState().replOpen).toBe(false);
useCommandStore.getState().dispatch('tools.repl');
expect(useSettingsStore.getState().replOpen).toBe(true);
useCommandStore.getState().dispatch('tools.repl');
expect(useSettingsStore.getState().replOpen).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useZenMode } from '@/hooks/use-zen-mode';
import { useAppStore } from '@/stores/app-store';
describe('useZenMode', () => {
beforeEach(() => {
localStorage.clear();
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: true });
});
it('attaches a keydown listener that exits zen mode on Escape', () => {
renderHook(() => useZenMode());
expect(useAppStore.getState().zenMode).toBe(true);
// Simulate Escape keydown
const event = new KeyboardEvent('keydown', { key: 'Escape' });
window.dispatchEvent(event);
expect(useAppStore.getState().zenMode).toBe(false);
});
it('does nothing if zen mode is already off', () => {
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: false });
const setZenMode = vi.fn();
useAppStore.setState({ setZenMode });
renderHook(() => useZenMode());
// No Escape dispatched; setZenMode should not be called
expect(setZenMode).not.toHaveBeenCalled();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { generateDocx } from '@/lib/docx-export';
describe('generateDocx', () => {
it('returns a Blob for a simple markdown string', async () => {
const blob = await generateDocx({ source: '# Hello\n\nWorld' });
expect(blob).toBeInstanceOf(Blob);
expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
});
it('converts headings to docx heading styles', async () => {
const blob = await generateDocx({ source: '# H1\n## H2\n### H3' });
// The blob should be a valid zip-based docx; check size and MIME
expect(blob.size).toBeGreaterThan(0);
});
it('converts markdown tables to preformatted (via applyAsciiTransform)', async () => {
const source = '| A | B |\n| - | - |\n| 1 | 2 |';
const blob = await generateDocx({ source });
expect(blob).toBeInstanceOf(Blob);
expect(blob.size).toBeGreaterThan(0);
});
});