mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f0bf911a0 | ||
|
|
2cac075c0e | ||
|
|
94906a068a | ||
|
|
e72b863362 | ||
|
|
d705cfc30b | ||
|
|
02e307f758 | ||
|
|
5ad1d1d4b3 | ||
|
|
f480449301 |
@@ -0,0 +1,103 @@
|
|||||||
|
# CLAUDE.md — MarkdownConverter (master)
|
||||||
|
|
||||||
|
> General code-quality, JavaScript, git, security, and testing standards are in the **global CLAUDE.md**. This file holds project- and branch-specific notes.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Electron desktop app for Markdown editing and universal file conversion powered by Pandoc. Cross-platform (Win/macOS/Linux). Features: multi-tab editor with live preview, 25+ themes, PDF viewer/editor (merge/split/compress/rotate/watermark/password), export to 20+ formats (PDF/DOCX/ODT/EPUB/HTML/LaTeX/RTF/PPTX), batch conversion, syntax highlighting, diagram support (Mermaid), Git integration, and a plugin system.
|
||||||
|
|
||||||
|
- **Version:** 4.4.5
|
||||||
|
- **License:** MIT
|
||||||
|
- **App ID:** `com.concreteinfo.markdownconverter`
|
||||||
|
|
||||||
|
## Branch Specifics
|
||||||
|
|
||||||
|
This is the **primary/release branch** — a vanilla JavaScript Electron app with no bundler or framework in the renderer. The renderer is a single large `renderer.js` (5,300+ lines) loaded directly via `src/index.html`. All UI is hand-rolled DOM manipulation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Main Process (`src/main.js` — 4,260 lines)
|
||||||
|
Monolithic main process file. Contains all IPC handlers, Pandoc invocation, file operations, menu definitions (600+ lines), and window lifecycle. Key modules extracted:
|
||||||
|
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib` (merge, split, compress, rotate, delete, reorder, watermark, encrypt, decrypt, permissions)
|
||||||
|
- `src/main/GitOperations.js` — Git status/stage/commit/log via `simple-git`
|
||||||
|
|
||||||
|
### Renderer (`src/renderer.js` — 5,361 lines)
|
||||||
|
Vanilla JS, no framework. Directly manipulates DOM. Loads CodeMirror 6 via `src/editor/codemirror-setup.js`. Uses `marked` + `highlight.js` + `DOMPurify` + `mermaid` for rendering. Lazy-loads sidebar panels, REPL, command palette, zen mode.
|
||||||
|
|
||||||
|
### Preload (`src/preload.js` — 448 lines)
|
||||||
|
Exists as IPC bridge, but **`contextIsolation: false` and `nodeIntegration: true`** — the renderer has full Node access. Preload is effectively a thin passthrough.
|
||||||
|
|
||||||
|
### Security Model
|
||||||
|
- `contextIsolation: false` + `nodeIntegration: true` (legacy; the react-electron branch fixes this)
|
||||||
|
- Pandoc invoked via `execFile` (not `exec`) to prevent shell injection
|
||||||
|
- Path traversal protection: `validatePath()`, `resolveWritablePath()`, blocks sensitive system dirs
|
||||||
|
- Permission handler only allows `clipboard-read`/`clipboard-write`
|
||||||
|
- Rate limiter on conversions (2-second minimum interval)
|
||||||
|
- File size limit: 50MB
|
||||||
|
- Error message sanitization strips absolute paths
|
||||||
|
|
||||||
|
### Plugin System (`src/plugins/`)
|
||||||
|
Manifest-based discovery (`manifest.json`). Built-in `writing-studio` plugin with sprint/goal/snapshot management. Plugin API exposed via `src/plugins/plugin-api.js`.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
Custom JSON file store at `<userData>/settings.json` (NOT `electron-store` despite the dependency). Recent files at `<userData>/recent-files.json`.
|
||||||
|
|
||||||
|
## System Dependencies
|
||||||
|
|
||||||
|
| Dependency | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x |
|
||||||
|
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. Must be present for DOCX/ODT/EPUB/LaTeX/PPTX export. |
|
||||||
|
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` for packaged builds |
|
||||||
|
| **MiKTeX / TeX Live** | Optional | For LaTeX PDF export; MiKTeX PATH injected on Windows automatically |
|
||||||
|
| **ImageMagick** | Optional | Linux image conversion; listed as deb dependency |
|
||||||
|
| **LibreOffice** | Optional | Enhanced document conversion; listed as deb dependency |
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # Launch Electron app (dev mode)
|
||||||
|
npm test # Jest test suite
|
||||||
|
npm test:watch # Jest in watch mode
|
||||||
|
npm test:coverage # Jest with coverage report
|
||||||
|
npm run lint # ESLint check (src + tests)
|
||||||
|
npm run lint:fix # ESLint auto-fix
|
||||||
|
npm run format # Prettier write
|
||||||
|
npm run format:check # Prettier check only
|
||||||
|
npm run download-tools # Download Pandoc binaries to bin/
|
||||||
|
npm run generate-icons # Generate app icons via sharp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & Package
|
||||||
|
|
||||||
|
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json` (no separate config file).
|
||||||
|
|
||||||
|
| Target | Platforms |
|
||||||
|
|---|---|
|
||||||
|
| `npm run build` | electron-builder (default platform) |
|
||||||
|
| `npm run build:win` | Windows: NSIS installer + portable + zip (x64) |
|
||||||
|
| `npm run build:mac` | macOS: default dmg |
|
||||||
|
| `npm run build:linux` | Linux: deb + AppImage + snap |
|
||||||
|
| `npm run dist` | Build without publish |
|
||||||
|
| `npm run dist:all` | Build for all platforms |
|
||||||
|
|
||||||
|
**Bundled with builds:** Pandoc binary per platform. FFmpeg via `ffmpeg-static` (asarUnpacked). NSIS installer uses custom script at `scripts/nsis-installer.nsh`.
|
||||||
|
|
||||||
|
**Output:** `dist/` directory.
|
||||||
|
|
||||||
|
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
|
||||||
|
|
||||||
|
## Project Conventions / Gotchas
|
||||||
|
|
||||||
|
- **No bundler/transpilation.** The app uses vanilla CommonJS JavaScript. `src/main.js` is loaded directly by Electron. No webpack, no Vite, no TypeScript, no Babel.
|
||||||
|
- **Monolithic files.** `main.js` (4,260 lines) and `renderer.js` (5,361 lines) contain most logic. Not ideal but is the current state of this branch.
|
||||||
|
- **CodeMirror 6** for the editor, configured in `src/editor/codemirror-setup.js`.
|
||||||
|
- **PDF rendering** uses `pdfjs-dist`; **PDF manipulation** uses `pdf-lib` in the main process.
|
||||||
|
- **Renderer security is weak** — full Node access in renderer. Do NOT introduce new privileged renderer code without understanding this.
|
||||||
|
- **Pandoc is external.** Must be installed separately or downloaded via `npm run download-tools`. HTML and built-in PDF export work without Pandoc; other formats require it.
|
||||||
|
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
|
||||||
|
- **ESLint flat config** (`eslint.config.js`) with ECMAScript 2022. Prettier with 2-space indent, single quotes, semicolons, 100-char width.
|
||||||
|
- **Tests:** Jest with jsdom environment, 15% coverage threshold. 24 test files in `tests/`.
|
||||||
|
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
|
||||||
|
- **Single instance lock** enforced via `app.requestSingleInstanceLock()`.
|
||||||
|
- **Adapters layer** (`src/adapters/`) abstracts file system operations for potential future non-Electron targets.
|
||||||
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
v4.1.0
|
v4.4.5
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 361 KiB |
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "src/renderer/styles/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,399 +0,0 @@
|
|||||||
# Phase 7 — Modals Design
|
|
||||||
|
|
||||||
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phases 7+8+9+10 are sketched there at high level; this spec locks Phase 7's architecture, file map, and contracts so it can be planned task-by-task.)
|
|
||||||
|
|
||||||
**Date:** 2026-06-05
|
|
||||||
**Phase:** 7 of 10 (React + shadcn/ui UI redesign)
|
|
||||||
**Tag (on completion):** `phase-7-modals`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Goal & Non-Goals
|
|
||||||
|
|
||||||
**Goal:** Add a layered modal system to the React renderer. Wire 7 modal types (SettingsSheet + 4 Export dialogs + About + Welcome + Confirm) to a single `<ModalLayer />`. Add a persisted `useSettingsStore` for user preferences. The modals are opened via the command store (Phase 6 pattern), and the modals themselves read/write settings via the new store.
|
|
||||||
|
|
||||||
**Non-goals (Phase 7):**
|
|
||||||
- Implement the actual export pipelines (PDF/DOCX/HTML/PNG generation) — those are main-process concerns, already implemented. Phase 7 only adds the renderer-side dialog UI.
|
|
||||||
- Real plugin system — the Plugins tab is a placeholder ("Coming soon").
|
|
||||||
- Toast notifications — Phase 8.
|
|
||||||
- Advanced tools (Zen mode, REPL, ASCII/Table generators, Print preview) — Phase 9.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture
|
|
||||||
|
|
||||||
### 2.1 Modal state lives in `useAppStore` (extended, not new store)
|
|
||||||
|
|
||||||
`useAppStore` is already the "global UI" store (sidebar, preview, zen, paneSizes). It's the right home for modal state because:
|
|
||||||
- It's already mounted.
|
|
||||||
- It's already persisted (via `zustand persist` with `partialize` for pane sizes).
|
|
||||||
- A separate `useUIStore` would be YAGNI.
|
|
||||||
|
|
||||||
**Add to `AppState`:**
|
|
||||||
- `modal: ModalState` (discriminated union — see §2.2)
|
|
||||||
- `openModal: <K extends ModalKind>(kind: K, props?: ModalPropsFor<K>) => void`
|
|
||||||
- `closeModal: () => void`
|
|
||||||
|
|
||||||
**Persistence:** `modal` is **runtime-only**, like `userBindings` in `useCommandStore`. We add it to the `partialize` function so only the persisted fields (`sidebarVisible`, `previewVisible`, `zenMode`, `paneSizes`) are saved. The modal kind never needs to survive a reload.
|
|
||||||
|
|
||||||
### 2.2 Discriminated-union modal shape
|
|
||||||
|
|
||||||
```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: 'settings' }
|
|
||||||
| { kind: 'about' }
|
|
||||||
| { kind: 'welcome' }
|
|
||||||
| { kind: 'confirm'; props: ConfirmProps };
|
|
||||||
|
|
||||||
export interface ConfirmProps {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
confirmLabel?: string; // default "Confirm"
|
|
||||||
cancelLabel?: string; // default "Cancel"
|
|
||||||
destructive?: boolean; // switches confirm button to red variant
|
|
||||||
onConfirm: () => void | Promise<void>;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why a discriminated union:** every component that opens a modal must pass the right `props` shape for the `kind` — TypeScript catches mismatches at compile time. A generic `{ open: boolean, type: string }` shape would defer the error to runtime.
|
|
||||||
|
|
||||||
### 2.3 Single `<ModalLayer />`
|
|
||||||
|
|
||||||
Mounted at the bottom of `App.tsx`. Reads `modal.kind` from `useAppStore`, renders the matching component (or `null`). Each child modal calls `closeModal()` on dismiss.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// src/renderer/components/modals/ModalLayer.tsx (sketch)
|
|
||||||
export function ModalLayer() {
|
|
||||||
const modal = useAppStore((s) => s.modal);
|
|
||||||
switch (modal.kind) {
|
|
||||||
case null: return null;
|
|
||||||
case 'export-pdf': return <ExportPdfDialog {...modal.props} />;
|
|
||||||
// ... etc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`<ModalLayer />` ensures only one modal is visible at a time (the store only holds one). This is correct for v1 — no need for stacking/replacement transitions in Phase 7.
|
|
||||||
|
|
||||||
### 2.4 Settings store (new, separate)
|
|
||||||
|
|
||||||
A new `useSettingsStore` for user preferences. **Why separate from `useAppStore`:** settings is a different lifecycle. `useAppStore` is "current view configuration"; `useSettingsStore` is "user preferences that survive across sessions and are read by many features". Same precedent as `useFileStore` (file tree state) being separate from `useAppStore` (UI chrome state).
|
|
||||||
|
|
||||||
**Persistence:** `zustand persist` with `partialize` to serialize only the leaf settings (matching the pattern in `useFileStore` and `useCommandStore`).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface SettingsState {
|
|
||||||
// Editor
|
|
||||||
fontSize: number; // 12-20, default 14
|
|
||||||
tabSize: number; // 2 | 4 | 8, default 4
|
|
||||||
lineNumbers: boolean; // default true
|
|
||||||
wordWrap: boolean; // default true
|
|
||||||
minimap: boolean; // default true
|
|
||||||
// Theme
|
|
||||||
theme: 'light' | 'dark' | 'auto'; // default 'auto'
|
|
||||||
accentColor: 'brand' | 'blue' | 'green' | 'purple' | 'orange'; // default 'brand'
|
|
||||||
fontFamily: 'system' | 'jetbrains' | 'fira'; // default 'system'
|
|
||||||
// Export
|
|
||||||
pdfFormat: 'letter' | 'a4' | 'legal'; // default 'a4'
|
|
||||||
pdfMargins: 'normal' | 'narrow' | 'wide'; // default 'normal'
|
|
||||||
pdfEmbedFonts: boolean; // default true
|
|
||||||
docxTemplate: 'standard' | 'minimal' | 'modern'; // default 'standard'
|
|
||||||
htmlHighlightStyle: 'github' | 'monokai' | 'nord' | 'none'; // default 'github'
|
|
||||||
// ASCII table formatting — applies to all 3 single-file export formats
|
|
||||||
renderTablesAsAscii: boolean; // default false
|
|
||||||
// First-launch / Welcome
|
|
||||||
welcomeDismissed: boolean; // default false
|
|
||||||
// Actions
|
|
||||||
setSetting: <K extends keyof Omit<SettingsState, ...>>(...);
|
|
||||||
resetToDefaults: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Template-based exports** (per user request): `docxTemplate` is one of `'standard' | 'minimal' | 'modern'`. The export dialog shows a Select with the available templates. The IPC layer (`ipc.export.docx`) already accepts a `template` field; Phase 7 just exposes it. The main process maps these template names to actual `.docx` template files bundled with the app.
|
|
||||||
|
|
||||||
**ASCII table formatting** (per user request): `renderTablesAsAscii` is a toggle in the Settings sheet (Export tab) and in each of the 3 single-file export dialogs as an inline checkbox override. When true, the markdown AST's table nodes are converted to fixed-width monospace text (using a small `lib/ascii-table.ts` helper) *before* the export pipeline sees them. The preview pane is unaffected — this is export-time only.
|
|
||||||
|
|
||||||
### 2.5 WelcomeDialog trigger logic
|
|
||||||
|
|
||||||
A small `useEffect` in `App.tsx`:
|
|
||||||
```ts
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useSettingsStore.getState().welcomeDismissed) {
|
|
||||||
useAppStore.getState().openModal('welcome');
|
|
||||||
}
|
|
||||||
}, []); // run once on mount
|
|
||||||
```
|
|
||||||
|
|
||||||
The Help menu registers a `help.welcome` command that simply calls `openModal('welcome')` — does NOT reset the `welcomeDismissed` flag. (Decision in §2.4 of the brainstorming.)
|
|
||||||
|
|
||||||
### 2.6 Commands trigger modals
|
|
||||||
|
|
||||||
The command store (Phase 6) gets new commands. Registered in `src/renderer/lib/commands/register-menu-commands.ts`:
|
|
||||||
|
|
||||||
| Command ID | Handler |
|
|
||||||
|------------------|------------------------------------------------------|
|
|
||||||
| `file.exportPdf` | `openModal('export-pdf', { sourcePath: activePath })` |
|
|
||||||
| `file.exportDocx` | `openModal('export-docx', { sourcePath: activePath })` |
|
|
||||||
| `file.exportHtml` | `openModal('export-html', { sourcePath: activePath })` |
|
|
||||||
| `file.exportBatch` | `openModal('export-batch', { sourcePaths: openFiles })` |
|
|
||||||
| `settings.open` | `openModal('settings')` |
|
|
||||||
| `help.welcome` | `openModal('welcome')` |
|
|
||||||
| `help.about` | `openModal('about')` |
|
|
||||||
| `file.confirmClose` | opens confirm dialog before closing a dirty tab |
|
|
||||||
| `app.quit` | opens confirm if dirty tabs exist, else quits |
|
|
||||||
|
|
||||||
`settings.open` and `help.about` also get buttons in `AppHeader` (already partly done in Phase 6 — we just add new icons and wire to the new commands).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. File Map
|
|
||||||
|
|
||||||
### 3.1 shadcn primitives (manually created, per the shadcn-CLI-blocked memory)
|
|
||||||
|
|
||||||
Created in `src/renderer/components/ui/`:
|
|
||||||
- `dialog.tsx` — Radix Dialog wrapper with motion preset
|
|
||||||
- `sheet.tsx` — Radix Dialog (side variant) for SettingsSheet
|
|
||||||
- `tabs.tsx` — Radix Tabs for the 5-tab SettingsSheet
|
|
||||||
- `input.tsx` — text input
|
|
||||||
- `textarea.tsx` — multi-line input (for confirm body, welcome copy)
|
|
||||||
- `select.tsx` — Radix Select for theme/font/template pickers
|
|
||||||
- `switch.tsx` — Radix Switch for boolean settings
|
|
||||||
- `checkbox.tsx` — Radix Checkbox for "don't show again" toggles
|
|
||||||
- `slider.tsx` — Radix Slider for fontSize
|
|
||||||
- `label.tsx` — Radix Label (always pair with form fields)
|
|
||||||
- `form.tsx` — react-hook-form glue components (FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage)
|
|
||||||
- `radio-group.tsx` — Radix RadioGroup for accent color / template
|
|
||||||
|
|
||||||
### 3.2 Modals (`src/renderer/components/modals/`)
|
|
||||||
|
|
||||||
- `ModalLayer.tsx` — root, mounted by `App.tsx`
|
|
||||||
- `ExportPdfDialog.tsx` — PDF options (format, margins, embed fonts, ascii tables)
|
|
||||||
- `ExportDocxDialog.tsx` — DOCX options (template picker: standard/minimal/modern, ascii tables)
|
|
||||||
- `ExportHtmlDialog.tsx` — HTML options (standalone, highlight style, ascii tables)
|
|
||||||
- `ExportBatchDialog.tsx` — batch queue (format, concurrency, file list)
|
|
||||||
- `SettingsSheet.tsx` — 5-tab sheet (side="right", 480px wide)
|
|
||||||
- `EditorSettings.tsx` — font size, tab size, line numbers, word wrap, minimap
|
|
||||||
- `ThemeSettings.tsx` — light/dark/auto, accent color, font family
|
|
||||||
- `ExportSettings.tsx` — pdf format, margins, embed fonts, docx template, html highlight, ascii tables
|
|
||||||
- `PluginsSettings.tsx` — "Coming soon" placeholder
|
|
||||||
- `AboutSettings.tsx` — app version, links, acknowledgements
|
|
||||||
- `AboutDialog.tsx` — simple read-only dialog with version + GitHub link
|
|
||||||
- `WelcomeDialog.tsx` — first-launch dialog with quick-start cards
|
|
||||||
- `ConfirmDialog.tsx` — generic confirmation (title, body, destructive, onConfirm)
|
|
||||||
- `ExportDialogFooter.tsx` — shared Cancel / Export button row used by the 4 export dialogs
|
|
||||||
- `useExportSource.ts` — shared hook: reads active buffer, validates, returns source string + path
|
|
||||||
|
|
||||||
### 3.3 Stores
|
|
||||||
|
|
||||||
- **Modify** `src/renderer/stores/app-store.ts` — add `modal` + `openModal` + `closeModal`; update `partialize` to exclude `modal`
|
|
||||||
- **Create** `src/renderer/stores/settings-store.ts` — new
|
|
||||||
|
|
||||||
### 3.4 Lib
|
|
||||||
|
|
||||||
- **Create** `src/renderer/lib/validators.ts` — zod schemas:
|
|
||||||
- `settingsSchema` (whole settings object)
|
|
||||||
- `exportPdfSchema` (format, margins, embedFonts, renderTablesAsAscii)
|
|
||||||
- `exportDocxSchema` (template, renderTablesAsAscii)
|
|
||||||
- `exportHtmlSchema` (standalone, highlightStyle, renderTablesAsAscii)
|
|
||||||
- `exportBatchSchema` (format, concurrency, file list)
|
|
||||||
- `confirmPropsSchema` (for the confirm dialog)
|
|
||||||
- **Create** `src/renderer/lib/ascii-table.ts` — `toAsciiTable(rows: string[][]): string` (the small helper that converts a 2D string array to a fixed-width ASCII table)
|
|
||||||
- **Create** `src/renderer/lib/modal-triggers.ts` — small helpers: `useWelcomeTrigger()`, `useQuitGuard()`
|
|
||||||
|
|
||||||
### 3.5 Modified files
|
|
||||||
|
|
||||||
- **Modify** `src/renderer/App.tsx` — mount `<ModalLayer />` at the bottom; add the `useWelcomeTrigger` `useEffect` for first-launch
|
|
||||||
- **Modify** `src/renderer/lib/commands/register-menu-commands.ts` — add the 9 new commands (4 export + settings + welcome + about + confirmClose + quit)
|
|
||||||
- **Modify** `src/renderer/components/layout/AppHeader.tsx` — add Settings (gear) and About (info) icon buttons that dispatch the new commands
|
|
||||||
- **Modify** `src/main.js` (verify) — no changes expected; menu items already wire to `menu:action` channels that flow through `useBridgeNativeMenu`. If any new menu items need IPC channels, add them in the main process mirror.
|
|
||||||
|
|
||||||
### 3.6 Tests
|
|
||||||
|
|
||||||
**Unit (`tests/unit/`):**
|
|
||||||
- `stores/settings-store.test.ts` (5-6 tests: defaults, setSetting, resetToDefaults, persistence/partialize)
|
|
||||||
- `stores/app-store.test.ts` extended (3 tests: openModal sets state, closeModal clears, only one modal at a time)
|
|
||||||
- `lib/validators.test.ts` (3 tests: each schema rejects bad input)
|
|
||||||
- `lib/ascii-table.test.ts` (3 tests: simple table, alignment, empty input)
|
|
||||||
|
|
||||||
**Component (`tests/component/modals/`):**
|
|
||||||
- `ExportPdfDialog.test.tsx` (4 tests: renders with default settings, submit calls ipc.export.pdf with merged opts, error renders inline, ascii-table toggle flows through)
|
|
||||||
- `ExportDocxDialog.test.tsx` (3 tests: renders, submit includes template, ascii-table toggle)
|
|
||||||
- `ExportHtmlDialog.test.tsx` (3 tests: renders, highlight style select, ascii-table toggle)
|
|
||||||
- `ExportBatchDialog.test.tsx` (3 tests: renders file list, format selector, concurrency)
|
|
||||||
- `SettingsSheet.test.tsx` (6 tests: renders 5 tabs, each tab shows correct fields, settings change persists)
|
|
||||||
- `AboutDialog.test.tsx` (2 tests: renders version, links open external)
|
|
||||||
- `WelcomeDialog.test.tsx` (3 tests: renders, dismiss sets welcomeDismissed, "don't show again" checked)
|
|
||||||
- `ConfirmDialog.test.tsx` (3 tests: confirm calls onConfirm and closes, cancel calls closeModal, destructive variant)
|
|
||||||
- `ModalLayer.test.tsx` (3 integration tests: null kind renders nothing, switching kinds replaces modal, modal unmounts on close)
|
|
||||||
|
|
||||||
**Integration (`tests/integration/`):**
|
|
||||||
- `phase7-modals-smoke.test.tsx` (4 tests: dispatch command opens modal, command store + settings store + IPC all wired, app.tsx mount triggers welcome on first launch, modal layer end-to-end)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Data Flow
|
|
||||||
|
|
||||||
### 4.1 Open a modal
|
|
||||||
```ts
|
|
||||||
// From any command handler in register-menu-commands.ts:
|
|
||||||
useAppStore.getState().openModal('export-pdf', { sourcePath: activePath });
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 The dialog reads source
|
|
||||||
```ts
|
|
||||||
// ExportPdfDialog.tsx
|
|
||||||
const { source, path } = useExportSource();
|
|
||||||
if (!source) return <EmptySourceFallback />;
|
|
||||||
```
|
|
||||||
|
|
||||||
`useExportSource` is a small hook that:
|
|
||||||
1. Reads `useFileStore.activeTabId` + `useEditorStore.buffers`
|
|
||||||
2. If no active buffer, prompts the user to open a file (uses confirm dialog)
|
|
||||||
3. Returns `{ source: string, path: string } | null`
|
|
||||||
|
|
||||||
### 4.3 Settings change
|
|
||||||
```ts
|
|
||||||
// EditorSettings.tsx — switches/inputs call setSetting
|
|
||||||
const [fontSize, setFontSize] = useSettingsStore(s => [s.fontSize, s.setSetting]);
|
|
||||||
// or
|
|
||||||
setSetting('fontSize', 16);
|
|
||||||
```
|
|
||||||
|
|
||||||
Editor and preview subscribe to specific slices. The `useTheme` hook from `next-themes` is augmented to read `theme: 'light' | 'dark' | 'auto'` from `useSettingsStore` (replacing the standalone next-themes default).
|
|
||||||
|
|
||||||
### 4.4 Export flow
|
|
||||||
```ts
|
|
||||||
// ExportPdfDialog on submit:
|
|
||||||
const settings = useSettingsStore.getState();
|
|
||||||
const result = await ipc.export.pdf({
|
|
||||||
inputPath: path,
|
|
||||||
outputPath: chosenOutputPath,
|
|
||||||
format: dialogFormat ?? settings.pdfFormat,
|
|
||||||
margins: MARGIN_PRESETS[dialogMargins ?? settings.pdfMargins],
|
|
||||||
embedFonts: dialogEmbed ?? settings.pdfEmbedFonts,
|
|
||||||
renderTablesAsAscii: dialogAscii ?? settings.renderTablesAsAscii,
|
|
||||||
});
|
|
||||||
if (!result.ok) setError(result.error.message);
|
|
||||||
else { closeModal(); /* toast in Phase 8 */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
The dialog-level overrides fall through to settings defaults when not explicitly chosen.
|
|
||||||
|
|
||||||
### 4.5 ASCII table transformation
|
|
||||||
The transformation happens in the **renderer** (pre-IPC), so the main process doesn't need to know about ASCII mode:
|
|
||||||
```ts
|
|
||||||
// In ExportPdfDialog before submitting:
|
|
||||||
const finalSource = renderTablesAsAscii
|
|
||||||
? applyAsciiTransform(source) // walks AST, replaces <table> blocks
|
|
||||||
: source;
|
|
||||||
```
|
|
||||||
`applyAsciiTransform` is a small function (10-20 lines) that:
|
|
||||||
1. Parses markdown source for `|...|` table syntax via a small regex
|
|
||||||
2. Replaces each table block with a fenced code block containing the ASCII table
|
|
||||||
3. Returns the modified source
|
|
||||||
|
|
||||||
(No AST walker needed — markdown tables are line-based and a regex per line + simple width calc is sufficient.)
|
|
||||||
|
|
||||||
### 4.6 DOCX template selection
|
|
||||||
The dialog shows a Select with 3 options (standard, minimal, modern). The main process maps these names to bundled `.docx` template files. The IPC contract is unchanged — `DocxOptions.template: string` was already defined in `types/ipc.ts` during Phase 1.
|
|
||||||
|
|
||||||
### 4.7 Confirm flow
|
|
||||||
```ts
|
|
||||||
// In a command handler:
|
|
||||||
const activeTab = ...;
|
|
||||||
if (activeTab?.dirty) {
|
|
||||||
useAppStore.getState().openModal('confirm', {
|
|
||||||
title: 'Discard unsaved changes?',
|
|
||||||
body: `"${activeTab.title}" has unsaved changes. Close without saving?`,
|
|
||||||
confirmLabel: 'Discard',
|
|
||||||
destructive: true,
|
|
||||||
onConfirm: () => doCloseTab(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
doCloseTab();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.8 Welcome first-launch
|
|
||||||
`useEffect` in `App.tsx` (run once on mount) checks `welcomeDismissed` from `useSettingsStore`. If false, calls `openModal('welcome')`. The Welcome dialog has a "Don't show again" checkbox that sets `welcomeDismissed: true` and closes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Error Handling
|
|
||||||
|
|
||||||
- **IPC errors in export dialogs:** inline error banner below the submit button. `IpcResult<T>` discriminated union makes this easy. Banner shows `result.error.message` and a "Try again" button that re-submits.
|
|
||||||
- **Settings validation:** zod schemas in `validators.ts`. Each form field shows `aria-invalid` + red border on error. Form-level errors via react-hook-form's `formState.errors`.
|
|
||||||
- **Confirm dialog cancel:** just calls `closeModal()`. No state mutation. Optional `onCancel` callback for "remember my choice" patterns (not used in Phase 7).
|
|
||||||
- **Welcome "don't show again":** persists `welcomeDismissed: true`. Help menu can re-open Welcome (without resetting the flag).
|
|
||||||
- **Settings corruption on load (bad localStorage data):** `useSettingsStore` is built with a `partialize` that also acts as a whitelist — only known fields are deserialized. Unknown fields are dropped. If a persisted value fails zod validation, fall back to defaults (logged as a warning).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Testing Strategy
|
|
||||||
|
|
||||||
TDD per the established pattern (Phases 1-6). Every component test:
|
|
||||||
- Renders with empty/default state
|
|
||||||
- One happy-path interaction (form submit, button click)
|
|
||||||
- One error/edge case (validation fail, IPC error, cancel)
|
|
||||||
|
|
||||||
Store tests focus on pure logic (state transitions, persistence, partialize). Settings store test specifically verifies:
|
|
||||||
- Defaults match schema
|
|
||||||
- `setSetting` works for leaf keys
|
|
||||||
- `resetToDefaults` clears to initial state
|
|
||||||
- Persisted payload (from `partialize`) contains exactly the leaf fields
|
|
||||||
- Hydration from a partial/corrupt payload doesn't throw
|
|
||||||
|
|
||||||
Component tests use `render` + `userEvent`, mock `window.electronAPI` for IPC.
|
|
||||||
|
|
||||||
ModalLayer integration test verifies:
|
|
||||||
1. Mounting with `kind: null` renders nothing (query container, expect empty)
|
|
||||||
2. Mounting with `kind: 'about'` renders `<AboutDialog>` (aria-label match)
|
|
||||||
3. Switching from `kind: 'about'` to `kind: 'settings'` unmounts About, mounts Settings (verified by role/aria-label transitions)
|
|
||||||
4. Confirm dialog calls `onConfirm` and `closeModal` on success
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Risks & Open Questions
|
|
||||||
|
|
||||||
**Risks:**
|
|
||||||
- **Form library complexity.** react-hook-form + zod is powerful but adds learning curve. Mitigation: a single shared `<SettingsForm>` wrapper reduces cognitive load; export dialogs use simple `useState` (no need for the full form infra).
|
|
||||||
- **shadcn Dialog animation jank with our motion presets.** Radix Dialog has its own `data-state` attributes for open/closed. We compose with our `modalPop` preset via `forceMount` + Motion. Need to verify no double-animation.
|
|
||||||
- **Settings store hydration race.** If a component reads a setting on first render before hydration completes, it gets the default. For Phase 7 this is fine — defaults are sensible.
|
|
||||||
|
|
||||||
**Open questions (deferrable):**
|
|
||||||
- Should the "ascii table" output include alignment row separators (`+---+---+`) or just be a `| a | b |`-style table? → Decision: use the `|---|` separator form (more compact, common in plain-text email).
|
|
||||||
- Should ExportBatchDialog be a Sheet (queue progress) or a Dialog (form)? → Decision: Dialog with a form; progress is shown inline (not a streaming queue). Phase 9 could revisit.
|
|
||||||
- Should `welcomeDismissed` be per-user-account or per-install? → Per-install (localStorage). No multi-user concept in v1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Out of Scope (deferred to later phases)
|
|
||||||
|
|
||||||
- Phase 8: Toast notifications on export success/failure (the dialog's inline error is v1; toasts are a follow-up).
|
|
||||||
- Phase 9: ASCII art generator (figlet) is separate from ASCII table rendering. This spec is about table formatting.
|
|
||||||
- Phase 9: Word export uses a `.docx` template *generation* step (WordExportDialog), not the IPC `ipc.export.docx` path. Distinct.
|
|
||||||
- Phase 10: Delete legacy `src/print-preview.js`, `src/wordTemplateExporter.js`, etc.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Success Criteria
|
|
||||||
|
|
||||||
Phase 7 is complete when:
|
|
||||||
- All listed shadcn primitives exist in `src/renderer/components/ui/` with tests
|
|
||||||
- `useSettingsStore` is implemented, tested, and persisted
|
|
||||||
- `useAppStore` extended with `modal` discriminated union and tested
|
|
||||||
- All 7 modal components implemented, tested, and accessible (aria-labels, keyboard nav)
|
|
||||||
- 4 export dialogs (PDF/DOCX/HTML/Batch) all submit through the command store and call IPC correctly
|
|
||||||
- `ModalLayer` mounted in `App.tsx` and integrated with command triggers
|
|
||||||
- Welcome dialog shows on first launch, dismissible, re-openable from Help menu
|
|
||||||
- Confirm dialog used by quit-with-dirty and close-with-dirty flows
|
|
||||||
- ASCII table rendering works (toggle in settings, override in export dialogs)
|
|
||||||
- DOCX template picker in ExportDocxDialog submits the correct `template` field
|
|
||||||
- `npx vite build` succeeds, `npx vitest run` shows **all tests green** (target: +50 new tests, total ~220)
|
|
||||||
- Branch tagged `phase-7-modals` and pushed to origin
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
# MarkdownConverter — React + shadcn/ui UI Redesign
|
|
||||||
|
|
||||||
**Date:** 2026-06-05
|
|
||||||
**Status:** Design (awaiting user approval)
|
|
||||||
**Branch:** `react-electron`
|
|
||||||
**Author:** Brainstormed with user via superpowers:brainstorming
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Replace the legacy vanilla JS renderer (`src/renderer.js` is 213 KB; `src/styles.css` is 74 KB) with a modern React 19 + Vite + TypeScript + shadcn/ui renderer that achieves visual feature parity while delivering a Polished + Glassy (Raycast/Arc-style) aesthetic. The Electron main process, preload bridge, and IPC contracts stay unchanged. Work proceeds via vertical slices — each PR ships a demoable feature.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. Replace every render-time feature of the legacy renderer with an idiomatic React + shadcn/ui implementation.
|
|
||||||
2. Adopt the "Polished + Glassy" visual language (subtle shadows, 8–12 px radii, gradient accents, gentle depth) on top of the existing ConcreteInfo brand tokens and design system.
|
|
||||||
3. Use a single component foundation (shadcn/ui) and a single motion library (Motion / Framer Motion) to keep the dependency surface narrow and the design coherent.
|
|
||||||
4. Keep the main process, preload, and IPC contracts untouched. The renderer is the only surface being rewritten.
|
|
||||||
5. Keep the app runnable at every commit. No "big bang" merge.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Migrating the main process to TypeScript (out of scope for this spec).
|
|
||||||
- Adding new features the legacy renderer does not have (plugin system, AI features, etc. — those are future specs).
|
|
||||||
- Replacing Pandoc/FFmpeg/ImageMagick orchestration.
|
|
||||||
- Changing the packaging pipeline (electron-builder config stays).
|
|
||||||
- Replacing the CodeMirror 6 editor — it is the right tool and is already wired up.
|
|
||||||
|
|
||||||
## Decisions Locked During Brainstorming
|
|
||||||
|
|
||||||
| Decision | Choice | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| Scope | Full feature parity with legacy renderer | User-selected option. Renderer is one cohesive surface; splitting it across specs would force premature contracts. |
|
|
||||||
| Visual style | **B — Polished + Glassy** (Raycast/Arc aesthetic) | "Fancy" comes from elevation, gradient accents, and material depth — not over-designed visuals. |
|
|
||||||
| Layout | **2 — IDE-style** (equal editor/preview, draggable divider, collapsible sidebar) | Power-user markdown apps converge on this. Draggable divider + keyboard reset. |
|
|
||||||
| Modal patterns | **A (Centered Dialog), B (Right Side-Sheet), D (Toasts)** | No command palette. |
|
|
||||||
| Command palette | **No** — full menus, no ⌘K | Power users can use the OS-level launcher. App stays focused on writing. |
|
|
||||||
| Animation | **Motion (Framer Motion)** | Best for layout transitions, modal/drawer enter/exit, drag. Sparingly used. |
|
|
||||||
| Component library | **shadcn/ui** | Tailwind + Radix primitives, copy-paste ownership, perfect fit for the "glassy" aesthetic and the HSL-CSS-variable foundation that is already in place. |
|
|
||||||
| Implementation strategy | **Vertical slices** — one feature end-to-end per PR | App stays runnable. Each PR is reviewable. |
|
|
||||||
|
|
||||||
## Defaults (Used Unless Overridden Later)
|
|
||||||
|
|
||||||
| Item | Default | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| State management | Zustand (already in deps) + Immer for nested patches | Already in `package.json`; perfect for editor state. |
|
|
||||||
| Theming | shadcn `next-themes`, dark default + light, system-aware | shadcn canonical pattern. Brand colors already in CSS vars. |
|
|
||||||
| Icons | `lucide-react` (already in deps) | 1000+ tree-shakable icons. |
|
|
||||||
| Forms | `react-hook-form` + `zod` | Standard for shadcn forms. |
|
|
||||||
| Drag/drop (sortable lists) | `@dnd-kit/core` | De facto React drag lib (file tree reordering, plugin list reorder). |
|
|
||||||
| Pane resize (split layout) | `react-resizable-panels` | Canonical React lib for resizable pane groups; handles drag, arrow keys, snap, persisted sizes. |
|
|
||||||
| Testing | Vitest + RTL + Playwright (E2E + visual regression) | Standard for React + Electron. |
|
|
||||||
| TypeScript | Strict mode (already wired) | Already in deps and `vite.renderer.config.ts`. |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The Electron app keeps its existing process model. Only the renderer is rewritten.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────┐
|
|
||||||
│ Electron Main (UNCHANGED) │
|
|
||||||
│ - BrowserWindow, IPC handlers, file/fs ops │
|
|
||||||
│ - Pandoc, FFmpeg, ImageMagick orchestration │
|
|
||||||
└────────────┬─────────────────────────────────────────────┘
|
|
||||||
│ contextBridge (UNCHANGED preload.js)
|
|
||||||
┌────────────▼─────────────────────────────────────────────┐
|
|
||||||
│ React Renderer (REWRITTEN) │
|
|
||||||
│ ┌────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ AppShell (layout) │ │
|
|
||||||
│ │ ├─ MenuBar (native) │ │
|
|
||||||
│ │ ├─ AppHeader (logo, breadcrumbs, theme toggle) │ │
|
|
||||||
│ │ ├─ TabBar (open files) │ │
|
|
||||||
│ │ ├─ Toolbar (formatting) │ │
|
|
||||||
│ │ ├─ ResizablePaneGroup (sidebar | editor | preview) │ │
|
|
||||||
│ │ │ ├─ Sidebar (file tree, outline) │ │
|
|
||||||
│ │ │ ├─ EditorPane (CodeMirror 6) │ │
|
|
||||||
│ │ │ └─ PreviewPane (marked + KaTeX + Mermaid) │ │
|
|
||||||
│ │ ├─ StatusBar (word count, encoding, cursor pos) │ │
|
|
||||||
│ │ └─ ModalLayer (Dialog, SideSheet, Toaster) │ │
|
|
||||||
│ └────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Feature Modules (each owns UI + state slice) │ │
|
|
||||||
│ │ ├─ editor/ CodeMirror wrapper, syntax, themes │ │
|
|
||||||
│ │ ├─ preview/ markdown→html, KaTeX, Mermaid │ │
|
|
||||||
│ │ ├─ tabs/ open files, dirty state │ │
|
|
||||||
│ │ ├─ sidebar/ file tree, outline, search results │ │
|
|
||||||
│ │ ├─ modals/ export, settings, about, etc. │ │
|
|
||||||
│ │ ├─ tools/ zen, repl, ascii-gen, table-gen │ │
|
|
||||||
│ │ └─ export/ pdf, docx, html, image batch │ │
|
|
||||||
│ └────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Shared Infrastructure │ │
|
|
||||||
│ │ ├─ stores/ Zustand slices per feature │ │
|
|
||||||
│ │ ├─ hooks/ useFile, useEditor, useTheme, etc. │ │
|
|
||||||
│ │ ├─ lib/ cn, ipc, formatters, validators │ │
|
|
||||||
│ │ ├─ ui/ shadcn primitives: button, dialog… │ │
|
|
||||||
│ │ └─ types/ shared TS types │ │
|
|
||||||
│ └────────────────────────────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Architectural Rules
|
|
||||||
|
|
||||||
- **Shell components** (`AppHeader`, `TabBar`, `StatusBar`) subscribe only to `useAppStore`.
|
|
||||||
- **Feature components** subscribe to their own feature's store.
|
|
||||||
- **Cross-feature access** goes through hooks, not direct store imports (e.g., `useFileTree()` wraps `useFileStore`).
|
|
||||||
- **Feature modules are self-contained** — each owns its components, its Zustand slice, and its types. Safe to develop in parallel later.
|
|
||||||
- **No direct `window.electronAPI` calls** from feature code. All IPC goes through `lib/ipc.ts` for type safety and error normalization.
|
|
||||||
|
|
||||||
## State Management
|
|
||||||
|
|
||||||
```
|
|
||||||
stores/
|
|
||||||
├─ useAppStore // global UI: theme, sidebar, pane sizes, modals open
|
|
||||||
├─ useFileStore // file system: tree, open files, active tab
|
|
||||||
├─ useEditorStore // editor: per-file content, cursor, selection, dirty
|
|
||||||
├─ usePreviewStore // preview: scroll sync, zoom, theme
|
|
||||||
├─ useSettingsStore // user prefs (persisted to electron-store)
|
|
||||||
└─ useCommandStore // menu actions registry, recent actions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why slices instead of one mega-store?** Editor content can be megabytes. Keeping it in its own slice lets React avoid re-rendering the preview when only the editor changes — components subscribe with selectors.
|
|
||||||
|
|
||||||
**Persistent state** (auto-saved to electron-store via Zustand `persist` middleware): theme, sidebar visibility, last open files, pane divider positions, settings.
|
|
||||||
|
|
||||||
**Ephemeral state** (lost on quit): active modal, hover states, search query, current file content (saved to disk on idle).
|
|
||||||
|
|
||||||
## Visual Design System
|
|
||||||
|
|
||||||
### Color Tokens
|
|
||||||
|
|
||||||
The existing `globals.css` HSL variables stay. Additions for the "glassy" aesthetic:
|
|
||||||
|
|
||||||
```css
|
|
||||||
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
|
||||||
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
|
||||||
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
|
||||||
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
|
||||||
|
|
||||||
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
|
||||||
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
|
||||||
--glass-border-light: rgba(255, 255, 255, 0.4);
|
|
||||||
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
|
||||||
```
|
|
||||||
|
|
||||||
### shadcn Components
|
|
||||||
|
|
||||||
Install via `npx shadcn@latest add`:
|
|
||||||
|
|
||||||
**Primitives** (need all of these): `button`, `dialog`, `sheet`, `popover`, `tooltip`, `select`, `dropdown-menu`, `tabs`, `separator`, `scroll-area`, `toggle`, `switch`, `slider`, `input`, `textarea`, `label`, `form`, `skeleton`, `sonner`, `command`.
|
|
||||||
|
|
||||||
**Composite** (custom-built on top of primitives): `file-tree`, `pane-group`, `divider`, `status-bar`, `menu-bar`, `toast`.
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
- Body: Plus Jakarta Sans 15 px / line-height 1.6
|
|
||||||
- Code: JetBrains Mono 13.5 px
|
|
||||||
- Display: Barlow Condensed 700/800 — used sparingly (page titles, big metrics)
|
|
||||||
- Headings: Plus Jakarta Sans 600/700 with tight letter-spacing
|
|
||||||
- Labels (`.label`): Plus Jakarta Sans 500, 12 px, uppercase, 0.05 em letter-spacing
|
|
||||||
|
|
||||||
### Motion Choreography
|
|
||||||
|
|
||||||
| Element | Motion | Duration | Easing |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Modal (Dialog A) | Scale 0.96→1, opacity 0→1 | 200 ms | `ease-out` |
|
|
||||||
| Side sheet (B) | TranslateX 100%→0, opacity 0→1 | 300 ms | `cubic-bezier(0.16, 1, 0.3, 1)` |
|
|
||||||
| Toast | TranslateY 100%→0, opacity | 250 ms | spring(stiffness: 300, damping: 30) |
|
|
||||||
| Sidebar toggle | Width 264 px↔72 px, content fade | 250 ms | `ease-in-out` |
|
|
||||||
| Divider drag | Live width update | 0 ms | none (instant) |
|
|
||||||
| Theme switch | CSS variables, opacity overlay | 300 ms | `ease-in-out` |
|
|
||||||
| Tab switch | Underline slide | 200 ms | `ease-out` |
|
|
||||||
| Hover (buttons) | `bg` + `shadow` shift | 150 ms | `ease-out` |
|
|
||||||
| Focus ring | Outline grow | 100 ms | `ease-out` |
|
|
||||||
|
|
||||||
**Rules:** Every motion has a purpose. No gratuitous animation. Respects `prefers-reduced-motion` (Motion handles this automatically).
|
|
||||||
|
|
||||||
### Empty / Loading / Error States
|
|
||||||
|
|
||||||
Every async surface ships all three. Sonner toasts for ephemeral feedback. Skeleton screens for content areas. Empty-state components with icon + message + primary CTA.
|
|
||||||
|
|
||||||
## Modal/Overlay Patterns
|
|
||||||
|
|
||||||
| Use case | Pattern | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| Export (PDF/DOCX/HTML) | A — Centered Dialog | Focused decision, ~3–5 fields, "do one thing" |
|
|
||||||
| Settings | B — Right Side-Sheet | Long form, tabbed sections, stays open while editing |
|
|
||||||
| Find/Replace | Inline toolbar (not modal) | Always-visible utility |
|
|
||||||
| About | A — Centered Dialog | Tiny info dump |
|
|
||||||
| Confirm destructive (delete, close unsaved) | A — Centered Dialog | Forces attention |
|
|
||||||
| File save error | D — Toast | Non-blocking info |
|
|
||||||
| Pandoc/FFmpeg progress | D — Toast → sticky on >3 s | Background work feedback |
|
|
||||||
| Plugin manager | B — Right Side-Sheet | Lists + per-item actions |
|
|
||||||
| ASCII/Table generators | A — Centered Dialog | Tool-style focused input → output |
|
|
||||||
| Print preview | A — Centered Dialog | Full-screen-ish modal overlay |
|
|
||||||
| Welcome (first launch) | A — Centered Dialog | One-time onboarding |
|
|
||||||
| Word export | A — Centered Dialog | Template selection |
|
|
||||||
| Zen mode | Full-viewport toggle (no modal) | Replaces the editor entirely |
|
|
||||||
| REPL | Bottom-pinned panel (split-pane) | Persistent terminal-like UI |
|
|
||||||
| Quick file open | B — Right Side-Sheet | File tree, search, recent |
|
|
||||||
|
|
||||||
### Dialog A Anatomy (Export as example)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ Backdrop (rgba(13,11,9,0.45) + backdrop-blur 4px) ─────────┐
|
|
||||||
│ │
|
|
||||||
│ ┌──── Modal (max-w-md, rounded-2xl, shadow-lg) ─────┐ │
|
|
||||||
│ │ ┌── Header ────────────────────────────────────┐ │ │
|
|
||||||
│ │ │ [Icon] Export as PDF [×] │ │ │
|
|
||||||
│ │ │ Choose format options │ │ │
|
|
||||||
│ │ └─────────────────────────────────────────────┘ │ │
|
|
||||||
│ │ ┌── Body ──────────────────────────────────────┐ │ │
|
|
||||||
│ │ │ Format: [Letter] [A4] [Legal] │ │ │
|
|
||||||
│ │ │ Margins: ────●──── │ │ │
|
|
||||||
│ │ │ ☐ Include table of contents │ │ │
|
|
||||||
│ │ │ ☐ Embed fonts │ │ │
|
|
||||||
│ │ └─────────────────────────────────────────────┘ │ │
|
|
||||||
│ │ ┌── Footer ────────────────────────────────────┐ │ │
|
|
||||||
│ │ │ [Cancel] [Export →] │ │ │
|
|
||||||
│ │ └─────────────────────────────────────────────┘ │ │
|
|
||||||
│ └──────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Side-Sheet B Anatomy (Settings as example)
|
|
||||||
|
|
||||||
Slides in from right, ~50 % width (max 560 px). Backdrop is `rgba(13,11,9,0.2)` with `backdrop-blur(2 px)` (lighter than dialog — the sheet is the focus). Tab navigation in the sheet header (Editor / Theme / Export / Plugins / About).
|
|
||||||
|
|
||||||
### Toast D Anatomy (Sonner)
|
|
||||||
|
|
||||||
Stacked bottom-right, max 3 visible. Glass background. Color-coded 3 px left border — success `#1a7a56`, error `#ef4444`, info `#0ea5e9`, warning `#eab308`. Icon + title + description. Optional action button. Auto-dismiss 4 s for success, sticky for error.
|
|
||||||
|
|
||||||
## Data Flow (Editor → Preview Pipeline)
|
|
||||||
|
|
||||||
```
|
|
||||||
User types
|
|
||||||
↓
|
|
||||||
CodeMirror onChange
|
|
||||||
↓
|
|
||||||
useEditorStore.updateContent(tabId, content) ← debounced 50 ms
|
|
||||||
↓
|
|
||||||
┌─ Persist to electron-store on idle (1 s) ─┐
|
|
||||||
│ │
|
|
||||||
└─ Publish to usePreviewStore (subscribed) ─┘
|
|
||||||
↓
|
|
||||||
marked(content) → sanitized HTML
|
|
||||||
↓
|
|
||||||
PreviewPane renders HTML
|
|
||||||
KaTeX post-processes $...$
|
|
||||||
Mermaid post-processes ```mermaid
|
|
||||||
Highlight.js post-processes ```lang
|
|
||||||
↓
|
|
||||||
useEffect: scroll-sync editor cursor → preview position
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bidirectional scroll sync:** editor scroll → preview (primary), preview click → editor cursor (secondary). Throttled to 60 fps via `requestAnimationFrame`.
|
|
||||||
|
|
||||||
## IPC Contract
|
|
||||||
|
|
||||||
The preload bridge stays unchanged. A new `src/renderer/lib/ipc.ts` wraps every channel with TypeScript types and normalizes errors to a discriminated union:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type IpcResult<T> =
|
|
||||||
| { ok: true; data: T }
|
|
||||||
| { ok: false; error: { code: string; message: string } };
|
|
||||||
|
|
||||||
export const ipc = {
|
|
||||||
file: {
|
|
||||||
open: (): Promise<IpcResult<FileResult>>,
|
|
||||||
read: (path: string): Promise<IpcResult<string>>,
|
|
||||||
write: (path: string, content: string): Promise<IpcResult<void>>,
|
|
||||||
list: (dir: string): Promise<IpcResult<FileEntry[]>>,
|
|
||||||
onChange: (cb: (path: string) => void) => () => void,
|
|
||||||
},
|
|
||||||
export: {
|
|
||||||
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult>>,
|
|
||||||
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult>>,
|
|
||||||
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult>>,
|
|
||||||
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult>>,
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
getVersion: (): Promise<IpcResult<string>>,
|
|
||||||
openExternal: (url: string): Promise<IpcResult<void>>,
|
|
||||||
showItemInFolder: (path: string): Promise<IpcResult<void>>,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
Layered defense — no single failure can crash the app:
|
|
||||||
|
|
||||||
1. **IPC errors** → caught in `lib/ipc.ts` wrapper, returned as `IpcResult<T>` discriminated union. Components show toast on error.
|
|
||||||
2. **Component errors** → React error boundary per feature area (one for editor, one for preview, one for modals). On error: show inline error UI with "Reload this panel" + "Copy details" actions.
|
|
||||||
3. **Async operations** (export, file ops) → loading state on button + toast on completion. Long ops (>2 s) get a sticky toast with cancel.
|
|
||||||
4. **Validation errors** (settings, export options) → inline form errors via `react-hook-form` + `zod`. shadcn `Form` component for consistent error display.
|
|
||||||
5. **Pandoc/FFmpeg missing** → detected at startup, banner + disable export menu items. Don't surprise-fail at export time.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
- **Unit (Vitest):** stores, hooks, lib utilities, formatters, validators
|
|
||||||
- **Component (Vitest + RTL):** shadcn wrappers, dialog interactions, sidebar toggle, divider drag, theme toggle
|
|
||||||
- **Integration (Vitest + RTL):** editor ↔ preview flow, file open → tab add → content display, settings change persists
|
|
||||||
- **E2E (Playwright):** app launches, file opens, markdown renders, export to PDF works
|
|
||||||
- **Visual regression (Playwright snapshots):** locked-in screenshots for header, sidebar, dialogs, toasts — catch accidental style drift
|
|
||||||
- **Target coverage:** stores/hooks/lib ≥ 90 %, components ≥ 75 %, E2E covers all critical paths
|
|
||||||
|
|
||||||
## Accessibility (WCAG 2.1 AA)
|
|
||||||
|
|
||||||
Baked in via shadcn + Radix:
|
|
||||||
|
|
||||||
- All dialogs focus-trap, Esc to close, return focus to trigger
|
|
||||||
- All interactive elements keyboard-reachable
|
|
||||||
- Visible focus rings (2 px brand ring)
|
|
||||||
- 4.5:1 contrast minimum (already met by brand colors)
|
|
||||||
- `aria-label` on icon buttons, `role="alert"` on toasts
|
|
||||||
- Respects `prefers-reduced-motion` (Motion handles this automatically)
|
|
||||||
|
|
||||||
## Implementation Phases (Vertical Slices)
|
|
||||||
|
|
||||||
Each phase is a shippable PR that keeps the app runnable. The legacy `renderer.js` is gradually replaced; until each phase is complete, the old renderer code still runs the missing parts.
|
|
||||||
|
|
||||||
| # | Phase | Output | Acceptance |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | **Foundation** | shadcn installed; `next-themes`, `motion`, `react-hook-form`, `zod`, `@dnd-kit/core`, `react-resizable-panels`, `sonner` installed; design tokens finalized; `lib/utils.ts` (`cn`), `lib/ipc.ts` typed wrappers, `lib/motion.ts` preset transitions, `App.tsx` shell skeleton renders | `npm run build` succeeds; dev server shows the new shell with theme toggle working |
|
|
||||||
| 2 | **App shell + layout** | `AppHeader`, `TabBar`, `Toolbar`, `Breadcrumb`, `StatusBar`, `ResizablePaneGroup` with sidebar toggle and draggable divider. Empty editor/preview panes. | Resize divider with mouse and arrow keys; sidebar collapses; pane sizes persist. |
|
|
||||||
| 3 | **Editor pane** | CodeMirror 6 wrapped, dark/light themes wired to shadcn theme, syntax highlighting, line numbers, search, autocomplete | Open `.md` file → renders in editor; can edit and save. |
|
|
||||||
| 4 | **Preview pane** | marked + DOMPurify + KaTeX + Mermaid + highlight.js. Bidirectional scroll sync with editor. | Open file → preview renders, follows cursor. |
|
|
||||||
| 5 | **File tree + tabs** | Sidebar file tree (read directory on demand, lazy-expand children only on click). Tabs for open files. Dirty state indicator. File content is read from disk only when the tab is activated. | Open folder → root populates; click folder → children load; click file → opens in tab; close tab reverts dirty. |
|
|
||||||
| 6 | **Native menus + toolbar** | Replace the legacy `CommandPalette` with full menus (File / Edit / View / Insert / Format / Tools / Help) bound to keyboard shortcuts. Toolbar buttons. | All menu items invoke the right action; shortcuts work; toolbar reflects active state. |
|
|
||||||
| 7 | **Modals** | Export dialog (PDF/DOCX/HTML/batch), Settings side-sheet (Editor/Theme/Export/Plugins/About tabs), About dialog, Confirm-destructive dialog | All dialogs and sheet render with proper motion; settings persist; export works end-to-end. |
|
|
||||||
| 8 | **Toasts** | Sonner wired into all async operations (save, export, errors, tool missing) | All operations give appropriate feedback; no silent failures. |
|
|
||||||
| 9 | **Advanced tools** | Zen mode (full-viewport toggle), REPL (bottom-pinned panel), ASCII generator, Table generator, Word export template picker, Print preview | Each tool is feature-complete vs. legacy version. |
|
|
||||||
| 10 | **Polish + delete legacy** | Visual regression snapshots locked; remove `renderer.js` and old `styles*.css` references from build; `npm run build:linux` produces a working installer | Final PR ships an installer with no legacy code paths. |
|
|
||||||
|
|
||||||
## Risks and Mitigations
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|---|---|
|
|
||||||
| shadcn CLI requires Vite (not vanilla Electron renderer) | Vite is already configured for the renderer in `vite.renderer.config.ts`; the CLI works against the same project. |
|
|
||||||
| shadcn CLI assumes Tailwind is at project root; we have it in renderer/ only | Point the CLI at `src/renderer/components.json` and set `tailwind.css` to the right path. |
|
|
||||||
| Marked + KaTeX + Mermaid + highlight.js together is heavy | Lazy-load Mermaid (only when `mermaid` code block encountered). KaTeX is loaded once, cached. |
|
|
||||||
| Editor content state can be megabytes → re-render storms | Editor content lives in its own slice; preview subscribes to a derived/cached HTML string; components use `useShallow` / selector subscriptions. |
|
|
||||||
| Bidirectional scroll sync loops | `useEffect` debounce + ignore if cursor/selection is the sync source. |
|
|
||||||
| CodeMirror 6 doesn't ship a Tailwind theme by default | Use `@codemirror/theme-one-dark` for dark; build a custom theme that pulls from CSS variables for light. |
|
|
||||||
| The legacy `renderer.js` and `styles*.css` are referenced from `index.html` (now `src/renderer/index.html`) | Phase 10 deletes the references; until then, both run side-by-side and the new React app sits in a known root div. |
|
|
||||||
| Electron `nodeIntegration` is off; we use contextBridge | Already the case. Document the contract clearly in `lib/ipc.ts`. |
|
|
||||||
|
|
||||||
## Open Questions (to confirm before implementation)
|
|
||||||
|
|
||||||
None. All major decisions are locked. Defaults will be used unless the user overrides them when reviewing the implementation plan.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `src/renderer.js` (213 KB) — legacy renderer being replaced
|
|
||||||
- `src/styles.css` (74 KB), `src/styles-modern.css` (71 KB), `src/styles-concreteinfo.css` (21 KB) — legacy styles being replaced
|
|
||||||
- `src/renderer/styles/globals.css` — already shadcn-compatible (HSL CSS variables)
|
|
||||||
- `src/renderer/App.tsx` — empty skeleton that references components we will build
|
|
||||||
- `tailwind.config.js` — brand colors and fonts already defined
|
|
||||||
- `vite.renderer.config.ts` — Vite + React plugin already configured
|
|
||||||
- `package.json` — CodeMirror 6, TanStack Table, lucide-react, cva, clsx, tailwind-merge, tailwindcss-animate already installed
|
|
||||||
Generated
+144
-4395
File diff suppressed because it is too large
Load Diff
+3
-54
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.4.2",
|
"version": "4.4.5",
|
||||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,9 +8,6 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
"test:renderer": "vitest run",
|
|
||||||
"test:renderer:watch": "vitest",
|
|
||||||
"test:renderer:coverage": "vitest run --coverage",
|
|
||||||
"lint": "eslint src tests",
|
"lint": "eslint src tests",
|
||||||
"lint:fix": "eslint src tests --fix",
|
"lint:fix": "eslint src tests --fix",
|
||||||
"format": "prettier --write src tests",
|
"format": "prettier --write src tests",
|
||||||
@@ -46,19 +43,7 @@
|
|||||||
"url": "https://github.com/amitwh/markdown-converter"
|
"url": "https://github.com/amitwh/markdown-converter"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/node": "^25.9.1",
|
|
||||||
"@types/react": "^19.2.16",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
|
||||||
"@vitest/ui": "^4.1.8",
|
|
||||||
"autoprefixer": "^10.5.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
@@ -67,17 +52,8 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"jsdom": "^29.1.1",
|
|
||||||
"lucide-react": "^1.17.0",
|
|
||||||
"postcss": "^8.5.15",
|
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3"
|
||||||
"tailwind-merge": "^3.6.0",
|
|
||||||
"tailwindcss": "^3.4.19",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.16",
|
|
||||||
"vitest": "^4.1.8"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -94,23 +70,6 @@
|
|||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.16",
|
"@codemirror/view": "^6.39.16",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@hookform/resolvers": "^5.4.0",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@tanstack/react-table": "^8.21.3",
|
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
@@ -120,26 +79,16 @@
|
|||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"immer": "^11.1.8",
|
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-footnote": "^1.4.0",
|
"marked-footnote": "^1.4.0",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"motion": "^12.40.0",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.5.207",
|
"pdfjs-dist": "^5.5.207",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pizzip": "^3.2.0",
|
"pizzip": "^3.2.0",
|
||||||
"react": "^19.2.7",
|
|
||||||
"react-dom": "^19.2.7",
|
|
||||||
"react-hook-form": "^7.77.0",
|
|
||||||
"react-resizable-panels": "^4.11.2",
|
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"sonner": "^2.0.7",
|
"tslib": "^2.8.1"
|
||||||
"tslib": "^2.8.1",
|
|
||||||
"zod": "^4.4.3",
|
|
||||||
"zustand": "^5.0.14"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+604
@@ -0,0 +1,604 @@
|
|||||||
|
# Repository Structure Map
|
||||||
|
|
||||||
|
Auto-generated by `~/.claude-shared/scripts/repo-map.sh` (universal-ctags).
|
||||||
|
Signatures only — classes, functions, methods, interfaces, enums, types, namespaces, traits.
|
||||||
|
Regenerate after structural changes. Languages: `JavaScript,Sh`.
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/download-tools.js:
|
||||||
|
L22 method extract (PANDOC_CONFIG.linux)
|
||||||
|
L36 method extract (PANDOC_CONFIG.win32)
|
||||||
|
L51 method extract (PANDOC_CONFIG.darwin)
|
||||||
|
L63 function download
|
||||||
|
L70 function get (download)
|
||||||
|
L109 function downloadPandoc
|
||||||
|
|
||||||
|
scripts/generate-icons.js:
|
||||||
|
L17 function generateIcons
|
||||||
|
|
||||||
|
src/adapters/electron/fs.js:
|
||||||
|
L20 method readFile (electronFsAdapter)
|
||||||
|
L30 method writeFile (electronFsAdapter)
|
||||||
|
L39 method deleteFile (electronFsAdapter)
|
||||||
|
L48 method ensureDir (electronFsAdapter)
|
||||||
|
L57 method listDirectory (electronFsAdapter)
|
||||||
|
L77 method exists (electronFsAdapter)
|
||||||
|
L86 method isDirectory (electronFsAdapter)
|
||||||
|
L96 method copy (electronFsAdapter)
|
||||||
|
L106 method move (electronFsAdapter)
|
||||||
|
|
||||||
|
src/analytics/analytics-panel.js:
|
||||||
|
L7 function showAnalyticsModal
|
||||||
|
L105 function escHandler (showAnalyticsModal)
|
||||||
|
L116 function escapeHtml
|
||||||
|
|
||||||
|
src/analytics/writing-analytics.js:
|
||||||
|
L77 function countSyllables
|
||||||
|
L83 function extractWords
|
||||||
|
L87 function getReadabilityLabel
|
||||||
|
L95 function analyze
|
||||||
|
|
||||||
|
src/command-palette.js:
|
||||||
|
L1 class CommandPalette
|
||||||
|
L2 method constructor (CommandPalette)
|
||||||
|
L12 method register (CommandPalette)
|
||||||
|
L16 method open (CommandPalette)
|
||||||
|
L24 method close (CommandPalette)
|
||||||
|
L28 method isOpen (CommandPalette)
|
||||||
|
L32 method setupEventListeners (CommandPalette)
|
||||||
|
L61 method renderResults (CommandPalette)
|
||||||
|
L90 method highlightMatch (CommandPalette)
|
||||||
|
L96 method updateSelection (CommandPalette)
|
||||||
|
L105 method executeSelected (CommandPalette)
|
||||||
|
|
||||||
|
src/editor/codemirror-setup.js:
|
||||||
|
L46 function createEditor
|
||||||
|
L61 function onChange (createEditor)
|
||||||
|
L118 function getLanguageExtension
|
||||||
|
L120 method javascript (getLanguageExtension.loaders)
|
||||||
|
L124 method html (getLanguageExtension.loaders)
|
||||||
|
L128 method css (getLanguageExtension.loaders)
|
||||||
|
L132 method json (getLanguageExtension.loaders)
|
||||||
|
L136 method python (getLanguageExtension.loaders)
|
||||||
|
|
||||||
|
src/main.js:
|
||||||
|
L18 function getPandocPath
|
||||||
|
L36 function getFFmpegPath
|
||||||
|
L55 function sanitizeErrorMessage
|
||||||
|
L64 function createRateLimiter
|
||||||
|
L66 function canProceed (createRateLimiter)
|
||||||
|
L83 function validatePath
|
||||||
|
L136 function resolveWritablePath
|
||||||
|
L181 function isPathAccessible
|
||||||
|
L203 function convertDataToMarkdown
|
||||||
|
L225 function runPandocCmd
|
||||||
|
L247 function parseCommand
|
||||||
|
L282 method get (store)
|
||||||
|
L291 method set (store)
|
||||||
|
L488 function checkPandocAvailability
|
||||||
|
L500 function createWindow
|
||||||
|
L586 function buildRecentFilesMenu
|
||||||
|
L599 method click (buildRecentFilesMenu.anonymousObjectc5643c890c05)
|
||||||
|
L637 function getRecentFiles
|
||||||
|
L647 function createMenu
|
||||||
|
L872 method click (createMenu.anonymousObjectc5643c890e05.anonymousObjectc5643c894505)
|
||||||
|
L1118 method click (createMenu.anonymousObjectc5643c895205.anonymousObjectc5643c897e05)
|
||||||
|
L1279 method click (createMenu.anonymousObjectc5643c899305.anonymousObjectc5643c89a505)
|
||||||
|
L1351 function showAboutDialog
|
||||||
|
L1452 function showDependenciesDialog
|
||||||
|
L1564 function openPDFFile
|
||||||
|
L1583 function openFile
|
||||||
|
L1622 function openPdfFile
|
||||||
|
L1645 function saveAsFile
|
||||||
|
L1664 function exportFile
|
||||||
|
L1673 function showExportOptionsDialog
|
||||||
|
L1676 function showBatchConversionDialog
|
||||||
|
L1681 function selectWordTemplate
|
||||||
|
L1705 function showTemplateSettings
|
||||||
|
L1902 function processDynamicFields
|
||||||
|
L1921 function setDocxPageSize
|
||||||
|
L1980 function addHeaderFooterToDocx
|
||||||
|
L2111 function exportWordWithTemplate
|
||||||
|
L2153 function exportPDFViaWordTemplate
|
||||||
|
L2231 function showUniversalConverterDialog
|
||||||
|
L2236 function showPDFEditorDialog
|
||||||
|
L2246 function checkConverterAvailable
|
||||||
|
L2370 function collectFiles
|
||||||
|
L2464 function convertWithLibreOffice
|
||||||
|
L2498 function convertWithImageMagick
|
||||||
|
L2507 function convertWithFFmpeg
|
||||||
|
L2515 function convertWithPandoc
|
||||||
|
L2521 function performExportWithOptions
|
||||||
|
L2805 function tryPdfFallback
|
||||||
|
L2882 function showExportSuccess
|
||||||
|
L2892 function exportWithPandoc
|
||||||
|
L2964 function exportToHTML
|
||||||
|
L3080 function exportToPDFElectron
|
||||||
|
L3224 function exportSpreadsheet
|
||||||
|
L3233 function importDocument
|
||||||
|
L3355 function setTheme
|
||||||
|
L3536 function extractTablesFromMarkdown
|
||||||
|
L3574 function performBatchConversion
|
||||||
|
L3597 function findMarkdownFiles (performBatchConversion)
|
||||||
|
L3636 function processNextFile (performBatchConversion)
|
||||||
|
L3970 function handleCLIConversion
|
||||||
|
L3997 function showConversionDialog
|
||||||
|
L4064 function performCLIConversion
|
||||||
|
L4098 function buildPandocCommand
|
||||||
|
L4319 function openFileFromPath
|
||||||
|
L4394 function openAsciiGenerator
|
||||||
|
L4426 function openTableGenerator
|
||||||
|
L4724 function loadSnippets
|
||||||
|
L4734 function saveSnippetsFile
|
||||||
|
|
||||||
|
src/main/GitOperations.js:
|
||||||
|
L3 function getGitInstance
|
||||||
|
L7 function getStatus
|
||||||
|
L16 function stage
|
||||||
|
L26 function commit
|
||||||
|
L35 function log
|
||||||
|
|
||||||
|
src/main/PDFOperations.js:
|
||||||
|
L5 function parsePageRanges
|
||||||
|
L28 function hexToRgb
|
||||||
|
L39 function pdfMerge
|
||||||
|
L59 function pdfSplit
|
||||||
|
L126 function pdfCompress
|
||||||
|
L152 function pdfRotate
|
||||||
|
L182 function pdfDeletePages
|
||||||
|
L208 function pdfReorder
|
||||||
|
L233 function pdfWatermark
|
||||||
|
L319 function pdfEncrypt
|
||||||
|
L353 function pdfDecrypt
|
||||||
|
L370 function pdfSetPermissions
|
||||||
|
L404 function executeOperation
|
||||||
|
L431 function getPageCount
|
||||||
|
|
||||||
|
src/plugins/built-in/_sample/index.js:
|
||||||
|
L3 class SamplePlugin
|
||||||
|
L4 method init (SamplePlugin)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/goal-tracker.js:
|
||||||
|
L3 class GoalTracker
|
||||||
|
L7 method constructor (GoalTracker)
|
||||||
|
L11 method _getHistory (GoalTracker)
|
||||||
|
L16 method _setHistory (GoalTracker)
|
||||||
|
L20 method _setHistoryDay (GoalTracker)
|
||||||
|
L26 method addWords (GoalTracker)
|
||||||
|
L37 method getDailyProgress (GoalTracker)
|
||||||
|
L44 method getStreak (GoalTracker)
|
||||||
|
L61 method getLast30Days (GoalTracker)
|
||||||
|
L74 method getWeeklyTotal (GoalTracker)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/index.js:
|
||||||
|
L7 class WritingStudioPlugin
|
||||||
|
L8 method init (WritingStudioPlugin)
|
||||||
|
L34 method _registerCommands (WritingStudioPlugin)
|
||||||
|
L112 method _registerStatusBar (WritingStudioPlugin)
|
||||||
|
L123 method deactivate (WritingStudioPlugin)
|
||||||
|
L127 method getEngines (WritingStudioPlugin)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/goals-panel.js:
|
||||||
|
L1 function renderGoalsPanel
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/manuscript-panel.js:
|
||||||
|
L1 function renderManuscriptPanel
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/proofread-panel.js:
|
||||||
|
L1 function renderProofreadPanel
|
||||||
|
L41 method callback (anonymousObject39a0f0110105)
|
||||||
|
L50 function renderIssues
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/snapshots-panel.js:
|
||||||
|
L1 function renderSnapshotsPanel
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/project-manager.js:
|
||||||
|
L1 class ProjectManager
|
||||||
|
L5 method constructor (ProjectManager)
|
||||||
|
L9 method createProject (ProjectManager)
|
||||||
|
L21 method loadProject (ProjectManager)
|
||||||
|
L27 method _saveProject (ProjectManager)
|
||||||
|
L31 method addChapter (ProjectManager)
|
||||||
|
L38 method updateChapter (ProjectManager)
|
||||||
|
L45 method compileManuscript (ProjectManager)
|
||||||
|
L56 method getStats (ProjectManager)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/snapshot-manager.js:
|
||||||
|
L1 class SnapshotManager
|
||||||
|
L6 method constructor (SnapshotManager)
|
||||||
|
L11 method _getAll (SnapshotManager)
|
||||||
|
L16 method _saveAll (SnapshotManager)
|
||||||
|
L20 method create (SnapshotManager)
|
||||||
|
L34 method list (SnapshotManager)
|
||||||
|
L38 method getById (SnapshotManager)
|
||||||
|
L42 method restore (SnapshotManager)
|
||||||
|
L48 method delete (SnapshotManager)
|
||||||
|
L53 method diff (SnapshotManager)
|
||||||
|
L71 method prune (SnapshotManager)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/sprint-engine.js:
|
||||||
|
L1 class SprintEngine
|
||||||
|
L6 method constructor (SprintEngine)
|
||||||
|
L14 method start (SprintEngine)
|
||||||
|
L22 method stop (SprintEngine)
|
||||||
|
L33 method tick (SprintEngine)
|
||||||
|
L43 method isActive (SprintEngine)
|
||||||
|
L47 method getRemaining (SprintEngine)
|
||||||
|
|
||||||
|
src/plugins/event-bus.js:
|
||||||
|
L1 class EventBus
|
||||||
|
L2 method constructor (EventBus)
|
||||||
|
L6 method on (EventBus)
|
||||||
|
L13 method off (EventBus)
|
||||||
|
L25 method emit (EventBus)
|
||||||
|
L37 method hasHandler (EventBus)
|
||||||
|
|
||||||
|
src/plugins/plugin-api.js:
|
||||||
|
L1 class PluginAPI
|
||||||
|
L7 method init (PluginAPI)
|
||||||
|
L12 method activate (PluginAPI)
|
||||||
|
L15 method deactivate (PluginAPI)
|
||||||
|
L18 method getManifest (PluginAPI)
|
||||||
|
|
||||||
|
src/plugins/plugin-context.js:
|
||||||
|
L1 class PluginContext
|
||||||
|
L14 method constructor (PluginContext)
|
||||||
|
L23 method register (PluginContext.constructor.commands)
|
||||||
|
L24 function safeHandler (PluginContext.constructor.commands.register)
|
||||||
|
L65 method registerPreHook (PluginContext.constructor.exports)
|
||||||
|
L68 method registerPostHook (PluginContext.constructor.exports)
|
||||||
|
|
||||||
|
src/plugins/plugin-loader.js:
|
||||||
|
L4 class PluginLoader
|
||||||
|
L8 method constructor (PluginLoader)
|
||||||
|
L17 method discoverPlugins (PluginLoader)
|
||||||
|
L66 method validateManifest (PluginLoader)
|
||||||
|
|
||||||
|
src/plugins/plugin-registry.js:
|
||||||
|
L4 class PluginRegistry
|
||||||
|
L5 method constructor (PluginRegistry)
|
||||||
|
L15 method register (PluginRegistry)
|
||||||
|
L49 method getPlugin (PluginRegistry)
|
||||||
|
L53 method getAll (PluginRegistry)
|
||||||
|
L57 method activate (PluginRegistry)
|
||||||
|
L68 method deactivate (PluginRegistry)
|
||||||
|
|
||||||
|
src/plugins/settings-store.js:
|
||||||
|
L1 class SettingsStore
|
||||||
|
L5 method constructor (SettingsStore)
|
||||||
|
L9 method get (SettingsStore)
|
||||||
|
L13 method set (SettingsStore)
|
||||||
|
L17 method onChanged (SettingsStore)
|
||||||
|
|
||||||
|
src/preload.js:
|
||||||
|
L257 method send (anonymousObjectb0724fcb0105)
|
||||||
|
L271 method invoke (anonymousObjectb0724fcb0105)
|
||||||
|
L290 method on (anonymousObjectb0724fcb0105)
|
||||||
|
L292 function subscription (anonymousObjectb0724fcb0105.on)
|
||||||
|
L310 method once (anonymousObjectb0724fcb0105)
|
||||||
|
L322 method removeAllListeners (anonymousObjectb0724fcb0105)
|
||||||
|
|
||||||
|
src/print-preview.js:
|
||||||
|
L1 class PrintPreview
|
||||||
|
L2 method constructor (PrintPreview)
|
||||||
|
L9 method open (PrintPreview)
|
||||||
|
L20 method close (PrintPreview)
|
||||||
|
L28 method setupEventListeners (PrintPreview)
|
||||||
|
L53 method updateScaleLabel (PrintPreview)
|
||||||
|
L59 method updatePreview (PrintPreview)
|
||||||
|
L111 method refreshPreview (PrintPreview)
|
||||||
|
L117 method getOptions (PrintPreview)
|
||||||
|
L130 method executePrint (PrintPreview)
|
||||||
|
|
||||||
|
src/renderer.js:
|
||||||
|
L19 method send (window.electronAPI)
|
||||||
|
L22 method invoke (window.electronAPI)
|
||||||
|
L25 method on (window.electronAPI)
|
||||||
|
L26 function subscription (window.electronAPI.on)
|
||||||
|
L32 method once (window.electronAPI)
|
||||||
|
L35 method removeAllListeners (window.electronAPI)
|
||||||
|
L81 function getSidebarManager
|
||||||
|
L85 function getRenderTemplatesPanel
|
||||||
|
L90 function getRenderExplorerPanel
|
||||||
|
L95 function getRenderGitPanel
|
||||||
|
L99 function getRenderSnippetsPanel
|
||||||
|
L105 function getRenderOutlinePanel
|
||||||
|
L110 function getReplPanel
|
||||||
|
L114 function getCommandPalette
|
||||||
|
L118 function getPrintPreview
|
||||||
|
L122 function getCreateWelcomeContent
|
||||||
|
L127 function getZenMode
|
||||||
|
L132 function getShowAnalyticsModal
|
||||||
|
L137 function ensureToastContainer
|
||||||
|
L147 function notifyUser
|
||||||
|
L159 function dismiss (notifyUser)
|
||||||
|
L173 method highlight (anonymousObject71555c7b0405)
|
||||||
|
L202 method start (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
|
||||||
|
L205 method tokenizer (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
|
||||||
|
L221 method renderer (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
|
||||||
|
L241 function plantumlEncode (notifyUser)
|
||||||
|
L249 function scopeCSS (notifyUser)
|
||||||
|
L271 class TabManager (notifyUser)
|
||||||
|
L272 method constructor (notifyUser.TabManager)
|
||||||
|
L306 method setupEventListeners (notifyUser.TabManager)
|
||||||
|
L354 method createNewTab (notifyUser.TabManager)
|
||||||
|
L379 method createPdfTab (notifyUser.TabManager)
|
||||||
|
L405 method createPdfTabElements (notifyUser.TabManager)
|
||||||
|
L436 method setupPdfTabEvents (notifyUser.TabManager)
|
||||||
|
L503 method loadPdfInTab (notifyUser.TabManager)
|
||||||
|
L528 method renderPdfPageInTab (notifyUser.TabManager)
|
||||||
|
L555 method createTabElements (notifyUser.TabManager)
|
||||||
|
L580 method onChange (anonymousObject71555c7b0d05)
|
||||||
|
L602 method onUpdate (anonymousObject71555c7b0d05)
|
||||||
|
L614 method switchToTab (notifyUser.TabManager)
|
||||||
|
L641 method switchToNextTab (notifyUser.TabManager)
|
||||||
|
L647 method closeTab (notifyUser.TabManager)
|
||||||
|
L692 method updateTabBar (notifyUser.TabManager)
|
||||||
|
L718 method updateUI (notifyUser.TabManager)
|
||||||
|
L748 method saveCurrentTabState (notifyUser.TabManager)
|
||||||
|
L756 method restoreTabState (notifyUser.TabManager)
|
||||||
|
L765 method focusActiveEditor (notifyUser.TabManager)
|
||||||
|
L771 method updatePreview (notifyUser.TabManager)
|
||||||
|
L800 method _hash (notifyUser.TabManager)
|
||||||
|
L809 method _renderPreview (notifyUser.TabManager)
|
||||||
|
L963 function onerror (img)
|
||||||
|
L997 method updatePreviewVisibility (notifyUser.TabManager)
|
||||||
|
L1010 method updateLineNumbers (notifyUser.TabManager)
|
||||||
|
L1015 method updateWordCount (notifyUser.TabManager)
|
||||||
|
L1031 method updateCursorPosition (notifyUser.TabManager)
|
||||||
|
L1038 method updateFilePath (notifyUser.TabManager)
|
||||||
|
L1046 method updateBreadcrumb (notifyUser.TabManager)
|
||||||
|
L1054 method setupEditorEvents (notifyUser.TabManager)
|
||||||
|
L1058 method handleEditorInput (notifyUser.TabManager)
|
||||||
|
L1073 method startAutoSave (notifyUser.TabManager)
|
||||||
|
L1081 method stopAutoSave (notifyUser.TabManager)
|
||||||
|
L1087 method performAutoSave (notifyUser.TabManager)
|
||||||
|
L1103 method showAutoSaveIndicator (notifyUser.TabManager)
|
||||||
|
L1119 method addToRecentFiles (notifyUser.TabManager)
|
||||||
|
L1135 method getRecentFiles (notifyUser.TabManager)
|
||||||
|
L1145 method setupToolbarEvents (notifyUser.TabManager)
|
||||||
|
L1215 method wrapSelection (notifyUser.TabManager)
|
||||||
|
L1233 method insertAtLineStart (notifyUser.TabManager)
|
||||||
|
L1249 method insertTable (notifyUser.TabManager)
|
||||||
|
L1259 method insertCodeBlock (notifyUser.TabManager)
|
||||||
|
L1277 method insertHorizontalRule (notifyUser.TabManager)
|
||||||
|
L1280 method setupFindEvents (notifyUser.TabManager)
|
||||||
|
L1350 method performFind (notifyUser.TabManager)
|
||||||
|
L1384 method findNext (notifyUser.TabManager)
|
||||||
|
L1390 method findPrevious (notifyUser.TabManager)
|
||||||
|
L1399 method highlightMatch (notifyUser.TabManager)
|
||||||
|
L1430 method replaceOne (notifyUser.TabManager)
|
||||||
|
L1454 method replaceAll (notifyUser.TabManager)
|
||||||
|
L1485 method clearFindHighlights (notifyUser.TabManager)
|
||||||
|
L1492 method checkForLargeFile (notifyUser.TabManager)
|
||||||
|
L1508 method openFile (notifyUser.TabManager)
|
||||||
|
L1555 method getEditorContent (notifyUser.TabManager)
|
||||||
|
L1564 method setEditorContent (notifyUser.TabManager)
|
||||||
|
L1579 method insertAtCursor (notifyUser.TabManager)
|
||||||
|
L1594 method getSelection (notifyUser.TabManager)
|
||||||
|
L1602 method replaceSelection (notifyUser.TabManager)
|
||||||
|
L1616 method getCurrentContent (notifyUser.TabManager)
|
||||||
|
L1620 method getCurrentFilePath (notifyUser.TabManager)
|
||||||
|
L1735 method render (anonymousObject71555c7b2d05)
|
||||||
|
L1788 method registerIndicator (anonymousObject71555c7b3105.statusBar)
|
||||||
|
L1881 function onload (reader)
|
||||||
|
L2032 method onChange (anonymousObject71555c7b3405)
|
||||||
|
L2040 method onUpdate (anonymousObject71555c7b3405)
|
||||||
|
L2164 function applyCustomPreviewCSS (notifyUser)
|
||||||
|
L2174 function triggerLoadCustomCSS (notifyUser)
|
||||||
|
L2188 function triggerClearCustomCSS (notifyUser)
|
||||||
|
L2202 function updateFontSizes (notifyUser)
|
||||||
|
L2234 function openPrintPreviewDialog (notifyUser)
|
||||||
|
L2255 function showExportDialog (notifyUser)
|
||||||
|
L2269 function hideExportDialog (notifyUser)
|
||||||
|
L2273 function initializeExportForm (notifyUser)
|
||||||
|
L2343 function collectExportOptions (notifyUser)
|
||||||
|
L2444 function loadExportProfiles (notifyUser)
|
||||||
|
L2456 function saveExportProfiles (notifyUser)
|
||||||
|
L2459 function populateProfileDropdown (notifyUser)
|
||||||
|
L2476 function saveCurrentProfile (notifyUser)
|
||||||
|
L2511 function loadProfile (notifyUser)
|
||||||
|
L2545 function deleteSelectedProfile (notifyUser)
|
||||||
|
L2679 function onchange (input)
|
||||||
|
L2776 function showBatchDialog (notifyUser)
|
||||||
|
L2796 function hideBatchDialog (notifyUser)
|
||||||
|
L2799 function updateBatchProgress (notifyUser)
|
||||||
|
L2816 function validateBatchForm (notifyUser)
|
||||||
|
L3375 function showUniversalConverterDialog (notifyUser)
|
||||||
|
L3383 function updateConverterFormats (notifyUser)
|
||||||
|
L3423 function updateConverterAdvancedOptions (notifyUser)
|
||||||
|
L3434 function collectConverterAdvancedOptions (notifyUser)
|
||||||
|
L3645 function showPDFEditorDialog (notifyUser)
|
||||||
|
L3769 function hidePDFEditorDialog (notifyUser)
|
||||||
|
L3780 function updateMergeFilesList (notifyUser)
|
||||||
|
L4107 function getPDFStatusElement (notifyUser)
|
||||||
|
L4110 function showPDFStatus (notifyUser)
|
||||||
|
L4117 function clearPDFStatus (notifyUser)
|
||||||
|
L4124 function showPDFValidationMessage (notifyUser)
|
||||||
|
L4135 function processPDFOperation (notifyUser)
|
||||||
|
L4379 function initMathSupport (notifyUser)
|
||||||
|
L4391 function onload (katexJS)
|
||||||
|
L4396 function onload (autoRenderJS)
|
||||||
|
L4417 function openHeaderFooterDialog (notifyUser)
|
||||||
|
L4425 function closeHeaderFooterDialog (notifyUser)
|
||||||
|
L4430 function openFieldPickerDialog (notifyUser)
|
||||||
|
L4436 function closeFieldPickerDialog (notifyUser)
|
||||||
|
L4477 function toggleConfigContent (notifyUser)
|
||||||
|
L4488 function saveHeaderFooterSettings (notifyUser)
|
||||||
|
L4509 function browseForLogo (notifyUser)
|
||||||
|
L4519 function clearLogo (notifyUser)
|
||||||
|
L4528 function insertDynamicField (notifyUser)
|
||||||
|
L4594 function showTableGenerator (notifyUser)
|
||||||
|
L4605 function hideTableGenerator (notifyUser)
|
||||||
|
L4608 function generateTablePreview (notifyUser)
|
||||||
|
L4616 function generateMarkdownTable (notifyUser)
|
||||||
|
L4685 function insertGeneratedTable (notifyUser)
|
||||||
|
L4742 function showASCIIGenerator (notifyUser)
|
||||||
|
L4753 function hideASCIIGenerator (notifyUser)
|
||||||
|
L4756 function switchASCIIMode (notifyUser)
|
||||||
|
L4784 function generateASCIIPreview (notifyUser)
|
||||||
|
L4802 function textToASCII (notifyUser)
|
||||||
|
L5238 function createASCIIBox (notifyUser)
|
||||||
|
L5312 function getASCIITemplate (notifyUser)
|
||||||
|
L5469 function loadASCIITemplate (notifyUser)
|
||||||
|
L5473 function insertASCIIArt (notifyUser)
|
||||||
|
L5523 function anonymousFunction71555c7bd300
|
||||||
|
L5603 function getPdfjsLib (notifyUser)
|
||||||
|
L5612 function openPdfFile (notifyUser)
|
||||||
|
L5627 function renderPdfPage (notifyUser)
|
||||||
|
L5733 function closePdfViewer (notifyUser)
|
||||||
|
L5783 function openPdfEditorDialog (notifyUser)
|
||||||
|
L5831 function initPaneResizer (notifyUser)
|
||||||
|
L5920 function onPDFFileSelected (notifyUser)
|
||||||
|
L5935 function loadPDFThumbnails (notifyUser)
|
||||||
|
L5966 function renderThumbnailGrid (notifyUser)
|
||||||
|
L6060 function renderThumbnail (notifyUser)
|
||||||
|
L6085 function syncRotateInput (notifyUser)
|
||||||
|
L6094 function syncDeleteInput (notifyUser)
|
||||||
|
L6103 function syncReorderInput (notifyUser)
|
||||||
|
|
||||||
|
src/repl/repl-panel.js:
|
||||||
|
L1 class ReplPanel
|
||||||
|
L2 method constructor (ReplPanel)
|
||||||
|
L8 method setupEventListeners (ReplPanel)
|
||||||
|
L13 method toggle (ReplPanel)
|
||||||
|
L19 method show (ReplPanel)
|
||||||
|
L25 method clear (ReplPanel)
|
||||||
|
L29 method appendOutput (ReplPanel)
|
||||||
|
L42 method escapeHtml (ReplPanel)
|
||||||
|
|
||||||
|
src/sidebar/explorer-panel.js:
|
||||||
|
L1 function renderExplorerPanel
|
||||||
|
L40 function renderTree
|
||||||
|
L79 function getFileIcon
|
||||||
|
|
||||||
|
src/sidebar/git-panel.js:
|
||||||
|
L1 function renderGitPanel
|
||||||
|
L24 function loadGitStatus (renderGitPanel)
|
||||||
|
|
||||||
|
src/sidebar/outline-panel.js:
|
||||||
|
L6 function renderOutlinePanel
|
||||||
|
L18 function parseHeadings (renderOutlinePanel)
|
||||||
|
L36 function findActiveHeading (renderOutlinePanel)
|
||||||
|
L48 function renderHeadings (renderOutlinePanel)
|
||||||
|
L89 function escapeHtml (renderOutlinePanel)
|
||||||
|
L95 function refresh (renderOutlinePanel)
|
||||||
|
L100 function setActiveHeading (renderOutlinePanel)
|
||||||
|
|
||||||
|
src/sidebar/sidebar-manager.js:
|
||||||
|
L1 class SidebarManager
|
||||||
|
L2 method constructor (SidebarManager)
|
||||||
|
L11 method setupEventListeners (SidebarManager)
|
||||||
|
L20 method registerPanel (SidebarManager)
|
||||||
|
L24 method togglePanel (SidebarManager)
|
||||||
|
L32 method expand (SidebarManager)
|
||||||
|
L45 method collapse (SidebarManager)
|
||||||
|
|
||||||
|
src/sidebar/snippets-panel.js:
|
||||||
|
L1 function renderSnippetsPanel
|
||||||
|
L14 function loadSnippets (renderSnippetsPanel)
|
||||||
|
L19 function renderList (renderSnippetsPanel)
|
||||||
|
|
||||||
|
src/sidebar/templates-panel.js:
|
||||||
|
L18 function renderTemplatesPanel
|
||||||
|
|
||||||
|
src/utils/ModalManager.js:
|
||||||
|
L5 class ModalManager
|
||||||
|
L16 method constructor (ModalManager)
|
||||||
|
L31 method init (ModalManager)
|
||||||
|
L47 method setupCloseTriggers (ModalManager)
|
||||||
|
L51 function handler (ModalManager.setupCloseTriggers)
|
||||||
|
L65 function handler
|
||||||
|
L74 method getFocusableElements (ModalManager)
|
||||||
|
L89 method trapFocus (ModalManager)
|
||||||
|
L111 method handleKeydown (ModalManager)
|
||||||
|
L119 method open (ModalManager)
|
||||||
|
L141 function keydownHandler (ModalManager.open)
|
||||||
|
L164 method close (ModalManager)
|
||||||
|
L182 function addHidden (ModalManager.close)
|
||||||
|
L188 function onTransitionEnd (ModalManager.close)
|
||||||
|
L223 method isOpen (ModalManager)
|
||||||
|
L227 method destroy (ModalManager)
|
||||||
|
|
||||||
|
src/welcome.js:
|
||||||
|
L1 function createWelcomeContent
|
||||||
|
|
||||||
|
src/wordTemplateExporter.js:
|
||||||
|
L11 class WordTemplateExporter
|
||||||
|
L12 method constructor (WordTemplateExporter)
|
||||||
|
L24 method preprocessMarkdownForWordExport (WordTemplateExporter)
|
||||||
|
L33 function flush (WordTemplateExporter.preprocessMarkdownForWordExport)
|
||||||
|
L59 function stripArtifacts (WordTemplateExporter.preprocessMarkdownForWordExport)
|
||||||
|
|
||||||
|
src/zen-mode.js:
|
||||||
|
L10 class ZenMode
|
||||||
|
L14 method constructor (ZenMode)
|
||||||
|
L23 method activate (ZenMode)
|
||||||
|
L65 method deactivate (ZenMode)
|
||||||
|
L101 method toggle (ZenMode)
|
||||||
|
L109 method _applyTypewriterBehavior (ZenMode)
|
||||||
|
L111 function scrollFn (ZenMode._applyTypewriterBehavior)
|
||||||
|
L151 method _createHUD (ZenMode)
|
||||||
|
L169 method _updateHUD (ZenMode)
|
||||||
|
L220 method constructor (anonymousObject3acf7ce30205)
|
||||||
|
L240 function getOpacity
|
||||||
|
L250 method constructor (anonymousObject3acf7ce30405)
|
||||||
|
|
||||||
|
tests/git-operations.test.js:
|
||||||
|
L26 function asyncFn
|
||||||
|
L35 function asyncFnWithError
|
||||||
|
|
||||||
|
tests/main-utils.test.js:
|
||||||
|
L7 function sanitizeErrorMessage
|
||||||
|
L49 function createRateLimiter
|
||||||
|
L51 function canProceed (createRateLimiter)
|
||||||
|
|
||||||
|
tests/markdown-extensions.test.js:
|
||||||
|
L83 method start (extension)
|
||||||
|
L86 method tokenizer (extension)
|
||||||
|
L102 method renderer (extension)
|
||||||
|
L151 function plantumlEncode
|
||||||
|
L176 function slugify
|
||||||
|
L203 function scopeCSS
|
||||||
|
|
||||||
|
tests/modal-manager.test.js:
|
||||||
|
L11 function createModalElement
|
||||||
|
|
||||||
|
tests/plugin-api.test.js:
|
||||||
|
L18 class MyPlugin
|
||||||
|
L19 method init (MyPlugin)
|
||||||
|
|
||||||
|
tests/plugin-context.test.js:
|
||||||
|
L39 function badHandler
|
||||||
|
|
||||||
|
tests/plugin-loader.test.js:
|
||||||
|
L17 function writeManifest
|
||||||
|
|
||||||
|
tests/plugin-registry.test.js:
|
||||||
|
L5 class TestPlugin
|
||||||
|
L6 method init (TestPlugin)
|
||||||
|
L10 method activate (TestPlugin)
|
||||||
|
L13 method deactivate (TestPlugin)
|
||||||
|
L69 class BadPlugin
|
||||||
|
L70 method init (BadPlugin)
|
||||||
|
|
||||||
|
tests/setup.js:
|
||||||
|
L96 function error (console)
|
||||||
|
|
||||||
|
tests/sidebar.test.js:
|
||||||
|
L41 method render (anonymousObjectfe2d35d70105)
|
||||||
|
L52 method render (anonymousObjectfe2d35d70205)
|
||||||
|
L62 method render (anonymousObjectfe2d35d70305)
|
||||||
|
L68 method render (anonymousObjectfe2d35d70405)
|
||||||
|
L80 method render (anonymousObjectfe2d35d70505)
|
||||||
|
L88 method render (anonymousObjectfe2d35d70605)
|
||||||
|
L96 method render (anonymousObjectfe2d35d70705)
|
||||||
|
L120 method render (anonymousObjectfe2d35d70905)
|
||||||
|
L130 method render (anonymousObjectfe2d35d70a05)
|
||||||
|
|
||||||
|
tests/utils.test.js:
|
||||||
|
L9 function parseCommand
|
||||||
|
L78 function hexToRgb
|
||||||
|
L121 function getExtension
|
||||||
|
L133 function replaceExtension
|
||||||
|
```
|
||||||
@@ -65,7 +65,7 @@ const electronFsAdapter = {
|
|||||||
isDir: entry.isDirectory,
|
isDir: entry.isDirectory,
|
||||||
size: entry.size ?? 0,
|
size: entry.size ?? 0,
|
||||||
modified: entry.modified ?? 0,
|
modified: entry.modified ?? 0,
|
||||||
path: entry.path
|
path: entry.path,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ const electronFsAdapter = {
|
|||||||
*/
|
*/
|
||||||
async move(source, dest) {
|
async move(source, dest) {
|
||||||
return await window.electronAPI.file.move(source, dest);
|
return await window.electronAPI.file.move(source, dest);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { electronFsAdapter };
|
module.exports = { electronFsAdapter };
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ function showAnalyticsModal(tabManager) {
|
|||||||
<span class="analytics-label">Avg Sentence</span>
|
<span class="analytics-label">Avg Sentence</span>
|
||||||
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
||||||
</div>
|
</div>
|
||||||
${metrics.longestSentenceLength > 0 ? `
|
${
|
||||||
|
metrics.longestSentenceLength > 0
|
||||||
|
? `
|
||||||
<div class="analytics-row analytics-longest">
|
<div class="analytics-row analytics-longest">
|
||||||
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
||||||
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
||||||
</div>` : ''}
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="analytics-section">
|
<div class="analytics-section">
|
||||||
@@ -74,13 +78,19 @@ function showAnalyticsModal(tabManager) {
|
|||||||
<span class="analytics-label">Unique</span>
|
<span class="analytics-label">Unique</span>
|
||||||
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
||||||
</div>
|
</div>
|
||||||
${metrics.topWords.length > 0 ? `
|
${
|
||||||
|
metrics.topWords.length > 0
|
||||||
|
? `
|
||||||
<div class="word-cloud">
|
<div class="word-cloud">
|
||||||
${metrics.topWords.map(w => {
|
${metrics.topWords
|
||||||
|
.map((w) => {
|
||||||
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
||||||
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
||||||
}).join('')}
|
})
|
||||||
</div>` : ''}
|
.join('')}
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,74 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const STOP_WORDS = new Set([
|
const STOP_WORDS = new Set([
|
||||||
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
'the',
|
||||||
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
'a',
|
||||||
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with',
|
'an',
|
||||||
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it',
|
'is',
|
||||||
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
|
'are',
|
||||||
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
|
'was',
|
||||||
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too',
|
'were',
|
||||||
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who'
|
'be',
|
||||||
|
'been',
|
||||||
|
'have',
|
||||||
|
'has',
|
||||||
|
'had',
|
||||||
|
'do',
|
||||||
|
'does',
|
||||||
|
'did',
|
||||||
|
'will',
|
||||||
|
'would',
|
||||||
|
'could',
|
||||||
|
'should',
|
||||||
|
'to',
|
||||||
|
'of',
|
||||||
|
'in',
|
||||||
|
'for',
|
||||||
|
'on',
|
||||||
|
'with',
|
||||||
|
'at',
|
||||||
|
'by',
|
||||||
|
'from',
|
||||||
|
'as',
|
||||||
|
'and',
|
||||||
|
'or',
|
||||||
|
'but',
|
||||||
|
'if',
|
||||||
|
'it',
|
||||||
|
'its',
|
||||||
|
'this',
|
||||||
|
'that',
|
||||||
|
'these',
|
||||||
|
'those',
|
||||||
|
'i',
|
||||||
|
'me',
|
||||||
|
'my',
|
||||||
|
'we',
|
||||||
|
'our',
|
||||||
|
'you',
|
||||||
|
'your',
|
||||||
|
'he',
|
||||||
|
'him',
|
||||||
|
'his',
|
||||||
|
'she',
|
||||||
|
'her',
|
||||||
|
'they',
|
||||||
|
'them',
|
||||||
|
'their',
|
||||||
|
'not',
|
||||||
|
'no',
|
||||||
|
'so',
|
||||||
|
'than',
|
||||||
|
'too',
|
||||||
|
'very',
|
||||||
|
'also',
|
||||||
|
'just',
|
||||||
|
'about',
|
||||||
|
'up',
|
||||||
|
'out',
|
||||||
|
'what',
|
||||||
|
'which',
|
||||||
|
'who',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function countSyllables(word) {
|
function countSyllables(word) {
|
||||||
@@ -48,17 +108,23 @@ function analyze(text) {
|
|||||||
avgSentenceLength: 0,
|
avgSentenceLength: 0,
|
||||||
longestSentence: '',
|
longestSentence: '',
|
||||||
longestSentenceLength: 0,
|
longestSentenceLength: 0,
|
||||||
topWords: []
|
topWords: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const words = extractWords(text);
|
const words = extractWords(text);
|
||||||
const wordCount = words.length;
|
const wordCount = words.length;
|
||||||
|
|
||||||
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
|
const sentences = text
|
||||||
|
.split(/[.!?]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const sentenceCount = Math.max(sentences.length, 1);
|
const sentenceCount = Math.max(sentences.length, 1);
|
||||||
|
|
||||||
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
|
const paragraphs = text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const paragraphCount = Math.max(paragraphs.length, 1);
|
const paragraphCount = Math.max(paragraphs.length, 1);
|
||||||
|
|
||||||
let totalSyllables = 0;
|
let totalSyllables = 0;
|
||||||
@@ -66,16 +132,23 @@ function analyze(text) {
|
|||||||
totalSyllables += countSyllables(w);
|
totalSyllables += countSyllables(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10;
|
const fleschEase =
|
||||||
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10;
|
Math.round(
|
||||||
|
(206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10
|
||||||
|
) / 10;
|
||||||
|
const fleschGrade =
|
||||||
|
Math.round(
|
||||||
|
(0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10
|
||||||
|
) / 10;
|
||||||
const readabilityLabel = getReadabilityLabel(fleschEase);
|
const readabilityLabel = getReadabilityLabel(fleschEase);
|
||||||
|
|
||||||
const readingTime = Math.ceil(wordCount / 200);
|
const readingTime = Math.ceil(wordCount / 200);
|
||||||
const speakingTime = Math.ceil(wordCount / 130);
|
const speakingTime = Math.ceil(wordCount / 130);
|
||||||
|
|
||||||
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
|
const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
|
||||||
const uniqueWordCount = uniqueWords.size;
|
const uniqueWordCount = uniqueWords.size;
|
||||||
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
const lexicalDiversity =
|
||||||
|
wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
||||||
|
|
||||||
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
||||||
|
|
||||||
@@ -120,7 +193,7 @@ function analyze(text) {
|
|||||||
avgSentenceLength,
|
avgSentenceLength,
|
||||||
longestSentence,
|
longestSentence,
|
||||||
longestSentenceLength,
|
longestSentenceLength,
|
||||||
topWords
|
topWords,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+288
-139
@@ -1,10 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ASCII Art Generator - MarkdownConverter</title>
|
<title>ASCII Art Generator - MarkdownConverter</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--ci-dark-gray: #464646;
|
--ci-dark-gray: #464646;
|
||||||
@@ -103,7 +106,9 @@
|
|||||||
color: var(--ci-dark-gray);
|
color: var(--ci-dark-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input, .form-select, .form-textarea {
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 2px solid var(--ci-light-gray);
|
border: 2px solid var(--ci-light-gray);
|
||||||
@@ -113,7 +118,9 @@
|
|||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
.form-input:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--ci-accent);
|
border-color: var(--ci-accent);
|
||||||
}
|
}
|
||||||
@@ -227,8 +234,8 @@
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>ASCII Art Generator</h1>
|
<h1>ASCII Art Generator</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +252,13 @@
|
|||||||
<div id="text-mode" class="mode-section active">
|
<div id="text-mode" class="mode-section active">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Text to Convert</label>
|
<label class="form-label">Text to Convert</label>
|
||||||
<input type="text" id="text-input" class="form-input" placeholder="Enter your text..." maxlength="30">
|
<input
|
||||||
|
type="text"
|
||||||
|
id="text-input"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter your text..."
|
||||||
|
maxlength="30"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Style</label>
|
<label class="form-label">Style</label>
|
||||||
@@ -263,7 +276,11 @@
|
|||||||
<div id="box-mode" class="mode-section">
|
<div id="box-mode" class="mode-section">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Text Content</label>
|
<label class="form-label">Text Content</label>
|
||||||
<textarea id="box-text" class="form-textarea" placeholder="Enter text for the box..."></textarea>
|
<textarea
|
||||||
|
id="box-text"
|
||||||
|
class="form-textarea"
|
||||||
|
placeholder="Enter text for the box..."
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Box Style</label>
|
<label class="form-label">Box Style</label>
|
||||||
@@ -277,7 +294,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Padding</label>
|
<label class="form-label">Padding</label>
|
||||||
<input type="number" id="box-padding" class="form-input" min="0" max="10" value="2" style="width: 100px;">
|
<input
|
||||||
|
type="number"
|
||||||
|
id="box-padding"
|
||||||
|
class="form-input"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
value="2"
|
||||||
|
style="width: 100px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -341,138 +366,262 @@
|
|||||||
standard: {
|
standard: {
|
||||||
height: 5,
|
height: 5,
|
||||||
chars: {
|
chars: {
|
||||||
'A': [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
|
A: [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
|
||||||
'B': ['|----\\', '| |', '|----/', '| \\', '|----/'],
|
B: ['|----\\', '| |', '|----/', '| \\', '|----/'],
|
||||||
'C': ['/----\\', '| ', '| ', '| ', '\\----/'],
|
C: ['/----\\', '| ', '| ', '| ', '\\----/'],
|
||||||
'D': ['|----\\', '| |', '| |', '| |', '|----/'],
|
D: ['|----\\', '| |', '| |', '| |', '|----/'],
|
||||||
'E': ['|----', '| ', '|--- ', '| ', '|----'],
|
E: ['|----', '| ', '|--- ', '| ', '|----'],
|
||||||
'F': ['|----', '| ', '|--- ', '| ', '| '],
|
F: ['|----', '| ', '|--- ', '| ', '| '],
|
||||||
'G': ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
|
G: ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
|
||||||
'H': ['| |', '| |', '|----/', '| |', '| |'],
|
H: ['| |', '| |', '|----/', '| |', '| |'],
|
||||||
'I': ['|---|', ' | ', ' | ', ' | ', '|---|'],
|
I: ['|---|', ' | ', ' | ', ' | ', '|---|'],
|
||||||
'J': [' |', ' |', ' |', '| |', '\\---/'],
|
J: [' |', ' |', ' |', '| |', '\\---/'],
|
||||||
'K': ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
|
K: ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
|
||||||
'L': ['| ', '| ', '| ', '| ', '|----'],
|
L: ['| ', '| ', '| ', '| ', '|----'],
|
||||||
'M': ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
|
M: ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
|
||||||
'N': ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
|
N: ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
|
||||||
'O': ['/----\\', '| |', '| |', '| |', '\\----/'],
|
O: ['/----\\', '| |', '| |', '| |', '\\----/'],
|
||||||
'P': ['|----\\', '| |', '|----/', '| ', '| '],
|
P: ['|----\\', '| |', '|----/', '| ', '| '],
|
||||||
'Q': ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
|
Q: ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
|
||||||
'R': ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
|
R: ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
|
||||||
'S': ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
|
S: ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
|
||||||
'T': ['-----', ' | ', ' | ', ' | ', ' | '],
|
T: ['-----', ' | ', ' | ', ' | ', ' | '],
|
||||||
'U': ['| |', '| |', '| |', '| |', '\\----/'],
|
U: ['| |', '| |', '| |', '| |', '\\----/'],
|
||||||
'V': ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
|
V: ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
|
||||||
'W': ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
|
W: ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
|
||||||
'X': ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
|
X: ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
|
||||||
'Y': ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
|
Y: ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
|
||||||
'Z': ['-----', ' / ', ' / ', ' / ', '-----'],
|
Z: ['-----', ' / ', ' / ', ' / ', '-----'],
|
||||||
' ': [' ', ' ', ' ', ' ', ' '],
|
' ': [' ', ' ', ' ', ' ', ' '],
|
||||||
'0': ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
|
0: ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
|
||||||
'1': [' /| ', ' / | ', ' | ', ' | ', ' ----'],
|
1: [' /| ', ' / | ', ' | ', ' | ', ' ----'],
|
||||||
'2': ['/---\\', ' |', ' ---/', '/ ', '-----'],
|
2: ['/---\\', ' |', ' ---/', '/ ', '-----'],
|
||||||
'3': ['----\\', ' |', ' ---/', ' |', '----/'],
|
3: ['----\\', ' |', ' ---/', ' |', '----/'],
|
||||||
'4': ['| |', '| |', '-----', ' |', ' |'],
|
4: ['| |', '| |', '-----', ' |', ' |'],
|
||||||
'5': ['-----', '| ', '----\\', ' |', '----/'],
|
5: ['-----', '| ', '----\\', ' |', '----/'],
|
||||||
'6': ['/----', '| ', '|---\\', '| |', '\\---/'],
|
6: ['/----', '| ', '|---\\', '| |', '\\---/'],
|
||||||
'7': ['-----', ' / ', ' / ', ' / ', '/ '],
|
7: ['-----', ' / ', ' / ', ' / ', '/ '],
|
||||||
'8': ['/---\\', '| |', ' --- ', '| |', '\\---/'],
|
8: ['/---\\', '| |', ' --- ', '| |', '\\---/'],
|
||||||
'9': ['/---\\', '| |', '\\----', ' |', '----/']
|
9: ['/---\\', '| |', '\\----', ' |', '----/'],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
banner: {
|
banner: {
|
||||||
height: 7,
|
height: 7,
|
||||||
chars: {
|
chars: {
|
||||||
'A': [' ##### ', ' ## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
|
A: [
|
||||||
'B': ['######## ', '## ##', '## ##', '######## ', '## ##', '## ##', '######## '],
|
' ##### ',
|
||||||
'C': [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
|
' ## ##',
|
||||||
'D': ['######## ', '## ##', '## ##', '## ##', '## ##', '## ##', '######## '],
|
'## ##',
|
||||||
'E': ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
|
'#########',
|
||||||
'F': ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
|
'## ##',
|
||||||
'G': [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
|
'## ##',
|
||||||
'H': ['## ##', '## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
|
'## ##',
|
||||||
'I': ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
|
],
|
||||||
'J': [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
|
B: [
|
||||||
'K': ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
|
'######## ',
|
||||||
'L': ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
|
'## ##',
|
||||||
'M': ['## ##', '### ###', '#### ####', '## ### ##', '## ##', '## ##', '## ##'],
|
'## ##',
|
||||||
'N': ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
|
'######## ',
|
||||||
'O': [' ####### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
|
'## ##',
|
||||||
'P': ['######## ', '## ##', '## ##', '######## ', '## ', '## ', '## '],
|
'## ##',
|
||||||
'Q': [' ####### ', '## ##', '## ##', '## ##', '## ## ##', '## ## ', ' ##### ##'],
|
'######## ',
|
||||||
'R': ['######## ', '## ##', '## ##', '######## ', '## ## ', '## ## ', '## ##'],
|
],
|
||||||
'S': [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
|
C: [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
|
||||||
'T': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
D: [
|
||||||
'U': ['## ##', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
|
'######## ',
|
||||||
'V': ['## ##', '## ##', '## ##', '## ##', ' ## ## ', ' ## ## ', ' ### '],
|
'## ##',
|
||||||
'W': ['## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', ' ### ### '],
|
'## ##',
|
||||||
'X': ['## ##', ' ## ## ', ' ## ## ', ' ### ', ' ## ## ', ' ## ## ', '## ##'],
|
'## ##',
|
||||||
'Y': ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
|
'## ##',
|
||||||
'Z': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
'## ##',
|
||||||
|
'######## ',
|
||||||
|
],
|
||||||
|
E: ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
|
||||||
|
F: ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
|
||||||
|
G: [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
|
||||||
|
H: [
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'#########',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
I: ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
|
||||||
|
J: [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
|
||||||
|
K: ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
|
||||||
|
L: ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
|
||||||
|
M: [
|
||||||
|
'## ##',
|
||||||
|
'### ###',
|
||||||
|
'#### ####',
|
||||||
|
'## ### ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
N: ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
|
||||||
|
O: [
|
||||||
|
' ####### ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
' ####### ',
|
||||||
|
],
|
||||||
|
P: [
|
||||||
|
'######## ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'######## ',
|
||||||
|
'## ',
|
||||||
|
'## ',
|
||||||
|
'## ',
|
||||||
|
],
|
||||||
|
Q: [
|
||||||
|
' ####### ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ',
|
||||||
|
' ##### ##',
|
||||||
|
],
|
||||||
|
R: [
|
||||||
|
'######## ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'######## ',
|
||||||
|
'## ## ',
|
||||||
|
'## ## ',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
S: [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
|
||||||
|
T: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||||
|
U: [
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
' ####### ',
|
||||||
|
],
|
||||||
|
V: [
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
' ## ## ',
|
||||||
|
' ## ## ',
|
||||||
|
' ### ',
|
||||||
|
],
|
||||||
|
W: [
|
||||||
|
'## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
' ### ### ',
|
||||||
|
],
|
||||||
|
X: [
|
||||||
|
'## ##',
|
||||||
|
' ## ## ',
|
||||||
|
' ## ## ',
|
||||||
|
' ### ',
|
||||||
|
' ## ## ',
|
||||||
|
' ## ## ',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
Y: ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||||
|
Z: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
||||||
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
||||||
'0': [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
|
0: [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
|
||||||
'1': [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
|
1: [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
|
||||||
'2': [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
2: [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
||||||
'3': [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
|
3: [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
|
||||||
'4': [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
|
4: [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
|
||||||
'5': ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
|
5: ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
|
||||||
'6': [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
|
6: [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
|
||||||
'7': ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
7: ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||||
'8': [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
|
8: [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
|
||||||
'9': [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### ']
|
9: [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### '],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
block: {
|
block: {
|
||||||
height: 6,
|
height: 6,
|
||||||
chars: {
|
chars: {
|
||||||
'A': ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
A: ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
||||||
'B': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
||||||
'C': ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
|
C: ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
|
||||||
'D': ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
|
D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
|
||||||
'E': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
|
E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
|
||||||
'F': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
|
F: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
|
||||||
'G': ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
|
G: ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
|
||||||
'H': ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
H: ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
||||||
'I': ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
|
I: ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
|
||||||
'J': [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
|
J: [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
|
||||||
'K': ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
|
K: ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
|
||||||
'L': ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
|
L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
|
||||||
'M': ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
|
M: [
|
||||||
'N': ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
|
'███╗ ███╗',
|
||||||
'O': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
'████╗ ████║',
|
||||||
'P': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
|
'██╔████╔██║',
|
||||||
'Q': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
|
'██║╚██╔╝██║',
|
||||||
'R': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
'██║ ╚═╝ ██║',
|
||||||
'S': ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
|
'╚═╝ ╚═╝',
|
||||||
'T': ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
|
],
|
||||||
'U': ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
N: ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
|
||||||
'V': ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
O: ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
||||||
'W': ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
|
P: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
|
||||||
'X': ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
|
Q: ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
|
||||||
'Y': ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
|
R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
||||||
'Z': ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
|
S: ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
|
||||||
' ': [' ', ' ', ' ', ' ', ' ', ' ']
|
T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
|
||||||
}
|
U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
||||||
}
|
V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
||||||
|
W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
|
||||||
|
X: ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
|
||||||
|
Y: ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
|
||||||
|
Z: ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
|
||||||
|
' ': [' ', ' ', ' ', ' ', ' ', ' '],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TEMPLATES = {
|
const TEMPLATES = {
|
||||||
'arrow-right': ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
|
'arrow-right':
|
||||||
'arrow-down': ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
|
' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
|
||||||
'decision': ' ╱╲\n ╱ ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
|
'arrow-down':
|
||||||
'process': '┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘',
|
' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
|
||||||
'flowchart': '┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘',
|
decision:
|
||||||
'sequence': ' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query │\n │ ├──────────►│\n │ │ │\n │ │ Result │\n │ │◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
|
' ╱╲\n ╱ ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
|
||||||
'network': ' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
|
process:
|
||||||
'hierarchy': ' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
|
'┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘',
|
||||||
'header': '╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
|
flowchart:
|
||||||
'note': '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
|
'┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘',
|
||||||
'warning': '╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
|
sequence:
|
||||||
'info': '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
|
' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query │\n │ ├──────────►│\n │ │ │\n │ │ Result │\n │ │◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
|
||||||
'divider': '════════════════════════════════════════',
|
network:
|
||||||
'separator': '╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
|
' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
|
||||||
'banner': '★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
|
hierarchy:
|
||||||
'checklist': '☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed'
|
' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
|
||||||
|
header:
|
||||||
|
'╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
|
||||||
|
note: '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
|
||||||
|
warning:
|
||||||
|
'╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
|
||||||
|
info: '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
|
||||||
|
divider: '════════════════════════════════════════',
|
||||||
|
separator:
|
||||||
|
'╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
|
||||||
|
banner:
|
||||||
|
'★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
|
||||||
|
checklist:
|
||||||
|
'☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOX_STYLES = {
|
const BOX_STYLES = {
|
||||||
@@ -480,20 +629,20 @@
|
|||||||
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
||||||
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
||||||
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
|
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
|
||||||
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
|
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentMode = 'text';
|
let currentMode = 'text';
|
||||||
let currentTemplate = null;
|
let currentTemplate = null;
|
||||||
|
|
||||||
// Mode switching
|
// Mode switching
|
||||||
document.querySelectorAll('.mode-tab').forEach(tab => {
|
document.querySelectorAll('.mode-tab').forEach((tab) => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active'));
|
||||||
tab.classList.add('active');
|
tab.classList.add('active');
|
||||||
currentMode = tab.dataset.mode;
|
currentMode = tab.dataset.mode;
|
||||||
|
|
||||||
document.querySelectorAll('.mode-section').forEach(s => s.classList.remove('active'));
|
document.querySelectorAll('.mode-section').forEach((s) => s.classList.remove('active'));
|
||||||
document.getElementById(currentMode + '-mode').classList.add('active');
|
document.getElementById(currentMode + '-mode').classList.add('active');
|
||||||
|
|
||||||
generatePreview();
|
generatePreview();
|
||||||
@@ -501,9 +650,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Template selection
|
// Template selection
|
||||||
document.querySelectorAll('.template-btn').forEach(btn => {
|
document.querySelectorAll('.template-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.template-btn').forEach((b) => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
currentTemplate = btn.dataset.template;
|
currentTemplate = btn.dataset.template;
|
||||||
generatePreview();
|
generatePreview();
|
||||||
@@ -531,7 +680,7 @@
|
|||||||
function generateBox(text, style, padding) {
|
function generateBox(text, style, padding) {
|
||||||
const box = BOX_STYLES[style] || BOX_STYLES.single;
|
const box = BOX_STYLES[style] || BOX_STYLES.single;
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const maxLen = Math.max(...lines.map(l => l.length)) + padding * 2;
|
const maxLen = Math.max(...lines.map((l) => l.length)) + padding * 2;
|
||||||
|
|
||||||
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
|
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
|
||||||
|
|
||||||
@@ -597,5 +746,5 @@
|
|||||||
// Initial preview
|
// Initial preview
|
||||||
generatePreview();
|
generatePreview();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -60,15 +60,19 @@ class CommandPalette {
|
|||||||
|
|
||||||
renderResults(query) {
|
renderResults(query) {
|
||||||
this.filteredCommands = query
|
this.filteredCommands = query
|
||||||
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
|
? this.commands.filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
: [...this.commands];
|
: [...this.commands];
|
||||||
|
|
||||||
this.results.innerHTML = this.filteredCommands.map((cmd, i) => `
|
this.results.innerHTML = this.filteredCommands
|
||||||
|
.map(
|
||||||
|
(cmd, i) => `
|
||||||
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
||||||
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
|
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
|
||||||
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
|
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
this.results.querySelectorAll('.command-item').forEach((el) => {
|
this.results.querySelectorAll('.command-item').forEach((el) => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
|
|||||||
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
||||||
// Language extensions loaded lazily on first use
|
// Language extensions loaded lazily on first use
|
||||||
let _javascript, _html, _css, _json, _python;
|
let _javascript, _html, _css, _json, _python;
|
||||||
const {
|
const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
|
||||||
defaultKeymap,
|
const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
|
||||||
history,
|
const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
|
||||||
historyKeymap,
|
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
|
||||||
indentWithTab,
|
|
||||||
} = require('@codemirror/commands');
|
|
||||||
const {
|
|
||||||
searchKeymap,
|
|
||||||
highlightSelectionMatches,
|
|
||||||
} = require('@codemirror/search');
|
|
||||||
const {
|
|
||||||
autocompletion,
|
|
||||||
completionKeymap,
|
|
||||||
} = require('@codemirror/autocomplete');
|
|
||||||
const {
|
|
||||||
bracketMatching,
|
|
||||||
foldGutter,
|
|
||||||
indentOnInput,
|
|
||||||
} = require('@codemirror/language');
|
|
||||||
const { oneDark } = require('@codemirror/theme-one-dark');
|
const { oneDark } = require('@codemirror/theme-one-dark');
|
||||||
|
|
||||||
// Custom theme for JetBrains Mono font
|
// Custom theme for JetBrains Mono font
|
||||||
const jetBrainsMonoTheme = EditorView.theme({
|
const jetBrainsMonoTheme = EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace"
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
|
||||||
},
|
},
|
||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
fontFamily: 'inherit'
|
fontFamily: 'inherit',
|
||||||
},
|
},
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
fontFamily: 'inherit'
|
fontFamily: 'inherit',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +44,14 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
* @returns {EditorView} the created editor view
|
* @returns {EditorView} the created editor view
|
||||||
*/
|
*/
|
||||||
function createEditor(parentElement, options = {}) {
|
function createEditor(parentElement, options = {}) {
|
||||||
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
|
console.log(
|
||||||
|
'[createEditor] Called with parentElement:',
|
||||||
|
parentElement?.id,
|
||||||
|
'dimensions:',
|
||||||
|
parentElement?.clientWidth,
|
||||||
|
'x',
|
||||||
|
parentElement?.clientHeight
|
||||||
|
);
|
||||||
if (!parentElement) {
|
if (!parentElement) {
|
||||||
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
||||||
return null;
|
return null;
|
||||||
@@ -125,11 +117,26 @@ function createEditor(parentElement, options = {}) {
|
|||||||
*/
|
*/
|
||||||
function getLanguageExtension(lang) {
|
function getLanguageExtension(lang) {
|
||||||
const loaders = {
|
const loaders = {
|
||||||
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); },
|
javascript: () => {
|
||||||
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); },
|
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
|
||||||
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); },
|
return _javascript();
|
||||||
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); },
|
},
|
||||||
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); },
|
html: () => {
|
||||||
|
if (!_html) _html = require('@codemirror/lang-html').html;
|
||||||
|
return _html();
|
||||||
|
},
|
||||||
|
css: () => {
|
||||||
|
if (!_css) _css = require('@codemirror/lang-css').css;
|
||||||
|
return _css();
|
||||||
|
},
|
||||||
|
json: () => {
|
||||||
|
if (!_json) _json = require('@codemirror/lang-json').json;
|
||||||
|
return _json();
|
||||||
|
},
|
||||||
|
python: () => {
|
||||||
|
if (!_python) _python = require('@codemirror/lang-python').python;
|
||||||
|
return _python();
|
||||||
|
},
|
||||||
markdown: () => markdown({ base: markdownLanguage }),
|
markdown: () => markdown({ base: markdownLanguage }),
|
||||||
};
|
};
|
||||||
loaders.js = loaders.javascript;
|
loaders.js = loaders.javascript;
|
||||||
|
|||||||
+1049
-262
File diff suppressed because it is too large
Load Diff
+1415
-936
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ async function getStatus(dir) {
|
|||||||
try {
|
try {
|
||||||
const git = getGitInstance(dir);
|
const git = getGitInstance(dir);
|
||||||
return await git.status();
|
return await git.status();
|
||||||
} catch (err) {
|
} catch {
|
||||||
return { error: 'Not a git repository' };
|
return { error: 'Not a git repository' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-39
@@ -4,11 +4,11 @@ const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
|||||||
|
|
||||||
function parsePageRanges(rangeString, totalPages) {
|
function parsePageRanges(rangeString, totalPages) {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const ranges = rangeString.split(',').map(r => r.trim());
|
const ranges = rangeString.split(',').map((r) => r.trim());
|
||||||
|
|
||||||
for (const range of ranges) {
|
for (const range of ranges) {
|
||||||
if (range.includes('-')) {
|
if (range.includes('-')) {
|
||||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
||||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||||
if (i > 0 && !pages.includes(i - 1)) {
|
if (i > 0 && !pages.includes(i - 1)) {
|
||||||
pages.push(i - 1);
|
pages.push(i - 1);
|
||||||
@@ -27,11 +27,13 @@ function parsePageRanges(rangeString, totalPages) {
|
|||||||
|
|
||||||
function hexToRgb(hex) {
|
function hexToRgb(hex) {
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
return result ? {
|
return result
|
||||||
|
? {
|
||||||
r: parseInt(result[1], 16) / 255,
|
r: parseInt(result[1], 16) / 255,
|
||||||
g: parseInt(result[2], 16) / 255,
|
g: parseInt(result[2], 16) / 255,
|
||||||
b: parseInt(result[3], 16) / 255
|
b: parseInt(result[3], 16) / 255,
|
||||||
} : { r: 0, g: 0, b: 0 };
|
}
|
||||||
|
: { r: 0, g: 0, b: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pdfMerge(data) {
|
async function pdfMerge(data) {
|
||||||
@@ -42,7 +44,7 @@ async function pdfMerge(data) {
|
|||||||
const pdfBytes = fs.readFileSync(filePath);
|
const pdfBytes = fs.readFileSync(filePath);
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||||
copiedPages.forEach(page => mergedPdf.addPage(page));
|
copiedPages.forEach((page) => mergedPdf.addPage(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await mergedPdf.save();
|
const pdfBytes = await mergedPdf.save();
|
||||||
@@ -63,13 +65,13 @@ async function pdfSplit(data) {
|
|||||||
const splits = [];
|
const splits = [];
|
||||||
|
|
||||||
if (data.splitMode === 'pages') {
|
if (data.splitMode === 'pages') {
|
||||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
const ranges = data.pageRanges.split(',').map((r) => r.trim());
|
||||||
for (let i = 0; i < ranges.length; i++) {
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
const range = ranges[i];
|
const range = ranges[i];
|
||||||
const pages = [];
|
const pages = [];
|
||||||
|
|
||||||
if (range.includes('-')) {
|
if (range.includes('-')) {
|
||||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
||||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||||
pages.push(p - 1);
|
pages.push(p - 1);
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,7 @@ async function pdfSplit(data) {
|
|||||||
for (const split of splits) {
|
for (const split of splits) {
|
||||||
const newPdf = await PDFDocument.create();
|
const newPdf = await PDFDocument.create();
|
||||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||||
|
|
||||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||||
const newPdfBytes = await newPdf.save();
|
const newPdfBytes = await newPdf.save();
|
||||||
@@ -129,18 +131,18 @@ async function pdfCompress(data) {
|
|||||||
const compressedPdfBytes = await pdf.save({
|
const compressedPdfBytes = await pdf.save({
|
||||||
useObjectStreams: true,
|
useObjectStreams: true,
|
||||||
addDefaultPage: false,
|
addDefaultPage: false,
|
||||||
objectsPerTick: 50
|
objectsPerTick: 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||||
|
|
||||||
const originalSize = fs.statSync(data.inputPath).size;
|
const originalSize = fs.statSync(data.inputPath).size;
|
||||||
const compressedSize = fs.statSync(data.outputPath).size;
|
const compressedSize = fs.statSync(data.outputPath).size;
|
||||||
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -160,7 +162,7 @@ async function pdfRotate(data) {
|
|||||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
}
|
}
|
||||||
|
|
||||||
pagesToRotate.forEach(pageIndex => {
|
pagesToRotate.forEach((pageIndex) => {
|
||||||
const page = pdf.getPage(pageIndex);
|
const page = pdf.getPage(pageIndex);
|
||||||
page.setRotation(degrees(data.angle));
|
page.setRotation(degrees(data.angle));
|
||||||
});
|
});
|
||||||
@@ -170,7 +172,7 @@ async function pdfRotate(data) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
|
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -185,7 +187,9 @@ async function pdfDeletePages(data) {
|
|||||||
|
|
||||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||||
|
|
||||||
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
pagesToDelete
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.forEach((pageIndex) => {
|
||||||
pdf.removePage(pageIndex);
|
pdf.removePage(pageIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,7 +198,7 @@ async function pdfDeletePages(data) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -207,7 +211,7 @@ async function pdfReorder(data) {
|
|||||||
const pdf = await PDFDocument.load(pdfBytes);
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
const totalPages = pdf.getPageCount();
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
|
const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
|
||||||
|
|
||||||
if (newOrder.length !== totalPages) {
|
if (newOrder.length !== totalPages) {
|
||||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||||
@@ -215,7 +219,7 @@ async function pdfReorder(data) {
|
|||||||
|
|
||||||
const newPdf = await PDFDocument.create();
|
const newPdf = await PDFDocument.create();
|
||||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||||
|
|
||||||
const reorderedPdfBytes = await newPdf.save();
|
const reorderedPdfBytes = await newPdf.save();
|
||||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||||
@@ -246,7 +250,9 @@ async function pdfWatermark(data) {
|
|||||||
const page = pdf.getPage(pageIndex);
|
const page = pdf.getPage(pageIndex);
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
|
|
||||||
let x, y, rotation = 0;
|
let x,
|
||||||
|
y,
|
||||||
|
rotation = 0;
|
||||||
|
|
||||||
switch (data.position) {
|
switch (data.position) {
|
||||||
case 'center':
|
case 'center':
|
||||||
@@ -294,7 +300,7 @@ async function pdfWatermark(data) {
|
|||||||
font,
|
font,
|
||||||
color: rgb(color.r, color.g, color.b),
|
color: rgb(color.r, color.g, color.b),
|
||||||
opacity: data.opacity,
|
opacity: data.opacity,
|
||||||
rotate: degrees(rotation)
|
rotate: degrees(rotation),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +309,7 @@ async function pdfWatermark(data) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -325,8 +331,8 @@ async function pdfEncrypt(data) {
|
|||||||
annotating: data.permissions.annotating,
|
annotating: data.permissions.annotating,
|
||||||
fillingForms: data.permissions.fillingForms,
|
fillingForms: data.permissions.fillingForms,
|
||||||
contentAccessibility: data.permissions.contentAccessibility,
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
documentAssembly: data.permissions.documentAssembly
|
documentAssembly: data.permissions.documentAssembly,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||||
@@ -336,7 +342,8 @@ async function pdfEncrypt(data) {
|
|||||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
error:
|
||||||
|
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -375,8 +382,8 @@ async function pdfSetPermissions(data) {
|
|||||||
annotating: data.permissions.annotating,
|
annotating: data.permissions.annotating,
|
||||||
fillingForms: data.permissions.fillingForms,
|
fillingForms: data.permissions.fillingForms,
|
||||||
contentAccessibility: data.permissions.contentAccessibility,
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
documentAssembly: data.permissions.documentAssembly
|
documentAssembly: data.permissions.documentAssembly,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
@@ -386,7 +393,8 @@ async function pdfSetPermissions(data) {
|
|||||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
error:
|
||||||
|
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -395,17 +403,28 @@ async function pdfSetPermissions(data) {
|
|||||||
|
|
||||||
function executeOperation(operation, data) {
|
function executeOperation(operation, data) {
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
case 'merge': return pdfMerge(data);
|
case 'merge':
|
||||||
case 'split': return pdfSplit(data);
|
return pdfMerge(data);
|
||||||
case 'compress': return pdfCompress(data);
|
case 'split':
|
||||||
case 'rotate': return pdfRotate(data);
|
return pdfSplit(data);
|
||||||
case 'delete': return pdfDeletePages(data);
|
case 'compress':
|
||||||
case 'reorder': return pdfReorder(data);
|
return pdfCompress(data);
|
||||||
case 'watermark': return pdfWatermark(data);
|
case 'rotate':
|
||||||
case 'encrypt': return pdfEncrypt(data);
|
return pdfRotate(data);
|
||||||
case 'decrypt': return pdfDecrypt(data);
|
case 'delete':
|
||||||
case 'permissions': return pdfSetPermissions(data);
|
return pdfDeletePages(data);
|
||||||
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
case 'reorder':
|
||||||
|
return pdfReorder(data);
|
||||||
|
case 'watermark':
|
||||||
|
return pdfWatermark(data);
|
||||||
|
case 'encrypt':
|
||||||
|
return pdfEncrypt(data);
|
||||||
|
case 'decrypt':
|
||||||
|
return pdfDecrypt(data);
|
||||||
|
case 'permissions':
|
||||||
|
return pdfSetPermissions(data);
|
||||||
|
default:
|
||||||
|
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,5 +448,5 @@ module.exports = {
|
|||||||
pdfDecrypt,
|
pdfDecrypt,
|
||||||
pdfSetPermissions,
|
pdfSetPermissions,
|
||||||
executeOperation,
|
executeOperation,
|
||||||
getPageCount
|
getPageCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,9 +5,7 @@
|
|||||||
"description": "Demonstrates the plugin system. Safe to delete.",
|
"description": "Demonstrates the plugin system. Safe to delete.",
|
||||||
"icon": "puzzle",
|
"icon": "puzzle",
|
||||||
"extensionPoints": {
|
"extensionPoints": {
|
||||||
"commands": [
|
"commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
|
||||||
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"settings": []
|
"settings": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
|
|
||||||
this.sprintEngine = new SprintEngine({
|
this.sprintEngine = new SprintEngine({
|
||||||
onEvent: (name, data) => context.events.emit(name, data)
|
onEvent: (name, data) => context.events.emit(name, data),
|
||||||
});
|
});
|
||||||
this.goalTracker = new GoalTracker(context.settings);
|
this.goalTracker = new GoalTracker(context.settings);
|
||||||
this.snapshotManager = new SnapshotManager(context.settings);
|
this.snapshotManager = new SnapshotManager(context.settings);
|
||||||
@@ -17,14 +17,14 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
readFile: (p) => context.ipc.invoke('read-file', p),
|
readFile: (p) => context.ipc.invoke('read-file', p),
|
||||||
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
||||||
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
||||||
listDir: (p) => context.ipc.invoke('list-directory', p)
|
listDir: (p) => context.ipc.invoke('list-directory', p),
|
||||||
});
|
});
|
||||||
|
|
||||||
this._engines = {
|
this._engines = {
|
||||||
sprint: this.sprintEngine,
|
sprint: this.sprintEngine,
|
||||||
goals: this.goalTracker,
|
goals: this.goalTracker,
|
||||||
snapshots: this.snapshotManager,
|
snapshots: this.snapshotManager,
|
||||||
projects: this.projectManager
|
projects: this.projectManager,
|
||||||
};
|
};
|
||||||
|
|
||||||
this._registerCommands(context);
|
this._registerCommands(context);
|
||||||
@@ -34,59 +34,89 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
_registerCommands(context) {
|
_registerCommands(context) {
|
||||||
const { sprintEngine, snapshotManager, goalTracker } = this;
|
const { sprintEngine, snapshotManager, goalTracker } = this;
|
||||||
|
|
||||||
context.commands.register('start-sprint', 'Studio: Start Sprint', () => {
|
context.commands.register(
|
||||||
|
'start-sprint',
|
||||||
|
'Studio: Start Sprint',
|
||||||
|
() => {
|
||||||
const duration = context.settings.get('sprintDuration') || 25;
|
const duration = context.settings.get('sprintDuration') || 25;
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
sprintEngine.start(duration, words);
|
sprintEngine.start(duration, words);
|
||||||
}, 'Ctrl+Alt+S');
|
},
|
||||||
|
'Ctrl+Alt+S'
|
||||||
|
);
|
||||||
|
|
||||||
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => {
|
context.commands.register(
|
||||||
|
'stop-sprint',
|
||||||
|
'Studio: Stop Sprint',
|
||||||
|
() => {
|
||||||
if (!sprintEngine.isActive()) return;
|
if (!sprintEngine.isActive()) return;
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
const result = sprintEngine.stop(words);
|
const result = sprintEngine.stop(words);
|
||||||
goalTracker.addWords(result.wordDelta);
|
goalTracker.addWords(result.wordDelta);
|
||||||
context.events.emit('sprint:stopped', result);
|
context.events.emit('sprint:stopped', result);
|
||||||
}, 'Ctrl+Alt+Shift+S');
|
},
|
||||||
|
'Ctrl+Alt+Shift+S'
|
||||||
|
);
|
||||||
|
|
||||||
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => {
|
context.commands.register(
|
||||||
|
'take-snapshot',
|
||||||
|
'Studio: Take Snapshot',
|
||||||
|
() => {
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
snapshotManager.create(content, 'manual');
|
snapshotManager.create(content, 'manual');
|
||||||
context.events.emit('snapshot:created', {});
|
context.events.emit('snapshot:created', {});
|
||||||
}, 'Ctrl+Alt+N');
|
},
|
||||||
|
'Ctrl+Alt+N'
|
||||||
|
);
|
||||||
|
|
||||||
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => {
|
context.commands.register(
|
||||||
|
'restore-last-snapshot',
|
||||||
|
'Studio: Restore Last Snapshot',
|
||||||
|
() => {
|
||||||
const snaps = snapshotManager.list();
|
const snaps = snapshotManager.list();
|
||||||
if (snaps.length === 0) return;
|
if (snaps.length === 0) return;
|
||||||
const content = snapshotManager.restore(snaps[0].id);
|
const content = snapshotManager.restore(snaps[0].id);
|
||||||
context.editor.insertAtCursor(content);
|
context.editor.insertAtCursor(content);
|
||||||
}, 'Ctrl+Alt+Z');
|
},
|
||||||
|
'Ctrl+Alt+Z'
|
||||||
|
);
|
||||||
|
|
||||||
context.commands.register('new-project', 'Studio: New Project', () => {
|
context.commands.register('new-project', 'Studio: New Project', () => {
|
||||||
context.events.emit('studio:new-project', {});
|
context.events.emit('studio:new-project', {});
|
||||||
});
|
});
|
||||||
|
|
||||||
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => {
|
context.commands.register(
|
||||||
|
'compile-manuscript',
|
||||||
|
'Studio: Compile Manuscript',
|
||||||
|
() => {
|
||||||
context.events.emit('studio:compile', {});
|
context.events.emit('studio:compile', {});
|
||||||
}, 'Ctrl+Alt+E');
|
},
|
||||||
|
'Ctrl+Alt+E'
|
||||||
|
);
|
||||||
|
|
||||||
context.commands.register('proofread-document', 'Studio: Proofread Document', () => {
|
context.commands.register(
|
||||||
|
'proofread-document',
|
||||||
|
'Studio: Proofread Document',
|
||||||
|
() => {
|
||||||
if (context.events.hasHandler('ai:analyze')) {
|
if (context.events.hasHandler('ai:analyze')) {
|
||||||
const content = context.editor.getContent() || '';
|
const content = context.editor.getContent() || '';
|
||||||
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
||||||
}
|
}
|
||||||
}, 'Ctrl+Alt+G');
|
},
|
||||||
|
'Ctrl+Alt+G'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_registerStatusBar(context) {
|
_registerStatusBar(context) {
|
||||||
context.statusBar.registerIndicator('word-goal', {
|
context.statusBar.registerIndicator('word-goal', {
|
||||||
text: '0/1000',
|
text: '0/1000',
|
||||||
tooltip: 'Daily word goal progress'
|
tooltip: 'Daily word goal progress',
|
||||||
});
|
});
|
||||||
context.statusBar.registerIndicator('sprint-timer', {
|
context.statusBar.registerIndicator('sprint-timer', {
|
||||||
text: '',
|
text: '',
|
||||||
tooltip: 'Writing sprint timer'
|
tooltip: 'Writing sprint timer',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,17 +15,34 @@
|
|||||||
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||||
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
||||||
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
||||||
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" },
|
{
|
||||||
|
"id": "restore-last-snapshot",
|
||||||
|
"label": "Studio: Restore Last Snapshot",
|
||||||
|
"shortcut": "Ctrl+Alt+Z"
|
||||||
|
},
|
||||||
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
||||||
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" },
|
{
|
||||||
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" }
|
"id": "compile-manuscript",
|
||||||
|
"label": "Studio: Compile Manuscript",
|
||||||
|
"shortcut": "Ctrl+Alt+E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "proofread-document",
|
||||||
|
"label": "Studio: Proofread Document",
|
||||||
|
"shortcut": "Ctrl+Alt+G"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
||||||
},
|
},
|
||||||
"settings": [
|
"settings": [
|
||||||
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||||
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
||||||
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" },
|
{
|
||||||
|
"key": "autoSnapshotInterval",
|
||||||
|
"type": "number",
|
||||||
|
"default": 0,
|
||||||
|
"label": "Auto-snapshot interval (min, 0=off)"
|
||||||
|
},
|
||||||
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ function renderGoalsPanel(container, { engines, settings }) {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ws-stat-row';
|
row.className = 'ws-stat-row';
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
label.textContent =
|
||||||
|
progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
||||||
const pct = document.createElement('span');
|
const pct = document.createElement('span');
|
||||||
pct.className = 'ws-pct';
|
pct.className = 'ws-pct';
|
||||||
pct.textContent = progress.pct + '%';
|
pct.textContent = progress.pct + '%';
|
||||||
@@ -81,7 +82,7 @@ function renderGoalsPanel(container, { engines, settings }) {
|
|||||||
|
|
||||||
const chart = document.createElement('div');
|
const chart = document.createElement('div');
|
||||||
chart.className = 'ws-chart';
|
chart.className = 'ws-chart';
|
||||||
const maxWords = Math.max(...last30.map(d => d.words), 1);
|
const maxWords = Math.max(...last30.map((d) => d.words), 1);
|
||||||
for (const day of last30) {
|
for (const day of last30) {
|
||||||
const barEl = document.createElement('div');
|
const barEl = document.createElement('div');
|
||||||
const height = Math.max(2, (day.words / maxWords) * 60);
|
const height = Math.max(2, (day.words / maxWords) * 60);
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ws-stat-row';
|
row.className = 'ws-stat-row';
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
label.textContent =
|
||||||
|
stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
||||||
const pct = document.createElement('span');
|
const pct = document.createElement('span');
|
||||||
pct.className = 'ws-pct';
|
pct.className = 'ws-pct';
|
||||||
pct.textContent = stats.pctComplete + '%';
|
pct.textContent = stats.pctComplete + '%';
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function renderProofreadPanel(container, { events, editor }) {
|
|||||||
if (result && result.issues) {
|
if (result && result.issues) {
|
||||||
renderIssues(issuesList, result.issues);
|
renderIssues(issuesList, result.issues);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,10 @@ function renderIssues(container, issues) {
|
|||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'ws-issue-actions';
|
actions.className = 'ws-issue-actions';
|
||||||
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) {
|
for (const [, label] of [
|
||||||
|
['accept', 'Accept'],
|
||||||
|
['dismiss', 'Dismiss'],
|
||||||
|
]) {
|
||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'ws-btn ws-btn-sm';
|
actionBtn.className = 'ws-btn ws-btn-sm';
|
||||||
actionBtn.textContent = label;
|
actionBtn.textContent = label;
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ function renderSnapshotsPanel(container, { engines, editor }) {
|
|||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'ws-snapshot-actions';
|
actions.className = 'ws-snapshot-actions';
|
||||||
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) {
|
for (const [action, text, cls] of [
|
||||||
|
['restore', 'Restore', ''],
|
||||||
|
['diff', 'Diff', ''],
|
||||||
|
['delete', 'Delete', 'ws-btn-danger'],
|
||||||
|
]) {
|
||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||||
actionBtn.textContent = text;
|
actionBtn.textContent = text;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ProjectManager {
|
|||||||
type: opts.type || 'manuscript',
|
type: opts.type || 'manuscript',
|
||||||
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
||||||
chapters: [],
|
chapters: [],
|
||||||
metadata: opts.metadata || {}
|
metadata: opts.metadata || {},
|
||||||
};
|
};
|
||||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||||
return project;
|
return project;
|
||||||
@@ -66,7 +66,7 @@ class ProjectManager {
|
|||||||
totalWords,
|
totalWords,
|
||||||
chapterCount: project.chapters.length,
|
chapterCount: project.chapters.length,
|
||||||
targetWords: target,
|
targetWords: target,
|
||||||
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0
|
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class SnapshotManager {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
content,
|
content,
|
||||||
wordCount: content.split(/\s+/).filter(Boolean).length,
|
wordCount: content.split(/\s+/).filter(Boolean).length,
|
||||||
label
|
label,
|
||||||
};
|
};
|
||||||
snaps.unshift(snap);
|
snaps.unshift(snap);
|
||||||
this._saveAll(snaps);
|
this._saveAll(snaps);
|
||||||
@@ -36,7 +36,7 @@ class SnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getById(id) {
|
getById(id) {
|
||||||
return this._getAll().find(s => s.id === id) || null;
|
return this._getAll().find((s) => s.id === id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
restore(id) {
|
restore(id) {
|
||||||
@@ -46,7 +46,7 @@ class SnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(id) {
|
delete(id) {
|
||||||
const snaps = this._getAll().filter(s => s.id !== id);
|
const snaps = this._getAll().filter((s) => s.id !== id);
|
||||||
this._saveAll(snaps);
|
this._saveAll(snaps);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +59,12 @@ class SnapshotManager {
|
|||||||
const newSet = new Set(newLines);
|
const newSet = new Set(newLines);
|
||||||
let added = 0;
|
let added = 0;
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const line of newLines) { if (!oldSet.has(line)) added++; }
|
for (const line of newLines) {
|
||||||
for (const line of oldLines) { if (!newSet.has(line)) removed++; }
|
if (!oldSet.has(line)) added++;
|
||||||
|
}
|
||||||
|
for (const line of oldLines) {
|
||||||
|
if (!newSet.has(line)) removed++;
|
||||||
|
}
|
||||||
return { added, removed };
|
return { added, removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ class SprintEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive() { return this._active; }
|
isActive() {
|
||||||
|
return this._active;
|
||||||
|
}
|
||||||
|
|
||||||
getRemaining() {
|
getRemaining() {
|
||||||
if (!this._active) return 0;
|
if (!this._active) return 0;
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ class PluginContext {
|
|||||||
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
||||||
*/
|
*/
|
||||||
constructor(deps) {
|
constructor(deps) {
|
||||||
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps;
|
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
|
||||||
|
deps;
|
||||||
|
|
||||||
this.sidebar = {
|
this.sidebar = {
|
||||||
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
|
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.commands = {
|
this.commands = {
|
||||||
@@ -28,41 +29,45 @@ class PluginContext {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.statusBar = {
|
this.statusBar = {
|
||||||
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
|
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.settings = {
|
this.settings = {
|
||||||
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
||||||
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
||||||
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb)
|
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.editor = {
|
this.editor = {
|
||||||
getContent: () => editor.getContent(),
|
getContent: () => editor.getContent(),
|
||||||
getSelection: () => editor.getSelection(),
|
getSelection: () => editor.getSelection(),
|
||||||
insertAtCursor: (text) => editor.insertAtCursor(text),
|
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||||
onContentChanged: (cb) => editor.onContentChanged(cb)
|
onContentChanged: (cb) => editor.onContentChanged(cb),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.events = {
|
this.events = {
|
||||||
on: (event, handler) => eventBus.on(event, handler),
|
on: (event, handler) => eventBus.on(event, handler),
|
||||||
off: (event, handler) => eventBus.off(event, handler),
|
off: (event, handler) => eventBus.off(event, handler),
|
||||||
emit: (event, payload) => eventBus.emit(event, payload),
|
emit: (event, payload) => eventBus.emit(event, payload),
|
||||||
hasHandler: (event) => eventBus.hasHandler(event)
|
hasHandler: (event) => eventBus.hasHandler(event),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ipc = {
|
this.ipc = {
|
||||||
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
||||||
on: (channel, handler) => ipc.on(channel, handler)
|
on: (channel, handler) => ipc.on(channel, handler),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.exports = {
|
this.exports = {
|
||||||
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
|
registerPreHook: (handler) => {
|
||||||
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
|
if (exportHooks) exportHooks.preHooks.push(handler);
|
||||||
|
},
|
||||||
|
registerPostHook: (handler) => {
|
||||||
|
if (exportHooks) exportHooks.postHooks.push(handler);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ class PluginLoader {
|
|||||||
const loaded = require(indexPath);
|
const loaded = require(indexPath);
|
||||||
PluginClass = loaded.Plugin || loaded.default || null;
|
PluginClass = loaded.Plugin || loaded.default || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
|
console.error(
|
||||||
|
`[PluginLoader] Failed to load index.js for "${manifest.id}":`,
|
||||||
|
err.message
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +49,7 @@ class PluginLoader {
|
|||||||
description: manifest.description,
|
description: manifest.description,
|
||||||
manifest,
|
manifest,
|
||||||
PluginClass,
|
PluginClass,
|
||||||
dir: pluginDir
|
dir: pluginDir,
|
||||||
});
|
});
|
||||||
this.loadedIds.add(manifest.id);
|
this.loadedIds.add(manifest.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class PluginRegistry {
|
|||||||
settings: this.deps.settings,
|
settings: this.deps.settings,
|
||||||
editor: this.deps.editor,
|
editor: this.deps.editor,
|
||||||
ipc: this.deps.ipc,
|
ipc: this.deps.ipc,
|
||||||
exportHooks: this.exportHooks
|
exportHooks: this.exportHooks,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class SettingsStore {
|
|||||||
this.backend.set(key, value);
|
this.backend.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChanged(key, callback) {
|
onChanged(_key, _callback) {
|
||||||
// Deferred: plugins read settings on init/activate for MVP.
|
// Deferred: plugins read settings on init/activate for MVP.
|
||||||
// Full change notification requires IPC watcher in main process.
|
// Full change notification requires IPC watcher in main process.
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-24
@@ -140,7 +140,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
|
|
||||||
// Plugin settings
|
// Plugin settings
|
||||||
'plugin-settings:get',
|
'plugin-settings:get',
|
||||||
'plugin-settings:set'
|
'plugin-settings:set',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -238,11 +238,6 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'toggle-command-palette',
|
'toggle-command-palette',
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel',
|
'toggle-bottom-panel',
|
||||||
|
|
||||||
// File dialog / directory listing
|
|
||||||
'list-directory',
|
|
||||||
'pick-folder',
|
|
||||||
'pick-file'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,25 +345,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
|
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
|
||||||
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
|
|
||||||
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
|
||||||
pickFile: () => ipcRenderer.invoke('pick-file')
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Theme Operations
|
// Theme Operations
|
||||||
theme: {
|
theme: {
|
||||||
get: () => ipcRenderer.send('get-theme')
|
get: () => ipcRenderer.send('get-theme'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Print Operations
|
// Print Operations
|
||||||
print: {
|
print: {
|
||||||
doPrint: (options) => ipcRenderer.send('do-print', options)
|
doPrint: (options) => ipcRenderer.send('do-print', options),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Export Operations
|
// Export Operations
|
||||||
export: {
|
export: {
|
||||||
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
|
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
|
||||||
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format })
|
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Batch Conversion
|
// Batch Conversion
|
||||||
@@ -376,7 +368,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
convert: (inputFolder, outputFolder, format, options) => {
|
convert: (inputFolder, outputFolder, format, options) => {
|
||||||
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
|
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
|
||||||
},
|
},
|
||||||
selectFolder: (type) => ipcRenderer.send('select-folder', type)
|
selectFolder: (type) => ipcRenderer.send('select-folder', type),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Universal Converter
|
// Universal Converter
|
||||||
@@ -385,8 +377,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
|
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
|
||||||
},
|
},
|
||||||
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
|
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
|
||||||
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder });
|
ipcRenderer.send('universal-convert-batch', {
|
||||||
}
|
tool,
|
||||||
|
fromFormat,
|
||||||
|
toFormat,
|
||||||
|
inputFolder,
|
||||||
|
outputFolder,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Header/Footer Operations
|
// Header/Footer Operations
|
||||||
@@ -394,22 +392,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
|
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
|
||||||
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
|
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
|
||||||
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
|
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
|
||||||
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
saveLogo: (position, filePath) =>
|
||||||
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position)
|
ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
||||||
|
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Settings
|
// Page Settings
|
||||||
page: {
|
page: {
|
||||||
getSettings: () => ipcRenderer.send('get-page-settings'),
|
getSettings: () => ipcRenderer.send('get-page-settings'),
|
||||||
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
|
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
|
||||||
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber)
|
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
|
||||||
},
|
},
|
||||||
|
|
||||||
// PDF Operations
|
// PDF Operations
|
||||||
pdf: {
|
pdf: {
|
||||||
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
|
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
|
||||||
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
|
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
|
||||||
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId)
|
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Image Converter Operations
|
// Image Converter Operations
|
||||||
@@ -418,7 +417,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
||||||
resize: (data) => ipcRenderer.send('image-resize', data),
|
resize: (data) => ipcRenderer.send('image-resize', data),
|
||||||
compress: (data) => ipcRenderer.send('image-compress', data),
|
compress: (data) => ipcRenderer.send('image-compress', data),
|
||||||
rotate: (data) => ipcRenderer.send('image-rotate', data)
|
rotate: (data) => ipcRenderer.send('image-rotate', data),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Audio Converter Operations
|
// Audio Converter Operations
|
||||||
@@ -427,7 +426,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
||||||
extract: (data) => ipcRenderer.send('audio-extract', data),
|
extract: (data) => ipcRenderer.send('audio-extract', data),
|
||||||
trim: (data) => ipcRenderer.send('audio-trim', data),
|
trim: (data) => ipcRenderer.send('audio-trim', data),
|
||||||
merge: (data) => ipcRenderer.send('audio-merge', data)
|
merge: (data) => ipcRenderer.send('audio-merge', data),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Video Converter Operations
|
// Video Converter Operations
|
||||||
@@ -437,16 +436,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
compress: (data) => ipcRenderer.send('video-compress', data),
|
compress: (data) => ipcRenderer.send('video-compress', data),
|
||||||
trim: (data) => ipcRenderer.send('video-trim', data),
|
trim: (data) => ipcRenderer.send('video-trim', data),
|
||||||
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
||||||
toGif: (data) => ipcRenderer.send('video-gif', data)
|
toGif: (data) => ipcRenderer.send('video-gif', data),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Generator Windows
|
// Generator Windows
|
||||||
generators: {
|
generators: {
|
||||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
||||||
openTable: () => ipcRenderer.send('open-table-generator')
|
openTable: () => ipcRenderer.send('open-table-generator'),
|
||||||
},
|
},
|
||||||
|
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version')
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log successful preload initialization
|
// Log successful preload initialization
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class PrintPreview {
|
|||||||
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
|
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
|
||||||
|
|
||||||
// Update preview on option changes
|
// Update preview on option changes
|
||||||
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
|
['print-paper-size', 'print-orientation', 'print-margins'].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
|
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,12 +67,12 @@ class PrintPreview {
|
|||||||
|
|
||||||
// Get dimensions for paper size
|
// Get dimensions for paper size
|
||||||
const sizes = {
|
const sizes = {
|
||||||
'A3': { width: '297mm', height: '420mm' },
|
A3: { width: '297mm', height: '420mm' },
|
||||||
'A4': { width: '210mm', height: '297mm' },
|
A4: { width: '210mm', height: '297mm' },
|
||||||
'A5': { width: '148mm', height: '210mm' },
|
A5: { width: '148mm', height: '210mm' },
|
||||||
'Letter': { width: '8.5in', height: '11in' },
|
Letter: { width: '8.5in', height: '11in' },
|
||||||
'Legal': { width: '8.5in', height: '14in' },
|
Legal: { width: '8.5in', height: '14in' },
|
||||||
'Tabloid': { width: '11in', height: '17in' },
|
Tabloid: { width: '11in', height: '17in' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const size = sizes[paperSize] || sizes['A4'];
|
const size = sizes[paperSize] || sizes['A4'];
|
||||||
|
|||||||
+1744
-954
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
|||||||
import { AppShell } from './components/layout/AppShell';
|
|
||||||
import { ModalLayer } from './components/modals/ModalLayer';
|
|
||||||
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
useWelcomeTrigger();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppShell />
|
|
||||||
<ModalLayer />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default App;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { EditorState, Compartment } from '@codemirror/state';
|
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
|
||||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
||||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
|
||||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { lightTheme, lightHighlight } from './themes/light';
|
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
bufferId: string;
|
|
||||||
initialContent: string;
|
|
||||||
onChange?: (content: string) => void;
|
|
||||||
onCursorChange?: (line: number, column: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
|
||||||
const themeCompartment = useRef(new Compartment());
|
|
||||||
const { resolvedTheme } = useTheme();
|
|
||||||
const updateContent = useEditorStore((s) => s.updateContent);
|
|
||||||
const setCursor = useEditorStore((s) => s.setCursor);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const state = EditorState.create({
|
|
||||||
doc: initialContent,
|
|
||||||
extensions: [
|
|
||||||
lineNumbers(),
|
|
||||||
highlightActiveLine(),
|
|
||||||
highlightSelectionMatches(),
|
|
||||||
history(),
|
|
||||||
drawSelection(),
|
|
||||||
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
|
||||||
autocompletion(),
|
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
|
|
||||||
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
|
|
||||||
EditorView.lineWrapping,
|
|
||||||
EditorView.updateListener.of((v) => {
|
|
||||||
if (v.docChanged) {
|
|
||||||
const content = v.state.doc.toString();
|
|
||||||
updateContent(bufferId, content);
|
|
||||||
onChange?.(content);
|
|
||||||
}
|
|
||||||
if (v.selectionSet || v.docChanged) {
|
|
||||||
const head = v.state.selection.main.head;
|
|
||||||
const line = v.state.doc.lineAt(head);
|
|
||||||
const lineNo = line.number;
|
|
||||||
const col = head - line.from + 1;
|
|
||||||
setCursor(bufferId, lineNo, col);
|
|
||||||
onCursorChange?.(lineNo, col);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const view = new EditorView({ state, parent: ref.current });
|
|
||||||
viewRef.current = view;
|
|
||||||
return () => {
|
|
||||||
view.destroy();
|
|
||||||
viewRef.current = null;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [bufferId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
if (!view) return;
|
|
||||||
view.dispatch({
|
|
||||||
effects: themeCompartment.current.reconfigure(
|
|
||||||
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [resolvedTheme]);
|
|
||||||
|
|
||||||
return <div ref={ref} className="h-full overflow-hidden" />;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
|
||||||
import { usePreviewStore } from '@/stores/preview-store';
|
|
||||||
|
|
||||||
export function EditorPane() {
|
|
||||||
const { buffers, activeId } = useEditorStore();
|
|
||||||
const buf = activeId ? buffers.get(activeId) : null;
|
|
||||||
const setPreviewSource = usePreviewStore((s) => s.setSource);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (buf) setPreviewSource(buf.content);
|
|
||||||
}, [buf?.id, buf?.content, buf, setPreviewSource]);
|
|
||||||
|
|
||||||
if (!buf) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
|
|
||||||
<p>No file open. Use File → Open to start.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<CodeMirrorEditor
|
|
||||||
key={buf.id}
|
|
||||||
bufferId={buf.id}
|
|
||||||
initialContent={buf.content}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { EditorView } from '@codemirror/view';
|
|
||||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
||||||
import { tags as t } from '@lezer/highlight';
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
background: '#ffffff',
|
|
||||||
foreground: '#0d0b09',
|
|
||||||
cursor: '#e5461f',
|
|
||||||
selection: 'rgba(229, 70, 31, 0.15)',
|
|
||||||
gutterBackground: '#fafbfc',
|
|
||||||
gutterForeground: '#7a7878',
|
|
||||||
lineHighlight: 'rgba(0, 0, 0, 0.04)',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const lightTheme = EditorView.theme(
|
|
||||||
{
|
|
||||||
'&': {
|
|
||||||
backgroundColor: colors.background,
|
|
||||||
color: colors.foreground,
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
'.cm-content': {
|
|
||||||
caretColor: colors.cursor,
|
|
||||||
fontFamily: 'JetBrains Mono, Fira Code, monospace',
|
|
||||||
fontSize: '13.5px',
|
|
||||||
},
|
|
||||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor },
|
|
||||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
|
||||||
backgroundColor: colors.selection,
|
|
||||||
},
|
|
||||||
'.cm-gutters': {
|
|
||||||
backgroundColor: colors.gutterBackground,
|
|
||||||
color: colors.gutterForeground,
|
|
||||||
border: 'none',
|
|
||||||
},
|
|
||||||
'.cm-activeLine': { backgroundColor: colors.lineHighlight },
|
|
||||||
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' },
|
|
||||||
},
|
|
||||||
{ dark: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const highlightStyle = HighlightStyle.define([
|
|
||||||
{ tag: t.heading1, color: '#0d0b09', fontWeight: '700' },
|
|
||||||
{ tag: t.heading2, color: '#0d0b09', fontWeight: '700' },
|
|
||||||
{ tag: t.heading3, color: '#464646', fontWeight: '600' },
|
|
||||||
{ tag: t.link, color: '#e5461f', textDecoration: 'underline' },
|
|
||||||
{ tag: t.url, color: '#e5461f' },
|
|
||||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
|
||||||
{ tag: t.strong, fontWeight: '700' },
|
|
||||||
{ tag: t.monospace, color: '#c93a18' },
|
|
||||||
{ tag: t.list, color: '#0ea5e9' },
|
|
||||||
{ tag: t.quote, color: '#7a7878', fontStyle: 'italic' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const lightHighlight = syntaxHighlighting(highlightStyle);
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { PanelLeft, PanelRight, Keyboard, Settings, Info } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useCommandStore } from '@/stores/command-store';
|
|
||||||
|
|
||||||
export function AppHeader() {
|
|
||||||
const { sidebarVisible, previewVisible } = useAppStore();
|
|
||||||
const dispatch = useCommandStore((s) => s.dispatch);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
|
|
||||||
aria-label="MarkdownConverter logo"
|
|
||||||
/>
|
|
||||||
<h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Toggle sidebar"
|
|
||||||
aria-pressed={sidebarVisible}
|
|
||||||
data-testid="header-toggle-sidebar"
|
|
||||||
onClick={() => dispatch('view.toggleSidebar')}
|
|
||||||
>
|
|
||||||
<PanelLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Toggle preview"
|
|
||||||
aria-pressed={previewVisible}
|
|
||||||
data-testid="header-toggle-preview"
|
|
||||||
onClick={() => dispatch('view.togglePreview')}
|
|
||||||
>
|
|
||||||
<PanelRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Keyboard shortcuts"
|
|
||||||
data-testid="header-shortcuts"
|
|
||||||
onClick={() => dispatch('shortcuts.show')}
|
|
||||||
>
|
|
||||||
<Keyboard className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Settings"
|
|
||||||
onClick={() => dispatch('settings.open')}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="About"
|
|
||||||
onClick={() => dispatch('help.about')}
|
|
||||||
>
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { AppHeader } from './AppHeader';
|
|
||||||
import { TabBar } from './TabBar';
|
|
||||||
import { Toolbar } from './Toolbar';
|
|
||||||
import { Breadcrumb } from './Breadcrumb';
|
|
||||||
import { StatusBar } from './StatusBar';
|
|
||||||
import { EditorPane } from '@/components/editor/EditorPane';
|
|
||||||
import { PreviewPane } from '@/components/preview/PreviewPane';
|
|
||||||
import { Sidebar } from '@/components/sidebar/Sidebar';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
|
||||||
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
|
||||||
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
|
||||||
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
|
||||||
|
|
||||||
export function AppShell() {
|
|
||||||
useFileShortcuts();
|
|
||||||
useRestoreLastFolder();
|
|
||||||
useRegisterMenuCommands();
|
|
||||||
useBridgeNativeMenu();
|
|
||||||
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
|
||||||
<AppHeader />
|
|
||||||
<TabBar />
|
|
||||||
<Toolbar />
|
|
||||||
<Breadcrumb />
|
|
||||||
<main className="flex-1 overflow-hidden">
|
|
||||||
<ResizablePanelGroup
|
|
||||||
direction="horizontal"
|
|
||||||
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
|
|
||||||
>
|
|
||||||
{sidebarVisible && (
|
|
||||||
<>
|
|
||||||
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
|
||||||
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
|
||||||
<Sidebar />
|
|
||||||
</aside>
|
|
||||||
</ResizablePanel>
|
|
||||||
<ResizableHandle />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
|
||||||
<EditorPane />
|
|
||||||
</ResizablePanel>
|
|
||||||
{previewVisible && (
|
|
||||||
<>
|
|
||||||
<ResizableHandle />
|
|
||||||
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
|
||||||
<PreviewPane />
|
|
||||||
</ResizablePanel>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</main>
|
|
||||||
<StatusBar />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export function Breadcrumb() {
|
|
||||||
return (
|
|
||||||
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
|
||||||
<span>No file selected</span>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useEditorStore } from '@/stores/editor-store';
|
|
||||||
|
|
||||||
function countWords(text: string): number {
|
|
||||||
return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBar() {
|
|
||||||
const { buffers, activeId } = useEditorStore();
|
|
||||||
const buf = activeId ? buffers.get(activeId) : null;
|
|
||||||
const wordCount = buf ? countWords(buf.content) : 0;
|
|
||||||
const cursor = buf?.cursor ?? { line: 1, column: 1 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span>{wordCount} words</span>
|
|
||||||
<span>UTF-8</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
|
||||||
<span>Markdown</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { X } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
closestCenter,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
useSortable,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { useFileStore, type OpenTab } from '@/stores/file-store';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
function SortableTab({
|
|
||||||
tab,
|
|
||||||
isActive,
|
|
||||||
onSelect,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
tab: OpenTab;
|
|
||||||
isActive: boolean;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
onClose: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
||||||
id: tab.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
aria-current={isActive ? 'page' : undefined}
|
|
||||||
data-testid={`tab-${tab.id}`}
|
|
||||||
className={cn(
|
|
||||||
'group flex h-full cursor-pointer select-none items-center gap-1 border-r border-border px-3 text-xs transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() => onSelect(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.dirty && (
|
|
||||||
<span
|
|
||||||
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
|
|
||||||
aria-label="Unsaved changes"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="max-w-[120px] truncate">{tab.title}</span>
|
|
||||||
<button
|
|
||||||
aria-label={`Close ${tab.title}`}
|
|
||||||
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose(tab.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabBar() {
|
|
||||||
const openTabs = useFileStore((s) => s.openTabs);
|
|
||||||
const activeTabId = useFileStore((s) => s.activeTabId);
|
|
||||||
const setActiveTab = useFileStore((s) => s.setActiveTab);
|
|
||||||
const closeTab = useFileStore((s) => s.closeTab);
|
|
||||||
const reorderTabs = useFileStore((s) => s.reorderTabs);
|
|
||||||
|
|
||||||
// Require a small drag distance before activating — so a click on the tab
|
|
||||||
// body still triggers `onClick` (dnd-kit's PointerSensor default is 0).
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
|
||||||
|
|
||||||
if (openTabs.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
|
||||||
<span>No files open</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd(e: DragEndEvent) {
|
|
||||||
const { active, over } = e;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
const fromIndex = openTabs.findIndex((t) => t.id === active.id);
|
|
||||||
const toIndex = openTabs.findIndex((t) => t.id === over.id);
|
|
||||||
if (fromIndex === -1 || toIndex === -1) return;
|
|
||||||
reorderTabs(fromIndex, toIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
|
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
||||||
<SortableContext items={openTabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
|
|
||||||
<div className="flex h-full items-center px-1">
|
|
||||||
{openTabs.map((tab) => (
|
|
||||||
<SortableTab
|
|
||||||
key={tab.id}
|
|
||||||
tab={tab}
|
|
||||||
isActive={tab.id === activeTabId}
|
|
||||||
onSelect={setActiveTab}
|
|
||||||
onClose={closeTab}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon, PanelLeft, PanelRight, Save, FolderOpen, FileText } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useCommandStore } from '@/stores/command-store';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
|
|
||||||
export function Toolbar() {
|
|
||||||
const dispatch = useCommandStore((s) => s.dispatch);
|
|
||||||
const { sidebarVisible, previewVisible } = useAppStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="Main toolbar"
|
|
||||||
className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Open file"
|
|
||||||
data-testid="toolbar-open-file"
|
|
||||||
onClick={() => dispatch('file.open')}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Open folder"
|
|
||||||
data-testid="toolbar-open-folder"
|
|
||||||
onClick={() => dispatch('file.openFolder')}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Save"
|
|
||||||
data-testid="toolbar-save"
|
|
||||||
onClick={() => dispatch('file.save')}
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Toggle sidebar"
|
|
||||||
aria-pressed={sidebarVisible}
|
|
||||||
data-testid="toolbar-toggle-sidebar"
|
|
||||||
onClick={() => dispatch('view.toggleSidebar')}
|
|
||||||
>
|
|
||||||
<PanelLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Toggle preview"
|
|
||||||
aria-pressed={previewVisible}
|
|
||||||
data-testid="toolbar-toggle-preview"
|
|
||||||
onClick={() => dispatch('view.togglePreview')}
|
|
||||||
>
|
|
||||||
<PanelRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Bold" disabled><Bold className="h-4 w-4" /></Button>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Italic" disabled><Italic className="h-4 w-4" /></Button>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Unordered list" disabled><List className="h-4 w-4" /></Button>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Ordered list" disabled><ListOrdered className="h-4 w-4" /></Button>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Code" disabled><Code className="h-4 w-4" /></Button>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Link" disabled><LinkIcon className="h-4 w-4" /></Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
|
|
||||||
export function AboutDialog() {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const isOpen = useAppStore((s) => s.modal.kind) === 'about';
|
|
||||||
const [version, setVersion] = useState<string>('…');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ipc.app.getVersion().then((r) => {
|
|
||||||
if (r.ok && typeof r.data === 'string') setVersion(r.data);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}>
|
|
||||||
<DialogContent aria-describedby="about-desc">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>About MarkdownConverter</DialogTitle>
|
|
||||||
<DialogDescription id="about-desc">
|
|
||||||
Professional Markdown editor and universal file converter.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-2 text-sm text-muted-foreground">
|
|
||||||
<p>Version: {version}</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href="https://github.com/amitwh/markdown-converter"
|
|
||||||
className="text-brand hover:underline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
ipc.app.openExternal('https://github.com/amitwh/markdown-converter');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
GitHub repository
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs">© ConcreteInfo. Licensed under MIT.</p>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button onClick={closeModal}>Close</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
|
|
||||||
export function AboutSettings() {
|
|
||||||
const [version, setVersion] = useState('…');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
ipc.app.getVersion().then((r) => {
|
|
||||||
if (r.ok && typeof r.data === 'string') setVersion(r.data);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<h3 className="text-base font-semibold">MarkdownConverter</h3>
|
|
||||||
<p className="text-muted-foreground">Version {version}</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href="https://github.com/amitwh/markdown-converter"
|
|
||||||
className="text-brand hover:underline"
|
|
||||||
onClick={(e) => { e.preventDefault(); ipc.app.openExternal('https://github.com/amitwh/markdown-converter'); }}
|
|
||||||
>
|
|
||||||
GitHub repository
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href="https://concreteinfo.co.in"
|
|
||||||
className="text-brand hover:underline"
|
|
||||||
onClick={(e) => { e.preventDefault(); ipc.app.openExternal('https://concreteinfo.co.in'); }}
|
|
||||||
>
|
|
||||||
ConcreteInfo
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">© ConcreteInfo. Licensed under MIT.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useAppStore, type ConfirmProps } from '@/stores/app-store';
|
|
||||||
|
|
||||||
export function ConfirmDialog(props: ConfirmProps) {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const { title, body, confirmLabel = 'Confirm', cancelLabel = 'Cancel', destructive, onConfirm, onCancel } = props;
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
await onConfirm();
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
const handleCancel = () => {
|
|
||||||
onCancel?.();
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && handleCancel()}>
|
|
||||||
<DialogContent aria-describedby="confirm-body">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogDescription id="confirm-body">{body}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={handleCancel}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button variant={destructive ? 'destructive' : 'default'} onClick={handleConfirm}>
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
|
|
||||||
export function EditorSettings() {
|
|
||||||
const { fontSize, tabSize, lineNumbers, wordWrap, minimap, setSetting } = useSettingsStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 text-sm">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="editor-font-size">Font size: {fontSize}px</Label>
|
|
||||||
<Slider id="editor-font-size" min={10} max={24} step={1} value={[fontSize]} onValueChange={([v]) => setSetting('fontSize', v)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="editor-tab-size">Tab size</Label>
|
|
||||||
<Select value={String(tabSize)} onValueChange={(v) => setSetting('tabSize', Number(v) as 2 | 4 | 8)}>
|
|
||||||
<SelectTrigger id="editor-tab-size" aria-label="Tab size"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="2">2 spaces</SelectItem>
|
|
||||||
<SelectItem value="4">4 spaces</SelectItem>
|
|
||||||
<SelectItem value="8">8 spaces</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>Line numbers</span>
|
|
||||||
<Switch checked={lineNumbers} onCheckedChange={(c) => setSetting('lineNumbers', c)} aria-label="Line numbers" />
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>Word wrap</span>
|
|
||||||
<Switch checked={wordWrap} onCheckedChange={(c) => setSetting('wordWrap', c)} aria-label="Word wrap" />
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>Minimap</span>
|
|
||||||
<Switch checked={minimap} onCheckedChange={(c) => setSetting('minimap', c)} aria-label="Minimap" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
|
||||||
|
|
||||||
const extFor = (format: 'pdf' | 'docx' | 'html' | 'png') =>
|
|
||||||
format === 'pdf' ? '.pdf' : format === 'docx' ? '.docx' : format === 'png' ? '.png' : '.html';
|
|
||||||
|
|
||||||
export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const [format, setFormat] = useState<'pdf' | 'docx' | 'html' | 'png'>('pdf');
|
|
||||||
const [concurrency, setConcurrency] = useState(4);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
const items = sourcePaths.map((p) => ({
|
|
||||||
inputPath: p,
|
|
||||||
outputPath: p.replace(/\.md$/, extFor(format)),
|
|
||||||
}));
|
|
||||||
const result = await ipc.export.batch(items, { format, concurrency });
|
|
||||||
if (!result.ok) {
|
|
||||||
setError(result.error.message);
|
|
||||||
setSubmitting(false);
|
|
||||||
} else {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
|
||||||
<DialogContent aria-describedby="batch-desc">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Batch export</DialogTitle>
|
|
||||||
<DialogDescription id="batch-desc">{sourcePaths.length} files</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="batch-format">Format</Label>
|
|
||||||
<Select value={format} onValueChange={(v) => setFormat(v as any)}>
|
|
||||||
<SelectTrigger id="batch-format" aria-label="Format"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="docx">DOCX</SelectItem>
|
|
||||||
<SelectItem value="html">HTML</SelectItem>
|
|
||||||
<SelectItem value="png">PNG</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="batch-concurrency">Concurrency</Label>
|
|
||||||
<Select value={String(concurrency)} onValueChange={(v) => setConcurrency(Number(v))}>
|
|
||||||
<SelectTrigger id="batch-concurrency" aria-label="Concurrency"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[1, 2, 4, 8, 16].map((n) => (
|
|
||||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-40 overflow-auto rounded border border-border bg-card/20 p-2 text-xs">
|
|
||||||
{sourcePaths.map((p) => <div key={p} className="truncate">{p}</div>)}
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { DialogFooter } from '@/components/ui/dialog';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onCancel: () => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
submitting: boolean;
|
|
||||||
submitLabel: string;
|
|
||||||
submitDisabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportDialogFooter({ onCancel, onSubmit, submitting, submitLabel, submitDisabled }: Props) {
|
|
||||||
return (
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onSubmit} disabled={submitting || submitDisabled}>
|
|
||||||
{submitting ? 'Exporting…' : submitLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
|
||||||
|
|
||||||
export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const { docxTemplate, renderTablesAsAscii } = useSettingsStore();
|
|
||||||
const source = useExportSource();
|
|
||||||
const [template, setTemplate] = useState<'standard' | 'minimal' | 'modern'>(docxTemplate);
|
|
||||||
const [ascii, setAscii] = useState(renderTablesAsAscii);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!source) { setError('No file open.'); return; }
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await ipc.export.docx({
|
|
||||||
inputPath: source.path,
|
|
||||||
outputPath: source.path.replace(/\.md$/, '.docx'),
|
|
||||||
template,
|
|
||||||
renderTablesAsAscii: ascii,
|
|
||||||
} as any);
|
|
||||||
if (!result.ok) {
|
|
||||||
setError(result.error.message);
|
|
||||||
setSubmitting(false);
|
|
||||||
} else {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
|
||||||
<DialogContent aria-describedby="docx-desc">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Export to DOCX</DialogTitle>
|
|
||||||
<DialogDescription id="docx-desc">{sourcePath}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="docx-template">Template</Label>
|
|
||||||
<Select value={template} onValueChange={(v) => setTemplate(v as any)}>
|
|
||||||
<SelectTrigger id="docx-template" aria-label="Template"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
<SelectItem value="minimal">Minimal</SelectItem>
|
|
||||||
<SelectItem value="modern">Modern</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Bundled with the app. Standard is the default; Modern adds a colored cover page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" />
|
|
||||||
Render tables as ASCII
|
|
||||||
</label>
|
|
||||||
{error && (
|
|
||||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
|
||||||
|
|
||||||
export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const { htmlHighlightStyle, renderTablesAsAscii } = useSettingsStore();
|
|
||||||
const source = useExportSource();
|
|
||||||
const [standalone, setStandalone] = useState(true);
|
|
||||||
const [highlight, setHighlight] = useState(htmlHighlightStyle);
|
|
||||||
const [ascii, setAscii] = useState(renderTablesAsAscii);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!source) { setError('No file open.'); return; }
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await ipc.export.html({
|
|
||||||
inputPath: source.path,
|
|
||||||
outputPath: source.path.replace(/\.md$/, '.html'),
|
|
||||||
standalone,
|
|
||||||
highlightStyle: highlight,
|
|
||||||
renderTablesAsAscii: ascii,
|
|
||||||
} as any);
|
|
||||||
if (!result.ok) {
|
|
||||||
setError(result.error.message);
|
|
||||||
setSubmitting(false);
|
|
||||||
} else {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
|
||||||
<DialogContent aria-describedby="html-desc">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Export to HTML</DialogTitle>
|
|
||||||
<DialogDescription id="html-desc">{sourcePath}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={standalone} onCheckedChange={(c) => setStandalone(!!c)} aria-label="Standalone" />
|
|
||||||
Standalone document (with inline CSS)
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="html-highlight">Syntax highlight style</Label>
|
|
||||||
<Select value={highlight} onValueChange={(v) => setHighlight(v as any)}>
|
|
||||||
<SelectTrigger id="html-highlight" aria-label="Highlight style"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="github">GitHub</SelectItem>
|
|
||||||
<SelectItem value="monokai">Monokai</SelectItem>
|
|
||||||
<SelectItem value="nord">Nord</SelectItem>
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" />
|
|
||||||
Render tables as ASCII
|
|
||||||
</label>
|
|
||||||
{error && (
|
|
||||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
import { useExportSource } from '@/hooks/use-export-source';
|
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
|
||||||
|
|
||||||
const MARGIN_MAP = {
|
|
||||||
normal: { top: 25, right: 25, bottom: 25, left: 25 },
|
|
||||||
narrow: { top: 15, right: 15, bottom: 15, left: 15 },
|
|
||||||
wide: { top: 35, right: 35, bottom: 35, left: 35 },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const { fontSize, pdfFormat, pdfMargins, pdfEmbedFonts, renderTablesAsAscii } = useSettingsStore();
|
|
||||||
const [format, setFormat] = useState<'letter' | 'a4' | 'legal'>(pdfFormat);
|
|
||||||
const [margins, setMargins] = useState<'normal' | 'narrow' | 'wide'>(pdfMargins);
|
|
||||||
const [embed, setEmbed] = useState(pdfEmbedFonts);
|
|
||||||
const [ascii, setAscii] = useState(renderTablesAsAscii);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const source = useExportSource();
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!source) {
|
|
||||||
setError('No file open.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await ipc.export.pdf({
|
|
||||||
inputPath: source.path,
|
|
||||||
outputPath: source.path.replace(/\.md$/, '.pdf'),
|
|
||||||
format,
|
|
||||||
margins: MARGIN_MAP[margins],
|
|
||||||
embedFonts: embed,
|
|
||||||
renderTablesAsAscii: ascii,
|
|
||||||
fontSize,
|
|
||||||
} as any);
|
|
||||||
if (!result.data?.ok) {
|
|
||||||
setError(result.data?.error?.message ?? 'Export failed');
|
|
||||||
setSubmitting(false);
|
|
||||||
} else {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
|
||||||
<DialogContent aria-describedby="pdf-desc">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Export to PDF</DialogTitle>
|
|
||||||
<DialogDescription id="pdf-desc">{sourcePath}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="pdf-format">Format</Label>
|
|
||||||
<Select value={format} onValueChange={(v) => setFormat(v as typeof format)}>
|
|
||||||
<SelectTrigger id="pdf-format" aria-label="Format"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="letter">Letter</SelectItem>
|
|
||||||
<SelectItem value="a4">A4</SelectItem>
|
|
||||||
<SelectItem value="legal">Legal</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="pdf-margins">Margins</Label>
|
|
||||||
<Select value={margins} onValueChange={(v) => setMargins(v as typeof margins)}>
|
|
||||||
<SelectTrigger id="pdf-margins" aria-label="Margins"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="narrow">Narrow</SelectItem>
|
|
||||||
<SelectItem value="normal">Normal</SelectItem>
|
|
||||||
<SelectItem value="wide">Wide</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={embed} onCheckedChange={(c) => setEmbed(!!c)} aria-label="Embed fonts" />
|
|
||||||
Embed fonts
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" />
|
|
||||||
Render tables as ASCII
|
|
||||||
</label>
|
|
||||||
{error && (
|
|
||||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
|
|
||||||
export function ExportSettings() {
|
|
||||||
const { pdfFormat, pdfMargins, pdfEmbedFonts, docxTemplate, htmlHighlightStyle, renderTablesAsAscii, setSetting } = useSettingsStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 text-sm">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="export-pdf-format">Default PDF format</Label>
|
|
||||||
<Select value={pdfFormat} onValueChange={(v) => setSetting('pdfFormat', v as any)}>
|
|
||||||
<SelectTrigger id="export-pdf-format" aria-label="Default PDF format"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="letter">Letter</SelectItem>
|
|
||||||
<SelectItem value="a4">A4</SelectItem>
|
|
||||||
<SelectItem value="legal">Legal</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="export-pdf-margins">Default PDF margins</Label>
|
|
||||||
<Select value={pdfMargins} onValueChange={(v) => setSetting('pdfMargins', v as any)}>
|
|
||||||
<SelectTrigger id="export-pdf-margins" aria-label="Default PDF margins"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="narrow">Narrow</SelectItem>
|
|
||||||
<SelectItem value="normal">Normal</SelectItem>
|
|
||||||
<SelectItem value="wide">Wide</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>Embed fonts in PDFs</span>
|
|
||||||
<Switch checked={pdfEmbedFonts} onCheckedChange={(c) => setSetting('pdfEmbedFonts', c)} aria-label="Embed fonts" />
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="export-docx-template">Default DOCX template</Label>
|
|
||||||
<Select value={docxTemplate} onValueChange={(v) => setSetting('docxTemplate', v as any)}>
|
|
||||||
<SelectTrigger id="export-docx-template" aria-label="Default DOCX template"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
<SelectItem value="minimal">Minimal</SelectItem>
|
|
||||||
<SelectItem value="modern">Modern</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="export-html-highlight">Default HTML highlight</Label>
|
|
||||||
<Select value={htmlHighlightStyle} onValueChange={(v) => setSetting('htmlHighlightStyle', v as any)}>
|
|
||||||
<SelectTrigger id="export-html-highlight" aria-label="Default HTML highlight"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="github">GitHub</SelectItem>
|
|
||||||
<SelectItem value="monokai">Monokai</SelectItem>
|
|
||||||
<SelectItem value="nord">Nord</SelectItem>
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center justify-between">
|
|
||||||
<span>Render tables as ASCII by default</span>
|
|
||||||
<Switch checked={renderTablesAsAscii} onCheckedChange={(c) => setSetting('renderTablesAsAscii', c)} aria-label="ASCII tables by default" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { AboutDialog } from './AboutDialog';
|
|
||||||
import { ConfirmDialog } from './ConfirmDialog';
|
|
||||||
import { ExportBatchDialog } from './ExportBatchDialog';
|
|
||||||
import { ExportDocxDialog } from './ExportDocxDialog';
|
|
||||||
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
|
||||||
import { ExportPdfDialog } from './ExportPdfDialog';
|
|
||||||
import { SettingsSheet } from './SettingsSheet';
|
|
||||||
import { WelcomeDialog } from './WelcomeDialog';
|
|
||||||
|
|
||||||
export function ModalLayer() {
|
|
||||||
const modal = useAppStore((s) => s.modal);
|
|
||||||
switch (modal.kind) {
|
|
||||||
case null:
|
|
||||||
return null;
|
|
||||||
case 'export-pdf':
|
|
||||||
return <ExportPdfDialog sourcePath={modal.props.sourcePath} />;
|
|
||||||
case 'export-docx':
|
|
||||||
return <ExportDocxDialog sourcePath={modal.props.sourcePath} />;
|
|
||||||
case 'export-html':
|
|
||||||
return <ExportHtmlDialog sourcePath={modal.props.sourcePath} />;
|
|
||||||
case 'export-batch':
|
|
||||||
return <ExportBatchDialog sourcePaths={modal.props.sourcePaths} />;
|
|
||||||
case 'settings':
|
|
||||||
return <SettingsSheet />;
|
|
||||||
case 'about':
|
|
||||||
return <AboutDialog />;
|
|
||||||
case 'welcome':
|
|
||||||
return <WelcomeDialog />;
|
|
||||||
case 'confirm':
|
|
||||||
return <ConfirmDialog {...modal.props} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Sparkles } from 'lucide-react';
|
|
||||||
|
|
||||||
export function PluginsSettings() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center text-muted-foreground">
|
|
||||||
<Sparkles className="h-8 w-8 opacity-50" />
|
|
||||||
<h3 className="text-base font-semibold text-foreground">Coming soon</h3>
|
|
||||||
<p className="max-w-sm text-sm">
|
|
||||||
The plugin system is on the roadmap. You'll be able to extend MarkdownConverter with custom
|
|
||||||
commands, themes, and export formats.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
import { EditorSettings } from './EditorSettings';
|
|
||||||
import { ThemeSettings } from './ThemeSettings';
|
|
||||||
import { ExportSettings } from './ExportSettings';
|
|
||||||
import { PluginsSettings } from './PluginsSettings';
|
|
||||||
import { AboutSettings } from './AboutSettings';
|
|
||||||
|
|
||||||
export function SettingsSheet() {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sheet open onOpenChange={(o) => !o && closeModal()}>
|
|
||||||
<SheetContent aria-describedby="settings-desc" side="right" className="w-full sm:max-w-[480px]">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Settings</SheetTitle>
|
|
||||||
<SheetDescription id="settings-desc">Editor, theme, and export preferences</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<Tabs defaultValue="editor" className="mt-4">
|
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
|
||||||
<TabsTrigger value="editor">Editor</TabsTrigger>
|
|
||||||
<TabsTrigger value="theme">Theme</TabsTrigger>
|
|
||||||
<TabsTrigger value="export">Export</TabsTrigger>
|
|
||||||
<TabsTrigger value="plugins">Plugins</TabsTrigger>
|
|
||||||
<TabsTrigger value="about">About</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<div className="mt-4 max-h-[70vh] overflow-y-auto pr-2">
|
|
||||||
<TabsContent value="editor"><EditorSettings /></TabsContent>
|
|
||||||
<TabsContent value="theme"><ThemeSettings /></TabsContent>
|
|
||||||
<TabsContent value="export"><ExportSettings /></TabsContent>
|
|
||||||
<TabsContent value="plugins"><PluginsSettings /></TabsContent>
|
|
||||||
<TabsContent value="about"><AboutSettings /></TabsContent>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
<div className="mt-4 flex justify-end border-t border-border pt-4">
|
|
||||||
<Button variant="ghost" onClick={resetToDefaults}>Reset to defaults</Button>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
|
|
||||||
export function ThemeSettings() {
|
|
||||||
const { theme, accentColor, fontFamily, setSetting } = useSettingsStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 text-sm">
|
|
||||||
<div>
|
|
||||||
<Label>Mode</Label>
|
|
||||||
<RadioGroup value={theme} onValueChange={(v) => setSetting('theme', v as 'light' | 'dark' | 'auto')}>
|
|
||||||
<div className="flex items-center gap-2"><RadioGroupItem value="light" id="theme-light" /><Label htmlFor="theme-light">Light</Label></div>
|
|
||||||
<div className="flex items-center gap-2"><RadioGroupItem value="dark" id="theme-dark" /><Label htmlFor="theme-dark">Dark</Label></div>
|
|
||||||
<div className="flex items-center gap-2"><RadioGroupItem value="auto" id="theme-auto" /><Label htmlFor="theme-auto">Auto (system)</Label></div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="theme-accent">Accent color</Label>
|
|
||||||
<Select value={accentColor} onValueChange={(v) => setSetting('accentColor', v as any)}>
|
|
||||||
<SelectTrigger id="theme-accent" aria-label="Accent color"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="brand">Brand (orange)</SelectItem>
|
|
||||||
<SelectItem value="blue">Blue</SelectItem>
|
|
||||||
<SelectItem value="green">Green</SelectItem>
|
|
||||||
<SelectItem value="purple">Purple</SelectItem>
|
|
||||||
<SelectItem value="orange">Orange</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="theme-font-family">Editor font</Label>
|
|
||||||
<Select value={fontFamily} onValueChange={(v) => setSetting('fontFamily', v as any)}>
|
|
||||||
<SelectTrigger id="theme-font-family" aria-label="Editor font"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="system">System (Plus Jakarta Sans)</SelectItem>
|
|
||||||
<SelectItem value="jetbrains">JetBrains Mono</SelectItem>
|
|
||||||
<SelectItem value="fira">Fira Code</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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 { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
|
|
||||||
export function WelcomeDialog() {
|
|
||||||
const closeModal = useAppStore((s) => s.closeModal);
|
|
||||||
const setSetting = useSettingsStore((s) => s.setSetting);
|
|
||||||
const [dontShow, setDontShow] = useState(false);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (dontShow) setSetting('welcomeDismissed', true);
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && handleClose()}>
|
|
||||||
<DialogContent aria-describedby="welcome-desc" className="max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Welcome to MarkdownConverter</DialogTitle>
|
|
||||||
<DialogDescription id="welcome-desc">
|
|
||||||
A polished editor for Markdown, with PDF, DOCX, and HTML export.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div className="rounded-md border border-border bg-card/30 p-3">
|
|
||||||
<h3 className="font-semibold">1. Open a folder</h3>
|
|
||||||
<p className="text-muted-foreground">File → Open Folder, or ⌘O. Your tree appears on the left.</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-border bg-card/30 p-3">
|
|
||||||
<h3 className="font-semibold">2. Edit & preview</h3>
|
|
||||||
<p className="text-muted-foreground">Type on the left, see the rendered preview on the right. Toggle with ⌘\.</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-border bg-card/30 p-3">
|
|
||||||
<h3 className="font-semibold">3. Export anywhere</h3>
|
|
||||||
<p className="text-muted-foreground">File → Export to PDF / DOCX / HTML, or batch convert a folder.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Checkbox checked={dontShow} onCheckedChange={(c) => setDontShow(!!c)} aria-label="Don't show again" />
|
|
||||||
Don't show again
|
|
||||||
</label>
|
|
||||||
<Button onClick={handleClose}>Get started</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { renderMarkdown } from '@/lib/markdown';
|
|
||||||
import { MermaidLazy } from './MermaidLazy';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
|
||||||
|
|
||||||
function extractMermaidCodes(source: string): string[] {
|
|
||||||
const codes: string[] = [];
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = MERMAID_RE.exec(source)) !== null) {
|
|
||||||
codes.push(match[1].trim());
|
|
||||||
}
|
|
||||||
return codes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MarkdownRenderer({ source }: Props) {
|
|
||||||
const html = renderMarkdown(source);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [mermaidCodes, setMermaidCodes] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// Re-derive mermaid codes from the original source. The renderMarkdown function
|
|
||||||
// replaces mermaid blocks with placeholders (data-mermaid-source="${idx}"), but
|
|
||||||
// it does not return the actual codes. Rather than thread them through the API,
|
|
||||||
// we re-extract from the source. Source is small (single buffer content) so this
|
|
||||||
// cost is negligible.
|
|
||||||
useEffect(() => {
|
|
||||||
setMermaidCodes(extractMermaidCodes(source));
|
|
||||||
}, [source]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
|
||||||
{mermaidCodes.map((code, i) => (
|
|
||||||
<div key={i} className="my-4">
|
|
||||||
<MermaidLazy code={code} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mermaidModule: typeof import('mermaid').default | null = null;
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
async function getMermaid() {
|
|
||||||
if (!mermaidModule) {
|
|
||||||
const mod = await import('mermaid');
|
|
||||||
mermaidModule = mod.default;
|
|
||||||
}
|
|
||||||
if (!initialized) {
|
|
||||||
mermaidModule.initialize({ startOnLoad: false, theme: 'default' });
|
|
||||||
initialized = true;
|
|
||||||
}
|
|
||||||
return mermaidModule;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MermaidLazy({ code }: Props) {
|
|
||||||
const [svg, setSvg] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
getMermaid()
|
|
||||||
.then((m) => m.render(idRef.current, code))
|
|
||||||
.then((rendered) => {
|
|
||||||
if (!cancelled) setSvg(rendered);
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
if (!cancelled) setError(err.message);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [code]);
|
|
||||||
|
|
||||||
if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
|
|
||||||
if (!svg) return <div className="text-xs text-muted-foreground">Loading diagram…</div>;
|
|
||||||
return <div data-testid="mermaid-output" dangerouslySetInnerHTML={{ __html: svg }} />;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
|
||||||
import { usePreviewStore } from '@/stores/preview-store';
|
|
||||||
import { useScrollSync } from '@/hooks/use-scroll-sync';
|
|
||||||
|
|
||||||
export function PreviewPane() {
|
|
||||||
const { source, setScrollRatio } = usePreviewStore();
|
|
||||||
const { handlePreviewScroll } = useScrollSync({ onPreviewScroll: setScrollRatio });
|
|
||||||
|
|
||||||
if (!source) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
||||||
<p>Nothing to preview. Start typing in the editor.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-auto bg-card/10" onScroll={handlePreviewScroll}>
|
|
||||||
<MarkdownRenderer source={source} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { ChevronRight, Folder, FolderOpen, File, FileText } from 'lucide-react';
|
|
||||||
import { useFileStore } from '@/stores/file-store';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
function FileTreeNode({ node, depth }: { node: import('@/stores/file-store').FileNode; depth: number }) {
|
|
||||||
const { expanded, activeTabId, loadChildren, toggleExpanded, openFile } = useFileStore();
|
|
||||||
const isExpanded = expanded.has(node.path);
|
|
||||||
const isActive = activeTabId === node.path;
|
|
||||||
|
|
||||||
if (node.isDirectory) {
|
|
||||||
const handleClick = async () => {
|
|
||||||
if (!node.loaded) {
|
|
||||||
await loadChildren(node.path);
|
|
||||||
}
|
|
||||||
toggleExpanded(node.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={handleClick}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
|
|
||||||
isActive && 'bg-accent'
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
size={12}
|
|
||||||
className={cn('transition-transform duration-150', isExpanded && 'rotate-90')}
|
|
||||||
/>
|
|
||||||
{isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />}
|
|
||||||
<span className="truncate">{node.name}</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && node.loaded && node.children && (
|
|
||||||
<div>
|
|
||||||
{node.children.map((child) => (
|
|
||||||
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => openFile(node.path)}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
|
|
||||||
isActive && 'bg-accent text-accent-foreground'
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
|
||||||
>
|
|
||||||
<FileText size={14} />
|
|
||||||
<span className="truncate">{node.name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileTree() {
|
|
||||||
const tree = useFileStore((s) => s.tree);
|
|
||||||
|
|
||||||
if (!tree) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-1">
|
|
||||||
{tree.children?.map((child) => (
|
|
||||||
<FileTreeNode key={child.path} node={child} depth={0} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { useEditorStore } from '@/stores/editor-store';
|
|
||||||
|
|
||||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
|
|
||||||
|
|
||||||
interface HeadingItem {
|
|
||||||
level: number;
|
|
||||||
text: string;
|
|
||||||
line: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractHeadings(content: string): HeadingItem[] {
|
|
||||||
const headings: HeadingItem[] = [];
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
const re = new RegExp(HEADING_RE.source, 'gm');
|
|
||||||
let lineNum = 1;
|
|
||||||
for (const line of content.split('\n')) {
|
|
||||||
re.lastIndex = 0;
|
|
||||||
match = re.exec(line);
|
|
||||||
if (match) {
|
|
||||||
headings.push({
|
|
||||||
level: match[1].length,
|
|
||||||
text: match[2].trim(),
|
|
||||||
line: lineNum,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
lineNum++;
|
|
||||||
}
|
|
||||||
return headings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Outline() {
|
|
||||||
const activeId = useEditorStore((s) => s.activeId);
|
|
||||||
const buffers = useEditorStore((s) => s.buffers);
|
|
||||||
|
|
||||||
if (!activeId) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
|
||||||
<span>No file open</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = buffers.get(activeId);
|
|
||||||
if (!buffer) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
|
||||||
<span>No file open</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headings = extractHeadings(buffer.content);
|
|
||||||
|
|
||||||
if (headings.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
|
||||||
<span>No headings</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0.5 py-1">
|
|
||||||
{headings.map((h, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => {}}
|
|
||||||
className="flex items-center gap-1 rounded px-2 py-0.5 text-left text-xs hover:bg-accent"
|
|
||||||
style={{ paddingLeft: `${(h.level - 1) * 16 + 8}px` }}
|
|
||||||
>
|
|
||||||
<span className="truncate text-muted-foreground">#{h.level}</span>
|
|
||||||
<span className="truncate">{h.text}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { ChevronRight, FolderOpen } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { useFileStore } from '@/stores/file-store';
|
|
||||||
import { FileTree } from './FileTree';
|
|
||||||
import { Outline } from './Outline';
|
|
||||||
|
|
||||||
export function Sidebar() {
|
|
||||||
const tree = useFileStore((s) => s.tree);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col gap-3">
|
|
||||||
{/* Files 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" />
|
|
||||||
Files
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
|
||||||
{!tree ? (
|
|
||||||
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
|
|
||||||
<span>No folder opened</span>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => useFileStore.getState().openFolderDialog()}>
|
|
||||||
<FolderOpen className="mr-1" /> Open Folder
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FileTree />
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* Outline 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" />
|
|
||||||
Outline
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
|
||||||
<Outline />
|
|
||||||
</ScrollArea>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return (
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Toggle theme" className="opacity-0">
|
|
||||||
<Sun className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = resolvedTheme === 'dark';
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
|
||||||
>
|
|
||||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2",
|
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
|
||||||
lg: "h-10 rounded-md px-8",
|
|
||||||
icon: "h-9 w-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Button.displayName = "Button"
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
|
||||||
import { Check } from "lucide-react"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
className={cn("flex items-center justify-center text-current")}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
))
|
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Checkbox }
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root
|
|
||||||
|
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
|
||||||
|
|
||||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ContextMenu = ContextMenuPrimitive.Root
|
|
||||||
|
|
||||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
|
||||||
|
|
||||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
|
||||||
|
|
||||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
|
||||||
|
|
||||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
|
||||||
|
|
||||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const ContextMenuSubTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.SubTrigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
|
||||||
))
|
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
|
||||||
|
|
||||||
const ContextMenuSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
|
||||||
|
|
||||||
const ContextMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Portal>
|
|
||||||
<ContextMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</ContextMenuPrimitive.Portal>
|
|
||||||
))
|
|
||||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const ContextMenuItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const ContextMenuCheckboxItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.CheckboxItem>
|
|
||||||
))
|
|
||||||
ContextMenuCheckboxItem.displayName =
|
|
||||||
ContextMenuPrimitive.CheckboxItem.displayName
|
|
||||||
|
|
||||||
const ContextMenuRadioItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.RadioItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<Circle className="h-4 w-4 fill-current" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.RadioItem>
|
|
||||||
))
|
|
||||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
|
||||||
|
|
||||||
const ContextMenuLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const ContextMenuSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const ContextMenuShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuCheckboxItem,
|
|
||||||
ContextMenuRadioItem,
|
|
||||||
ContextMenuLabel,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuShortcut,
|
|
||||||
ContextMenuGroup,
|
|
||||||
ContextMenuPortal,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuRadioGroup,
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function Dialog({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
data-slot="dialog-overlay"
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DialogPortal data-slot="dialog-portal">
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
data-slot="dialog-content"
|
|
||||||
className={cn(
|
|
||||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({
|
|
||||||
className,
|
|
||||||
showCloseButton = false,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-footer"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
data-slot="dialog-title"
|
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
data-slot="dialog-description"
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
type ControllerProps,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
useFormContext,
|
|
||||||
} from "react-hook-form"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Form = React.forwardRef<
|
|
||||||
HTMLFormElement,
|
|
||||||
React.ComponentProps<"form">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const methods = useFormContext()
|
|
||||||
return (
|
|
||||||
<form ref={ref} className={className} onSubmit={methods.handleSubmit(() => {})} {...props} />
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Form.displayName = "Form"
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FormFieldContext - provides field-level context
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<
|
|
||||||
ControllerProps<FieldValues, string>
|
|
||||||
>({} as never)
|
|
||||||
|
|
||||||
const FormField = <
|
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
||||||
>({
|
|
||||||
...props
|
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
|
||||||
const { name } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormFieldContext.Provider value={props as ControllerProps<FieldValues, string>}>
|
|
||||||
<Controller {...props} />
|
|
||||||
</FormFieldContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// useFormField - must be used inside a FormField
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function useFormField(): ControllerProps<FieldValues, string> {
|
|
||||||
const fieldContext = React.useContext(FormFieldContext)
|
|
||||||
if (!fieldContext) {
|
|
||||||
throw new Error("useFormField must be used within a FormField")
|
|
||||||
}
|
|
||||||
return fieldContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FormItem - wraps a labeled form control
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
|
||||||
))
|
|
||||||
FormItem.displayName = "FormItem"
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FormLabel
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
FormLabel.displayName = LabelPrimitive.Root.displayName
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FormControl - Slot bridge
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Slot>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
|
||||||
>(({ ...props }, ref) => (
|
|
||||||
<Slot ref={ref} {...props} />
|
|
||||||
))
|
|
||||||
FormControl.displayName = "FormControl"
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FormDescription
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
FormDescription.displayName = "FormDescription"
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// FormMessage
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, children, ...props }, ref) => {
|
|
||||||
const { error } = useFormField()
|
|
||||||
const body = error ? String(error.message ?? "") : children
|
|
||||||
if (!body) return null
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm font-medium text-destructive", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormMessage.displayName = "FormMessage"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Form,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormMessage,
|
|
||||||
useFormField,
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export interface InputProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, type, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={cn(
|
|
||||||
"h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Input.displayName = "Input"
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
|
||||||
import { Circle } from "lucide-react"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const RadioGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Root
|
|
||||||
className={cn("grid gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const RadioGroupItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
|
||||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
|
||||||
</RadioGroupPrimitive.Indicator>
|
|
||||||
</RadioGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem }
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { GripVertical } from "lucide-react"
|
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ResizablePanelGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ResizablePrimitive.Group>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ResizablePrimitive.Group
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ResizablePanelGroup.displayName = "ResizablePanelGroup"
|
|
||||||
|
|
||||||
const ResizablePanel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ResizablePrimitive.Panel>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Panel>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ResizablePrimitive.Panel
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"h-full w-full overflow-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ResizablePanel.displayName = "ResizablePanel"
|
|
||||||
|
|
||||||
const ResizableHandle = ({
|
|
||||||
withHandle,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
|
|
||||||
withHandle?: boolean
|
|
||||||
}) => (
|
|
||||||
<ResizablePrimitive.Separator
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{withHandle && (
|
|
||||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
|
||||||
<GripVertical className="h-2.5 w-2.5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePrimitive.Separator>
|
|
||||||
)
|
|
||||||
ResizableHandle.displayName = "ResizableHandle"
|
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ScrollArea = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn("relative overflow-hidden", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
))
|
|
||||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const ScrollBar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
||||||
ref={ref}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"flex touch-none select-none transition-colors",
|
|
||||||
orientation === "vertical" &&
|
|
||||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
|
||||||
orientation === "horizontal" &&
|
|
||||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
))
|
|
||||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
|
||||||
export { ScrollArea, ScrollBar }
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
))
|
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
))
|
|
||||||
SelectScrollDownButton.displayName =
|
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
))
|
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
))
|
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectGroup,
|
|
||||||
SelectValue,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
|
||||||
SelectLabel,
|
|
||||||
SelectItem,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Sheet = SheetPrimitive.Root
|
|
||||||
|
|
||||||
const SheetTrigger = SheetPrimitive.Trigger
|
|
||||||
|
|
||||||
const SheetClose = SheetPrimitive.Close
|
|
||||||
|
|
||||||
const SheetPortal = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SheetPrimitive.DialogPortalProps) => (
|
|
||||||
<SheetPrimitive.Portal className={cn(className)} {...props} />
|
|
||||||
)
|
|
||||||
SheetPortal.displayName = SheetPrimitive.Portal.displayName
|
|
||||||
|
|
||||||
const SheetOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Overlay
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const sheetVariants = cva(
|
|
||||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
side: {
|
|
||||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
||||||
bottom:
|
|
||||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
||||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
|
||||||
right:
|
|
||||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
side: "right"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
interface SheetContentProps
|
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
|
||||||
VariantProps<typeof sheetVariants> {}
|
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
|
||||||
SheetContentProps
|
|
||||||
>(({ side = "right", className, children, ...props }, ref) => (
|
|
||||||
<SheetPortal>
|
|
||||||
<SheetOverlay />
|
|
||||||
<SheetPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(sheetVariants({ side }), className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-secondary">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</SheetPrimitive.Close>
|
|
||||||
</SheetPrimitive.Content>
|
|
||||||
</SheetPortal>
|
|
||||||
))
|
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SheetHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
SheetHeader.displayName = "SheetHeader"
|
|
||||||
|
|
||||||
const SheetFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
SheetFooter.displayName = "SheetFooter"
|
|
||||||
|
|
||||||
const SheetTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-lg font-semibold text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const SheetDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sheet,
|
|
||||||
SheetPortal,
|
|
||||||
SheetOverlay,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetClose,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetFooter,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { 'aria-label': ariaLabel, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<SliderPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full touch-none select-none items-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
|
||||||
</SliderPrimitive.Track>
|
|
||||||
<SliderPrimitive.Thumb
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
</SliderPrimitive.Root>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Slider }
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SwitchPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SwitchPrimitive.Thumb
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitive.Root>
|
|
||||||
))
|
|
||||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Switch }
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Tabs({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Root
|
|
||||||
data-slot="tabs"
|
|
||||||
data-orientation={orientation}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-muted",
|
|
||||||
line: "gap-1 bg-transparent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function TabsList({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
|
||||||
VariantProps<typeof tabsListVariants>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
data-slot="tabs-list"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(tabsListVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsTrigger({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
data-slot="tabs-trigger"
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
|
||||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
data-slot="tabs-content"
|
|
||||||
className={cn("flex-1 outline-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export interface TextareaProps
|
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Textarea.displayName = "Textarea"
|
|
||||||
|
|
||||||
export { Textarea }
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useFileStore } from '@/stores/file-store';
|
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
|
||||||
|
|
||||||
export interface ExportSource {
|
|
||||||
source: string;
|
|
||||||
path: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the active buffer's content from the editor store. Returns null if
|
|
||||||
* no buffer is open. Components handle the null case (prompt to open a file).
|
|
||||||
*/
|
|
||||||
export function useExportSource(): ExportSource | null {
|
|
||||||
const activeTabId = useFileStore((s) => s.activeTabId);
|
|
||||||
const openTabs = useFileStore((s) => s.openTabs);
|
|
||||||
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
|
|
||||||
|
|
||||||
if (!activeTabId || !buffer) return null;
|
|
||||||
const tab = openTabs.find((t) => t.id === activeTabId);
|
|
||||||
return {
|
|
||||||
source: buffer.content,
|
|
||||||
path: activeTabId,
|
|
||||||
title: tab?.title ?? activeTabId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useFileStore } from '@/stores/file-store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global keyboard shortcuts for file operations.
|
|
||||||
*
|
|
||||||
* - Cmd/Ctrl+O: open file
|
|
||||||
* - Cmd/Ctrl+Shift+O: open folder
|
|
||||||
* - Cmd/Ctrl+S: save active buffer
|
|
||||||
* - Cmd/Ctrl+W: close active tab
|
|
||||||
* - Cmd/Ctrl+Tab / Cmd/Ctrl+Shift+Tab: next/previous tab (wraps)
|
|
||||||
*
|
|
||||||
* Suppressed when an <input>, <textarea>, or contentEditable element has focus.
|
|
||||||
*/
|
|
||||||
export function useFileShortcuts(): void {
|
|
||||||
useEffect(() => {
|
|
||||||
function isTypingTarget(target: EventTarget | null): boolean {
|
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
|
||||||
const tag = target.tagName;
|
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
|
||||||
if (target.isContentEditable) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle(e: KeyboardEvent): void {
|
|
||||||
const mod = e.metaKey || e.ctrlKey;
|
|
||||||
if (!mod) return;
|
|
||||||
if (isTypingTarget(e.target)) return;
|
|
||||||
|
|
||||||
const key = e.key.toLowerCase();
|
|
||||||
|
|
||||||
// Order matters: more specific (Shift) variants first so they don't get
|
|
||||||
// shadowed by the plain modifier-only branch.
|
|
||||||
if (e.shiftKey && key === 'o') {
|
|
||||||
e.preventDefault();
|
|
||||||
void useFileStore.getState().openFolderDialog();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!e.shiftKey && key === 'o') {
|
|
||||||
e.preventDefault();
|
|
||||||
void useFileStore.getState().openFileDialog();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!e.shiftKey && key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
void useFileStore.getState().saveActiveBuffer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!e.shiftKey && key === 'w') {
|
|
||||||
e.preventDefault();
|
|
||||||
const { activeTabId, closeTab } = useFileStore.getState();
|
|
||||||
if (activeTabId) closeTab(activeTabId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === 'tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
|
||||||
if (openTabs.length === 0) return;
|
|
||||||
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
|
|
||||||
const safeIdx = idx === -1 ? 0 : idx;
|
|
||||||
const nextIdx = e.shiftKey
|
|
||||||
? (safeIdx <= 0 ? openTabs.length - 1 : safeIdx - 1)
|
|
||||||
: (safeIdx >= openTabs.length - 1 ? 0 : safeIdx + 1);
|
|
||||||
setActiveTab(openTabs[nextIdx].id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handle);
|
|
||||||
return () => window.removeEventListener('keydown', handle);
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { ipc } from '@/lib/ipc';
|
|
||||||
import { useCommandStore } from '@/stores/command-store';
|
|
||||||
import type { CommandId } from '@/stores/command-store';
|
|
||||||
|
|
||||||
type Transform<Args = unknown> = (...payload: unknown[]) => Args;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridge a native-menu IPC channel to a command-store dispatch.
|
|
||||||
*
|
|
||||||
* When the main process sends `channel` (e.g. `'file-save'`), the optional
|
|
||||||
* `transform` converts the payload to the command's args, then the command
|
|
||||||
* `commandId` is dispatched through the command store.
|
|
||||||
*
|
|
||||||
* The hook cleans up its IPC subscription on unmount.
|
|
||||||
*/
|
|
||||||
export function useMenuAction<Args = unknown>(
|
|
||||||
channel: string,
|
|
||||||
commandId: CommandId,
|
|
||||||
transform?: Transform<Args>
|
|
||||||
): void {
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = ipc.menu.on(channel, (...payload: unknown[]) => {
|
|
||||||
const args = transform ? transform(...payload) : (payload[0] as Args);
|
|
||||||
useCommandStore.getState().dispatch(commandId, args);
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [channel, commandId, transform]);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useFileStore } from '@/stores/file-store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On mount, re-opens the last folder if one was persisted via the file store's
|
|
||||||
* zustand persist middleware. Idempotent — re-running with the same path is a
|
|
||||||
* no-op (openFolder's list IPC call would just re-fetch, which is acceptable).
|
|
||||||
*/
|
|
||||||
export function useRestoreLastFolder(): void {
|
|
||||||
useEffect(() => {
|
|
||||||
const { rootPath, openFolder, tree } = useFileStore.getState();
|
|
||||||
if (rootPath && !tree) {
|
|
||||||
void openFolder(rootPath);
|
|
||||||
}
|
|
||||||
// Run once on mount only. Persist hydration happens before AppShell renders.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
onEditorScroll?: (ratio: number) => void;
|
|
||||||
onPreviewScroll?: (ratio: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScrollSync(opts: Options) {
|
|
||||||
const FRAME_MS = 1000 / 60;
|
|
||||||
const lastTick = useRef(-FRAME_MS);
|
|
||||||
|
|
||||||
const handleEditorScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
|
||||||
const target = evt.currentTarget;
|
|
||||||
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
|
||||||
const now = performance.now();
|
|
||||||
if (now - lastTick.current < FRAME_MS) return;
|
|
||||||
lastTick.current = now;
|
|
||||||
opts.onEditorScroll?.(ratio);
|
|
||||||
}, [opts]);
|
|
||||||
|
|
||||||
const handlePreviewScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
|
||||||
const target = evt.currentTarget;
|
|
||||||
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
|
||||||
opts.onPreviewScroll?.(ratio);
|
|
||||||
}, [opts]);
|
|
||||||
|
|
||||||
return { handleEditorScroll, handlePreviewScroll };
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind a keyboard shortcut to a callback.
|
|
||||||
*
|
|
||||||
* Combos use `+` separators. Tokens:
|
|
||||||
* - `mod` → meta (Mac) or ctrl (others)
|
|
||||||
* - `ctrl` → ctrl only
|
|
||||||
* - `meta` → meta only
|
|
||||||
* - `alt` → alt / option
|
|
||||||
* - `shift` → shift
|
|
||||||
* - any single char (case-insensitive), e.g. `s`, `S`
|
|
||||||
* - special names: `Tab`, `Enter`, `Escape`, `Space`, `ArrowUp/Down/Left/Right`
|
|
||||||
*
|
|
||||||
* The hook is suppressed when the keydown target is an `<input>`, `<textarea>`,
|
|
||||||
* or contentEditable element, mirroring the standard browser shortcut pattern.
|
|
||||||
*
|
|
||||||
* On match, `event.preventDefault()` is called.
|
|
||||||
*/
|
|
||||||
export function useShortcut(combo: string, callback: (e: KeyboardEvent) => void): void {
|
|
||||||
useEffect(() => {
|
|
||||||
const tokens = parseCombo(combo);
|
|
||||||
|
|
||||||
function isTypingTarget(target: EventTarget | null): boolean {
|
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
|
||||||
const tag = target.tagName;
|
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
|
||||||
if (target.isContentEditable) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle(e: KeyboardEvent): void {
|
|
||||||
if (isTypingTarget(e.target)) return;
|
|
||||||
if (!matchCombo(tokens, e)) return;
|
|
||||||
e.preventDefault();
|
|
||||||
callback(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handle);
|
|
||||||
return () => window.removeEventListener('keydown', handle);
|
|
||||||
}, [combo, callback]);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComboSpec {
|
|
||||||
mod?: boolean;
|
|
||||||
ctrl?: boolean;
|
|
||||||
meta?: boolean;
|
|
||||||
alt?: boolean;
|
|
||||||
shift?: boolean;
|
|
||||||
key: string; // lowercased
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCombo(combo: string): ComboSpec {
|
|
||||||
const parts = combo
|
|
||||||
.split('+')
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const spec: ComboSpec = { key: '' };
|
|
||||||
for (const p of parts) {
|
|
||||||
const lower = p.toLowerCase();
|
|
||||||
if (lower === 'mod') spec.mod = true;
|
|
||||||
else if (lower === 'ctrl' || lower === 'control') spec.ctrl = true;
|
|
||||||
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') spec.meta = true;
|
|
||||||
else if (lower === 'alt' || lower === 'option') spec.alt = true;
|
|
||||||
else if (lower === 'shift') spec.shift = true;
|
|
||||||
else spec.key = lower;
|
|
||||||
}
|
|
||||||
return spec;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchCombo(spec: ComboSpec, e: KeyboardEvent): boolean {
|
|
||||||
const key = e.key.toLowerCase();
|
|
||||||
if (key !== spec.key) return false;
|
|
||||||
|
|
||||||
if (spec.mod) {
|
|
||||||
if (!(e.metaKey || e.ctrlKey)) return false;
|
|
||||||
}
|
|
||||||
if (spec.ctrl && !e.ctrlKey) return false;
|
|
||||||
if (spec.meta && !e.metaKey) return false;
|
|
||||||
if (spec.alt && !e.altKey) return false;
|
|
||||||
if (spec.shift && !e.shiftKey) return false;
|
|
||||||
|
|
||||||
// Reject extra modifiers that the spec did not ask for.
|
|
||||||
// (e.g. spec says "mod+s" but user pressed "mod+shift+s" → no match.)
|
|
||||||
if (!spec.shift && e.shiftKey) return false;
|
|
||||||
if (!spec.alt && e.altKey) return false;
|
|
||||||
// ctrl/meta: if spec did not require mod, ctrl or meta must be absent.
|
|
||||||
if (!spec.mod && !spec.ctrl && e.ctrlKey) return false;
|
|
||||||
if (!spec.mod && !spec.meta && e.metaKey) return false;
|
|
||||||
// If spec said "mod" only, ctrl+meta is allowed (mod is either-or).
|
|
||||||
if (spec.mod && !spec.ctrl && !spec.meta) {
|
|
||||||
// ok
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On first mount, if the user hasn't dismissed the welcome dialog, open it.
|
|
||||||
* Call once at the top of App.tsx.
|
|
||||||
*/
|
|
||||||
export function useWelcomeTrigger() {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useSettingsStore.getState().welcomeDismissed) {
|
|
||||||
useAppStore.getState().openModal('welcome');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>MarkdownConverter</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="font-sans antialiased">
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert a 2D string array to a fixed-width ASCII table with `| ... |` rows
|
|
||||||
* and a `| --- |` separator.
|
|
||||||
*/
|
|
||||||
export function toAsciiTable(rows: string[][]): string {
|
|
||||||
if (rows.length === 0) return '';
|
|
||||||
const numCols = Math.max(...rows.map((r) => r.length));
|
|
||||||
const widths: number[] = Array.from({ length: numCols }, (_, c) =>
|
|
||||||
Math.max(...rows.map((r) => (r[c] ?? '').length))
|
|
||||||
);
|
|
||||||
const lines = rows.map((row) =>
|
|
||||||
'| ' +
|
|
||||||
Array.from({ length: numCols }, (_, c) => (row[c] ?? '').padEnd(widths[c])).join(' | ') +
|
|
||||||
' |'
|
|
||||||
);
|
|
||||||
// Insert separator after first row
|
|
||||||
const sep =
|
|
||||||
'| ' + Array.from({ length: numCols }, (_, c) => '-'.repeat(widths[c])).join(' | ') + ' |';
|
|
||||||
return [lines[0], sep, ...lines.slice(1)].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches a markdown table block: header row, separator, ≥1 data row.
|
|
||||||
const TABLE_RE = /^\|.+\|\n^\|[\s:|-]+\|\n((?:^\|.+\|\n?)+)/gm;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace all markdown tables in `source` with fenced code blocks containing
|
|
||||||
* the ASCII-rendered equivalent. Non-table content is preserved verbatim.
|
|
||||||
*/
|
|
||||||
export function applyAsciiTransform(source: string): string {
|
|
||||||
return source.replace(TABLE_RE, (block) => {
|
|
||||||
const lines = block.trim().split('\n');
|
|
||||||
const header = lines[0].slice(1, -1).split('|').map((s) => s.trim());
|
|
||||||
const body = lines.slice(2).map((l) =>
|
|
||||||
l.slice(1, -1).split('|').map((s) => s.trim())
|
|
||||||
);
|
|
||||||
return '```\n' + toAsciiTable([header, ...body]) + '\n```';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useCommandStore } from '@/stores/command-store';
|
|
||||||
import { useFileStore } from '@/stores/file-store';
|
|
||||||
import { useAppStore } from '@/stores/app-store';
|
|
||||||
import { useMenuAction } from '@/hooks/use-menu-action';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register all Phase 6 menu commands in the command store, and bridge
|
|
||||||
* the native menu IPC channels to the matching command ids.
|
|
||||||
*
|
|
||||||
* Phase 6 scope: file/view/tab commands with direct store mappings.
|
|
||||||
* Phase 7+ will add the dialog-driven commands (export, settings, etc.)
|
|
||||||
* once the corresponding modals exist.
|
|
||||||
*/
|
|
||||||
export function registerMenuCommands(): void {
|
|
||||||
const { registerMany } = useCommandStore.getState();
|
|
||||||
|
|
||||||
registerMany({
|
|
||||||
'settings.open': () => useAppStore.getState().openModal('settings'),
|
|
||||||
'help.about': () => useAppStore.getState().openModal('about'),
|
|
||||||
'help.welcome': () => useAppStore.getState().openModal('welcome'),
|
|
||||||
'file.exportPdf': () => {
|
|
||||||
const activeTabId = useFileStore.getState().activeTabId;
|
|
||||||
if (!activeTabId) return;
|
|
||||||
useAppStore.getState().openModal('export-pdf', { sourcePath: activeTabId });
|
|
||||||
},
|
|
||||||
'file.exportDocx': () => {
|
|
||||||
const activeTabId = useFileStore.getState().activeTabId;
|
|
||||||
if (!activeTabId) return;
|
|
||||||
useAppStore.getState().openModal('export-docx', { sourcePath: activeTabId });
|
|
||||||
},
|
|
||||||
'file.exportHtml': () => {
|
|
||||||
const activeTabId = useFileStore.getState().activeTabId;
|
|
||||||
if (!activeTabId) return;
|
|
||||||
useAppStore.getState().openModal('export-html', { sourcePath: activeTabId });
|
|
||||||
},
|
|
||||||
'file.exportBatch': () => {
|
|
||||||
const paths = useFileStore.getState().openTabs.map((t) => t.path);
|
|
||||||
if (paths.length === 0) return;
|
|
||||||
useAppStore.getState().openModal('export-batch', { sourcePaths: paths });
|
|
||||||
},
|
|
||||||
'file.confirmClose': () => {
|
|
||||||
/* stub — wired in later phase */
|
|
||||||
},
|
|
||||||
'app.quit': () => {
|
|
||||||
/* stub — wired in later phase */
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { register } = useCommandStore.getState();
|
|
||||||
register('file.open', () => {
|
|
||||||
void useFileStore.getState().openFileDialog();
|
|
||||||
});
|
|
||||||
register('file.openFolder', () => {
|
|
||||||
void useFileStore.getState().openFolderDialog();
|
|
||||||
});
|
|
||||||
register('file.save', () => {
|
|
||||||
void useFileStore.getState().saveActiveBuffer();
|
|
||||||
});
|
|
||||||
register('file.closeTab', () => {
|
|
||||||
const { activeTabId, closeTab } = useFileStore.getState();
|
|
||||||
if (activeTabId) closeTab(activeTabId);
|
|
||||||
});
|
|
||||||
register('tab.next', () => {
|
|
||||||
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
|
||||||
if (openTabs.length === 0) return;
|
|
||||||
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
|
|
||||||
const safeIdx = idx === -1 ? 0 : idx;
|
|
||||||
const nextIdx = safeIdx >= openTabs.length - 1 ? 0 : safeIdx + 1;
|
|
||||||
setActiveTab(openTabs[nextIdx].id);
|
|
||||||
});
|
|
||||||
register('tab.prev', () => {
|
|
||||||
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
|
||||||
if (openTabs.length === 0) return;
|
|
||||||
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
|
|
||||||
const safeIdx = idx === -1 ? 0 : idx;
|
|
||||||
const nextIdx = safeIdx <= 0 ? openTabs.length - 1 : safeIdx - 1;
|
|
||||||
setActiveTab(openTabs[nextIdx].id);
|
|
||||||
});
|
|
||||||
register('view.toggleSidebar', () => useAppStore.getState().toggleSidebar());
|
|
||||||
register('view.togglePreview', () => useAppStore.getState().togglePreview());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRegisterMenuCommands(): void {
|
|
||||||
// Register handlers in the command store.
|
|
||||||
useEffect(() => {
|
|
||||||
registerMenuCommands();
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire native-menu IPC channels to the command store.
|
|
||||||
* Channel names match what main.js dispatches via webContents.send.
|
|
||||||
*/
|
|
||||||
export function useBridgeNativeMenu(): void {
|
|
||||||
useMenuAction('file-save', 'file.save');
|
|
||||||
useMenuAction('toggle-preview', 'view.togglePreview');
|
|
||||||
useMenuAction('toggle-command-palette', 'command.palette');
|
|
||||||
useMenuAction('toggle-sidebar-panel', 'view.sidebarPanel', (panel) => panel as string);
|
|
||||||
useMenuAction('toggle-bottom-panel', 'view.bottomPanel');
|
|
||||||
useMenuAction('toggle-find', 'find.toggle');
|
|
||||||
useMenuAction('undo', 'editor.undo');
|
|
||||||
useMenuAction('redo', 'editor.redo');
|
|
||||||
useMenuAction('adjust-font-size', 'font.size', (direction) => direction as string);
|
|
||||||
useMenuAction('load-custom-css', 'theme.loadCustomCss');
|
|
||||||
useMenuAction('clear-custom-css', 'theme.clearCustomCss');
|
|
||||||
useMenuAction('load-template-menu', 'template.load', (name) => name as string);
|
|
||||||
useMenuAction('print-preview', 'print.preview');
|
|
||||||
useMenuAction('print-preview-styled', 'print.previewStyled');
|
|
||||||
useMenuAction('file-opened', 'file.opened', (payload) => payload);
|
|
||||||
useMenuAction('clear-recent-files', 'file.clearRecent');
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import type {
|
|
||||||
IpcResult,
|
|
||||||
FileResult,
|
|
||||||
FileEntry,
|
|
||||||
PdfOptions,
|
|
||||||
DocxOptions,
|
|
||||||
HtmlOptions,
|
|
||||||
ExportResult,
|
|
||||||
BatchItem,
|
|
||||||
BatchOptions,
|
|
||||||
BatchResult,
|
|
||||||
} from '@/types/ipc';
|
|
||||||
|
|
||||||
type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string };
|
|
||||||
|
|
||||||
function wrap<T>(fn: () => Promise<T>): Promise<IpcResult<T | ChannelMissing>> {
|
|
||||||
if (typeof window === 'undefined' || !window.electronAPI) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fn().then(
|
|
||||||
(data) => ({ ok: true as const, data }),
|
|
||||||
(err: Error) => ({
|
|
||||||
ok: false as const,
|
|
||||||
error: { code: err.name || 'IPC_ERROR', message: err.message || String(err) },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeCall<T extends (...args: any[]) => Promise<any>>(
|
|
||||||
channel: string,
|
|
||||||
method: string,
|
|
||||||
...args: Parameters<T>
|
|
||||||
): Promise<IpcResult<Awaited<ReturnType<T>> | ChannelMissing>> {
|
|
||||||
if (typeof window === 'undefined' || !window.electronAPI) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const target = (window.electronAPI as any)[channel]?.[method];
|
|
||||||
if (!target) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 'CHANNEL_MISSING', message: `Missing channel: ${channel}.${method}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (typeof target !== 'function') {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 'CHANNEL_MISSING', message: `Not a function: ${channel}.${method}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return wrap(() => target(...args));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ipc = {
|
|
||||||
file: {
|
|
||||||
open: (): Promise<IpcResult<FileResult | ChannelMissing>> =>
|
|
||||||
safeCall('file', 'open'),
|
|
||||||
read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
|
|
||||||
safeCall('file', 'read', path),
|
|
||||||
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
|
|
||||||
safeCall('file', 'write', path, content),
|
|
||||||
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
|
||||||
safeCall('file', 'list', dir),
|
|
||||||
pickFolder: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
|
||||||
safeCall('file', 'pickFolder'),
|
|
||||||
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
|
||||||
safeCall('file', 'pickFile'),
|
|
||||||
onChange: (cb: (path: string) => void): (() => void) => {
|
|
||||||
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
return window.electronAPI.file.onChange(cb);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
export: {
|
|
||||||
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
|
||||||
safeCall('export', 'pdf', opts),
|
|
||||||
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
|
||||||
safeCall('export', 'docx', opts),
|
|
||||||
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
|
||||||
safeCall('export', 'html', opts),
|
|
||||||
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult | ChannelMissing>> =>
|
|
||||||
safeCall('export', 'batch', items, opts),
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
|
|
||||||
safeCall('app', 'getVersion'),
|
|
||||||
openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
|
|
||||||
safeCall('app', 'openExternal', url),
|
|
||||||
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
|
||||||
safeCall('app', 'showItemInFolder', path),
|
|
||||||
},
|
|
||||||
menu: {
|
|
||||||
/**
|
|
||||||
* Subscribe to a native-menu IPC channel. The callback receives the
|
|
||||||
* raw payload(s) from the main process. Returns an unsubscribe fn.
|
|
||||||
*/
|
|
||||||
on: (channel: string, callback: (...args: unknown[]) => void): (() => void) => {
|
|
||||||
if (typeof window === 'undefined' || !window.electronAPI?.on) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
return window.electronAPI.on(channel, callback as (...a: any[]) => void);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user