mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8027d0b9b5 | ||
|
|
c0988a0108 | ||
|
|
18fc2317ae | ||
|
|
94dd62946a | ||
|
|
054ce2351a | ||
|
|
6651dccfdf | ||
|
|
848102905e | ||
|
|
6c7a86c99b | ||
|
|
b46fa9abaa | ||
|
|
2a32e702f3 | ||
|
|
356cfc5cc8 | ||
|
|
a03144df5b | ||
|
|
7fc11aed3b | ||
|
|
095f77f5f1 | ||
|
|
80afbb136b | ||
|
|
95bbe171b5 | ||
|
|
2d77fa5456 | ||
|
|
fbaff37505 | ||
|
|
1ccde4baa3 | ||
|
|
8822c9a2f9 | ||
|
|
324e5676b8 | ||
|
|
a62511e7ed | ||
|
|
a6c6e3696e | ||
|
|
1b2ab437d1 | ||
|
|
efc7709f66 | ||
|
|
a8f7547c7e | ||
|
|
094b52a2b3 | ||
|
|
24c6675c82 | ||
|
|
929d72cf1d | ||
|
|
b40bff641c | ||
|
|
338669f2db | ||
|
|
54615649f4 | ||
|
|
3ff76c8c33 | ||
|
|
8160ac05e0 | ||
|
|
6762b5f9af | ||
|
|
00485d1bef |
@@ -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,250 @@
|
|||||||
|
# Phase 8 — Toasts Design
|
||||||
|
|
||||||
|
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phases 8+9+10 are sketched at high level; this spec locks Phase 8's architecture, file map, and contracts so it can be planned task-by-task.)
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Phase:** 8 of 10 (React + shadcn/ui UI redesign)
|
||||||
|
**Tag (on completion):** `phase-8-toasts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal & Non-Goals
|
||||||
|
|
||||||
|
**Goal:** Add a toast notification layer to the React renderer using sonner (already installed). Surface user-initiated async action results (file save, file/folder open, exports) as transient toasts in the bottom-right of the window. The error banner pattern in export dialogs stays — toasts are a complementary global channel.
|
||||||
|
|
||||||
|
**Non-goals (Phase 8):**
|
||||||
|
- Undo/redo support (Phase 9)
|
||||||
|
- Custom toast actions/buttons (Phase 9, if needed)
|
||||||
|
- Persistent notification history
|
||||||
|
- Toast queueing/throttling for rapid-fire events
|
||||||
|
- Replacing inline error banners (they stay as per-dialog context)
|
||||||
|
|
||||||
|
**Scope decision:** Toast on user-initiated actions only. Skip silent background operations (loadChildren, etc.) to avoid noise. 4 wire points total: save (success+error), open file/folder (error only), 4 export dialogs (success+error).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
### 2.1 Toast helpers in `lib/toast.ts`
|
||||||
|
|
||||||
|
A thin typed layer over sonner. The helpers exist to:
|
||||||
|
- Give us a single import surface (instead of scattering `import { toast } from 'sonner'` across the codebase)
|
||||||
|
- Provide a place to add brand colors, formatting, or analytics later without touching call sites
|
||||||
|
- Make mocking easier in tests (one module to mock)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/renderer/lib/toast.ts (sketch)
|
||||||
|
import { toast as sonnerToast } from 'sonner';
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
success: (message: string) => sonnerToast.success(message),
|
||||||
|
error: (message: string) => sonnerToast.error(message),
|
||||||
|
info: (message: string) => sonnerToast.info(message),
|
||||||
|
warning: (message: string) => sonnerToast.warning(message),
|
||||||
|
promise: <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
msgs: { loading: string; success: string | ((data: T) => string); error: string | ((err: unknown) => string) }
|
||||||
|
) => sonnerToast.promise(promise, msgs),
|
||||||
|
dismiss: (id?: string | number) => sonnerToast.dismiss(id),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `<Toaster />` mounted in App.tsx
|
||||||
|
|
||||||
|
A canonical shadcn Toaster wrapper (manual paste per memory `shadcn-cli-blocked-manual-primitives`). Mounts alongside `<ModalLayer />`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/renderer/App.tsx (sketch)
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
useWelcomeTrigger();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppShell />
|
||||||
|
<ModalLayer />
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Toaster uses sonner's `<Toaster />` component with:
|
||||||
|
- `theme={resolvedTheme}` from `useTheme()` (so toasts match the user's light/dark preference)
|
||||||
|
- `richColors` for semantic success/error/info/warning colors
|
||||||
|
- `position="bottom-right"` (sonner default; explicit for clarity)
|
||||||
|
- `closeButton` (so users can dismiss)
|
||||||
|
|
||||||
|
### 2.3 Inline wiring at 4 wire points
|
||||||
|
|
||||||
|
**Pattern choice — inline in stores/dialogs, NOT middleware.** Matches the Phase 7 precedent of inline error banners in export dialogs. Middleware would be DRY but harder to debug and harder to control granularity (we want errors on save, but NOT on silent loadChildren).
|
||||||
|
|
||||||
|
**Wire points:**
|
||||||
|
|
||||||
|
1. **`useFileStore.saveActiveBuffer`** — wrap the existing logic:
|
||||||
|
```ts
|
||||||
|
saveActiveBuffer: async () => {
|
||||||
|
// ... existing logic to get buffer ...
|
||||||
|
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
||||||
|
if (!writeResult.ok) {
|
||||||
|
toast.error(`Failed to save: ${writeResult.error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ... existing markSaved/markTabClean ...
|
||||||
|
toast.success(`Saved ${title}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`useFileStore.openFile`** — error only:
|
||||||
|
```ts
|
||||||
|
openFile: async (filePath) => {
|
||||||
|
// ... existing logic to check existing tab ...
|
||||||
|
const result = await ipc.file.read(filePath);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open file: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... existing logic ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`useFileStore.openFolder`** — error only:
|
||||||
|
```ts
|
||||||
|
openFolder: async (path) => {
|
||||||
|
const result = await ipc.file.list(path);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open folder: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... existing logic ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **4 export dialogs** — both success and error (error complements the inline banner):
|
||||||
|
```ts
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
|
setError(result.error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. File Map
|
||||||
|
|
||||||
|
### 3.1 Created files
|
||||||
|
|
||||||
|
- `src/renderer/lib/toast.ts` — typed wrappers (re-export of sonner with our naming)
|
||||||
|
- `src/renderer/components/ui/sonner.tsx` — canonical shadcn Toaster wrapper (manual paste)
|
||||||
|
- `tests/unit/lib/toast.test.ts` — ~5 unit tests
|
||||||
|
- `tests/component/ui/sonner.test.tsx` — 1 smoke test
|
||||||
|
- `tests/integration/phase8-toasts-smoke.test.tsx` — ~4 integration tests
|
||||||
|
|
||||||
|
### 3.2 Modified files
|
||||||
|
|
||||||
|
- `src/renderer/App.tsx` — mount `<Toaster />` alongside `<ModalLayer />`
|
||||||
|
- `src/renderer/stores/file-store.ts` — add 3 toast calls (save, openFile, openFolder)
|
||||||
|
- `src/renderer/components/modals/ExportPdfDialog.tsx` — toast on submit result
|
||||||
|
- `src/renderer/components/modals/ExportDocxDialog.tsx` — toast on submit result
|
||||||
|
- `src/renderer/components/modals/ExportHtmlDialog.tsx` — toast on submit result
|
||||||
|
- `src/renderer/components/modals/ExportBatchDialog.tsx` — toast on submit result
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Flow
|
||||||
|
|
||||||
|
User action → store action OR export dialog submit → IPC call → result →
|
||||||
|
- if error: `toast.error(message)` → sonner renders in bottom-right portal
|
||||||
|
- if success: `toast.success(message)` → sonner renders
|
||||||
|
- inline error banner in dialog stays as per-dialog context (NOT removed)
|
||||||
|
|
||||||
|
Theme comes from `useTheme()` in the Toaster component, not from individual toasts. The Toaster is mounted once at the app level.
|
||||||
|
|
||||||
|
For the 4 export dialogs: the existing inline error banner stays (it's contextual to the dialog), AND a toast is fired. This gives the user both immediate context (in the dialog) and persistent notification (toast in corner).
|
||||||
|
|
||||||
|
For file save: ONLY a toast (no inline error UI in the editor pane — keeps the editor clean). Errors still show the toast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
- **Toast helpers never throw.** They are thin re-exports of sonner, which handles its own error cases (e.g., render failures, missing portal target).
|
||||||
|
- **In tests**, the `toast` module is mocked so calls don't render real toasts. Mock signature: `vi.mock('@/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), ... } }))`.
|
||||||
|
- **Theme fallback:** If `useTheme()` is loading or `resolvedTheme` is undefined, Toaster uses `'system'` as a fallback (sonner's default behavior).
|
||||||
|
- **Sonner missing:** If for some reason sonner fails to load, the toast calls become no-ops. The user still sees inline error banners in the export dialogs.
|
||||||
|
- **Inline error banners stay.** They are not replaced by toasts — they complement them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing Strategy
|
||||||
|
|
||||||
|
### 6.1 Unit tests (`tests/unit/lib/toast.test.ts`)
|
||||||
|
|
||||||
|
5 tests:
|
||||||
|
- `toast.success` calls sonner `toast.success` with the message
|
||||||
|
- `toast.error` calls sonner `toast.error` with the message
|
||||||
|
- `toast.info` calls sonner `toast.info`
|
||||||
|
- `toast.warning` calls sonner `toast.warning`
|
||||||
|
- `toast.promise` forwards the promise and messages to sonner
|
||||||
|
|
||||||
|
All tests mock the sonner module and verify the forwarded call.
|
||||||
|
|
||||||
|
### 6.2 Component test (`tests/component/ui/sonner.test.tsx`)
|
||||||
|
|
||||||
|
1 smoke test: `<Toaster />` renders without crashing inside a ThemeProvider.
|
||||||
|
|
||||||
|
### 6.3 Integration test (`tests/integration/phase8-toasts-smoke.test.tsx`)
|
||||||
|
|
||||||
|
~4 tests:
|
||||||
|
- Saving a file → `toast.success` called with `"Saved test.md"`
|
||||||
|
- Opening a non-existent file (ipc returns error) → `toast.error` called with file path in message
|
||||||
|
- Export success (ipc returns ok) → `toast.success` called + dialog closes
|
||||||
|
- Export failure (ipc returns error) → `toast.error` called + inline banner shown + dialog stays open
|
||||||
|
|
||||||
|
TDD throughout. Mocks for `ipc.*` and `lib/toast`.
|
||||||
|
|
||||||
|
### 6.4 Existing tests must still pass
|
||||||
|
|
||||||
|
- All 243 tests from Phase 7 must remain green
|
||||||
|
- Export dialog tests will need updates to verify the new toast calls (the dialog tests currently check for inline banner; they should ALSO verify the toast call)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & Open Questions
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- **Sonner theme switching:** If `useTheme()` returns `'system'` and the OS preference changes mid-session, sonner will re-render. This is the correct behavior; just confirm in test.
|
||||||
|
- **Toast spam:** If user holds Cmd+S, the save could fire many times. Sonner deduplicates by default for similar messages? → Decision: rely on sonner's default behavior. If spam becomes an issue, add a `toast.dismiss()` debounce in v2.
|
||||||
|
|
||||||
|
**Open questions (deferrable):**
|
||||||
|
- Should we add a custom toast for "undo last close" (Phase 9's undo feature)? → Decision: out of scope for Phase 8. Phase 9 will handle undo toasts.
|
||||||
|
- Should the export dialog inline banner be removed once toasts are wired? → Decision: NO — keep both. The inline banner is contextual to the dialog; the toast is global. They serve different purposes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of Scope (deferred to later phases)
|
||||||
|
|
||||||
|
- Phase 9: Undo toasts ("Tab closed — Undo" with action button)
|
||||||
|
- Phase 9: Long-running operation progress toasts (e.g., batch export queue)
|
||||||
|
- Phase 9: Custom toast variants for advanced tools (ASCII generator, table generator)
|
||||||
|
- Phase 10: Cleanup of inline error banners (will evaluate in Phase 10 if redundant)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Success Criteria
|
||||||
|
|
||||||
|
Phase 8 is complete when:
|
||||||
|
- `lib/toast.ts` exists with typed wrappers
|
||||||
|
- `<Toaster />` is mounted in `App.tsx` and renders toasts
|
||||||
|
- `useFileStore.saveActiveBuffer` calls `toast.success`/`toast.error` on result
|
||||||
|
- `useFileStore.openFile` calls `toast.error` on failure
|
||||||
|
- `useFileStore.openFolder` calls `toast.error` on failure
|
||||||
|
- 4 export dialogs call `toast.success`/`toast.error` on result
|
||||||
|
- All tests pass: ~+10 new tests, total ~253
|
||||||
|
- `npx vite build` succeeds
|
||||||
|
- Branch tagged `phase-8-toasts` and pushed to origin
|
||||||
@@ -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
@@ -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/**'
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+37
-4
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
+45
-955
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,13 +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 { 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 />
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
const extFor = (format: 'pdf' | 'docx' | 'html' | 'png') =>
|
const extFor = (format: 'pdf' | 'docx' | 'html' | 'png') =>
|
||||||
@@ -25,9 +26,11 @@ export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
|
|||||||
}));
|
}));
|
||||||
const result = await ipc.export.batch(items, { format, concurrency });
|
const result = await ipc.export.batch(items, { format, concurrency });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${sourcePaths.length} files`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||||
@@ -29,9 +30,11 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
renderTablesAsAscii: ascii,
|
renderTablesAsAscii: ascii,
|
||||||
} as any);
|
} as any);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||||
@@ -31,9 +32,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
renderTablesAsAscii: ascii,
|
renderTablesAsAscii: ascii,
|
||||||
} as any);
|
} as any);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
toast.error(`Export failed: ${result.error.message}`);
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||||
|
|
||||||
const MARGIN_MAP = {
|
const MARGIN_MAP = {
|
||||||
@@ -43,10 +44,12 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
renderTablesAsAscii: ascii,
|
renderTablesAsAscii: ascii,
|
||||||
fontSize,
|
fontSize,
|
||||||
} as any);
|
} as any);
|
||||||
if (!result.data?.ok) {
|
if (!result.ok) {
|
||||||
setError(result.data?.error?.message ?? 'Export failed');
|
toast.error(`Export failed: ${result.error?.message ?? 'Export failed'}`);
|
||||||
|
setError(result.error?.message ?? 'Export failed');
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
} else {
|
} else {
|
||||||
|
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&' }[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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
position="bottom-right"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -8,9 +8,15 @@ export function toAsciiTable(rows: string[][]): string {
|
|||||||
const widths: number[] = Array.from({ length: numCols }, (_, c) =>
|
const widths: number[] = Array.from({ length: numCols }, (_, c) =>
|
||||||
Math.max(...rows.map((r) => (r[c] ?? '').length))
|
Math.max(...rows.map((r) => (r[c] ?? '').length))
|
||||||
);
|
);
|
||||||
|
// Heuristic: if ANY column header starts with a digit, right-align ALL columns
|
||||||
|
const header = rows[0];
|
||||||
|
const rightAlign = header.some((cell) => /^\d/.test(cell));
|
||||||
|
const pad = rightAlign
|
||||||
|
? (s: string, w: number) => s.padStart(w)
|
||||||
|
: (s: string, w: number) => s.padEnd(w);
|
||||||
const lines = rows.map((row) =>
|
const lines = rows.map((row) =>
|
||||||
'| ' +
|
'| ' +
|
||||||
Array.from({ length: numCols }, (_, c) => (row[c] ?? '').padEnd(widths[c])).join(' | ') +
|
Array.from({ length: numCols }, (_, c) => pad(row[c] ?? '', widths[c])).join(' | ') +
|
||||||
' |'
|
' |'
|
||||||
);
|
);
|
||||||
// Insert separator after first row
|
// Insert separator after first row
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 ?? '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { toast as sonnerToast } from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed wrappers over sonner's toast API. Centralizing the import gives us
|
||||||
|
* a single surface to evolve (e.g., add brand colors, action buttons,
|
||||||
|
* analytics) without touching every call site.
|
||||||
|
*/
|
||||||
|
export const toast = {
|
||||||
|
success: (message: string) => sonnerToast.success(message),
|
||||||
|
error: (message: string) => sonnerToast.error(message),
|
||||||
|
info: (message: string) => sonnerToast.info(message),
|
||||||
|
warning: (message: string) => sonnerToast.warning(message),
|
||||||
|
promise: <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
msgs: {
|
||||||
|
loading: string;
|
||||||
|
success: string | ((data: T) => string);
|
||||||
|
error: string | ((err: unknown) => string);
|
||||||
|
}
|
||||||
|
) => sonnerToast.promise(promise, msgs),
|
||||||
|
dismiss: (id?: string | number) => sonnerToast.dismiss(id),
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import { immer } from 'zustand/middleware/immer';
|
import { immer } from 'zustand/middleware/immer';
|
||||||
import { enableMapSet } from 'immer';
|
import { enableMapSet } from 'immer';
|
||||||
import { ipc } from '@/lib/ipc';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import type { FileEntry } from '@/types/ipc';
|
import type { FileEntry } from '@/types/ipc';
|
||||||
|
|
||||||
@@ -79,7 +80,10 @@ export const useFileStore = create<FileState>()(
|
|||||||
|
|
||||||
openFolder: async (path) => {
|
openFolder: async (path) => {
|
||||||
const result = await ipc.file.list(path);
|
const result = await ipc.file.list(path);
|
||||||
if (!result.ok) return;
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open folder: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const children: FileNode[] = result.data!.map(entryToNode);
|
const children: FileNode[] = result.data!.map(entryToNode);
|
||||||
|
|
||||||
@@ -144,7 +148,10 @@ export const useFileStore = create<FileState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await ipc.file.read(filePath);
|
const result = await ipc.file.read(filePath);
|
||||||
if (!result.ok) return;
|
if (!result.ok) {
|
||||||
|
toast.error(`Failed to open file: ${result.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const content = result.data!;
|
const content = result.data!;
|
||||||
const title = filePath.split('/').pop() ?? filePath;
|
const title = filePath.split('/').pop() ?? filePath;
|
||||||
@@ -217,10 +224,15 @@ export const useFileStore = create<FileState>()(
|
|||||||
if (!buffer) return false;
|
if (!buffer) return false;
|
||||||
|
|
||||||
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
||||||
if (!writeResult.ok) return false;
|
if (!writeResult.ok) {
|
||||||
|
toast.error(`Failed to save: ${writeResult.error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
useEditorStore.getState().markSaved(activeTabId);
|
useEditorStore.getState().markSaved(activeTabId);
|
||||||
useFileStore.getState().markTabClean(activeTabId);
|
useFileStore.getState().markTabClean(activeTabId);
|
||||||
|
const title = useFileStore.getState().openTabs.find(t => t.id === activeTabId)?.title ?? activeTabId.split('/').pop() ?? activeTabId;
|
||||||
|
toast.success(`Saved ${title}`);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
|
||||||
@@ -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; }
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
@@ -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">📂</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">📋</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">⌘</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
@@ -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">·</span>
|
|
||||||
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
|
||||||
<span class="zen-hud-sep">·</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 };
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ describe('ExportPdfDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders an error banner when IPC fails', async () => {
|
it('renders an error banner when IPC fails', async () => {
|
||||||
(window.electronAPI.export.pdf as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'Pandoc not found' } });
|
(window.electronAPI.export.pdf as any).mockRejectedValueOnce(new Error('Pandoc not found'));
|
||||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/pandoc not found/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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
|
||||||
|
describe('Toaster', () => {
|
||||||
|
it('renders inside ThemeProvider without crashing', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ThemeProvider defaultTheme="light" attribute="class">
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
// The Toaster portal is mounted at body level, so the container may be empty
|
||||||
|
// but the test should not throw
|
||||||
|
expect(container).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,109 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
promise: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
app: {
|
||||||
|
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
|
||||||
|
openExternal: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
read: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
onChange: vi.fn(),
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
on: vi.fn(() => () => {}),
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: vi.fn(),
|
||||||
|
docx: vi.fn(),
|
||||||
|
html: vi.fn(),
|
||||||
|
batch: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import App from '@/App';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
|
||||||
|
|
||||||
|
describe('Phase 8 toasts 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: '/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: true }]]) } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saving a file calls toast.success with "Saved test.md"', async () => {
|
||||||
|
(ipc.file.write as any).mockResolvedValue({ ok: true });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Saved test.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saving with IPC error calls toast.error', async () => {
|
||||||
|
(ipc.file.write as any).mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to save: Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening a missing file calls toast.error', async () => {
|
||||||
|
(ipc.file.read as any).mockResolvedValue({ ok: false, error: { code: 'ENOENT', message: 'No such file' } });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/missing.md');
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to open file: No such file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exporting a file calls toast.success on success', async () => {
|
||||||
|
(ipc.export.pdf as any).mockResolvedValue({ ok: true, data: { outputPath: '/test.pdf', bytes: 100, durationMs: 50 } });
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Open the export-pdf modal via command dispatch
|
||||||
|
useCommandStore.getState().dispatch('file.exportPdf');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'export-pdf', props: { sourcePath: '/test.md' } });
|
||||||
|
|
||||||
|
// Click the Export button
|
||||||
|
const exportBtn = await screen.findByRole('button', { name: /^export$/i });
|
||||||
|
await userEvent.click(exportBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
promise: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { toast as sonnerToast } from 'sonner';
|
||||||
|
|
||||||
|
describe('toast helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.success forwards to sonner with the message', () => {
|
||||||
|
toast.success('Saved test.md');
|
||||||
|
expect(sonnerToast.success).toHaveBeenCalledWith('Saved test.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.error forwards to sonner with the message', () => {
|
||||||
|
toast.error('Failed to save: ENOENT');
|
||||||
|
expect(sonnerToast.error).toHaveBeenCalledWith('Failed to save: ENOENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.info forwards to sonner with the message', () => {
|
||||||
|
toast.info('Update available');
|
||||||
|
expect(sonnerToast.info).toHaveBeenCalledWith('Update available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.warning forwards to sonner with the message', () => {
|
||||||
|
toast.warning('Disk space low');
|
||||||
|
expect(sonnerToast.warning).toHaveBeenCalledWith('Disk space low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toast.promise forwards the promise and messages to sonner', async () => {
|
||||||
|
const promise = Promise.resolve('ok');
|
||||||
|
const msgs = { loading: 'Loading…', success: 'Done', error: 'Failed' };
|
||||||
|
toast.promise(promise, msgs);
|
||||||
|
expect(sonnerToast.promise).toHaveBeenCalledWith(promise, msgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
list: vi.fn(),
|
||||||
|
read: vi.fn(),
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
promise: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
const fakeRead = ipc.file.read as ReturnType<typeof vi.fn>;
|
||||||
|
const fakeWrite = ipc.file.write as ReturnType<typeof vi.fn>;
|
||||||
|
const fakeList = ipc.file.list as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe('file-store toasts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set<string>(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
} as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveActiveBuffer', () => {
|
||||||
|
it('calls toast.success when save succeeds', async () => {
|
||||||
|
fakeWrite.mockResolvedValue({ ok: true, data: undefined });
|
||||||
|
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: true }]]),
|
||||||
|
activeId: '/test.md',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Saved test.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls toast.error when save fails', async () => {
|
||||||
|
fakeWrite.mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||||
|
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: true }]]),
|
||||||
|
activeId: '/test.md',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to save: Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openFile', () => {
|
||||||
|
it('calls toast.error when IPC read fails', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: false, error: { code: 'ENOENT', message: 'No such file' } });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/missing.md');
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to open file: No such file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openFolder', () => {
|
||||||
|
it('calls toast.error when IPC list fails', async () => {
|
||||||
|
fakeList.mockResolvedValue({ ok: false, error: { code: 'EACCES', message: 'Permission denied' } });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFolder('/forbidden');
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to open folder: Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user