mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24bb9aa9f5 | ||
|
|
b39bdcf475 | ||
|
|
9d154a8521 | ||
|
|
fe62c8814c | ||
|
|
9306a9c23b | ||
|
|
88e26c130e | ||
|
|
b02707ac7f | ||
|
|
ebc149ff0b | ||
|
|
be3af42fc5 | ||
|
|
ed611c77ed | ||
|
|
f25d856a2a | ||
|
|
76fed872ce | ||
|
|
9de14df016 | ||
|
|
5f0b4c9c4e | ||
|
|
7d083a024d | ||
|
|
68fcaf9bfc | ||
|
|
a2a44fbf2c | ||
|
|
026a4d9fac | ||
|
|
a461b62dd3 | ||
|
|
1cd316caf1 | ||
|
|
2bdb380130 | ||
|
|
41942a1316 | ||
|
|
9ad484744a | ||
|
|
d0a26126cc | ||
|
|
53870d58bb | ||
|
|
00d6802422 | ||
|
|
02418b943e | ||
|
|
8b2809ebaf | ||
|
|
8f5eb301b6 | ||
|
|
8f9dab1405 | ||
|
|
a6fa14124d | ||
|
|
0538aec02c | ||
|
|
1d782a00e6 | ||
|
|
9eb1fe0f00 | ||
|
|
3344b2aea9 | ||
|
|
6b23e5ef60 | ||
|
|
221598c91b | ||
|
|
d8ee743fb1 | ||
|
|
66e2b56221 | ||
|
|
d22d616727 | ||
|
|
89590a349a | ||
|
|
f447896a63 | ||
|
|
ccb998be66 | ||
|
|
813582aa8d | ||
|
|
b1e16af62d | ||
|
|
487ce2ad45 | ||
|
|
1717b2e8c0 | ||
|
|
cdd370c62d | ||
|
|
6df21ace35 | ||
|
|
c93747c476 | ||
|
|
d865b181e9 | ||
|
|
5e5bab3da9 | ||
|
|
ce2bf62d42 | ||
|
|
6da98c3a54 | ||
|
|
3598d06240 | ||
|
|
650c266945 | ||
|
|
05bbb9f86b | ||
|
|
77c8e05fa5 | ||
|
|
17f318dd75 | ||
|
|
fa5a46d5ac | ||
|
|
a4201acad9 | ||
|
|
0b5af04a6a | ||
|
|
2d6f909b0e | ||
|
|
8624257c1f | ||
|
|
b75f436aaa | ||
|
|
d81d8c28d2 | ||
|
|
559d09cad9 | ||
|
|
5ad582ec3e | ||
|
|
d02f896167 | ||
|
|
f34775ce53 | ||
|
|
5d6a55dd09 | ||
|
|
0e80e37d9d | ||
|
|
9a7e994224 | ||
|
|
3effacf816 | ||
|
|
1be10f9ae5 | ||
|
|
4c0b7d6e84 | ||
|
|
db04ea5854 | ||
|
|
567726022c | ||
|
|
1999a7716e | ||
|
|
16e071ae5e | ||
|
|
7ecf54ceb0 | ||
|
|
95e2f45f1f | ||
|
|
7fbbd60525 | ||
|
|
c715bb354c | ||
|
|
6525de7667 | ||
|
|
a2c1d0c11a | ||
|
|
e0d6315204 | ||
|
|
68ff7d076a | ||
|
|
67b57eae78 | ||
|
|
301c51ac84 | ||
|
|
904ffbdd5b | ||
|
|
ea383783ec | ||
|
|
e758a81c4c | ||
|
|
ea0f3f93bf | ||
|
|
188e73d7c1 | ||
|
|
8265e4ff88 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$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
@@ -0,0 +1,399 @@
|
||||
# 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
|
||||
Generated
+2585
-108
File diff suppressed because it is too large
Load Diff
+35
-1
@@ -8,6 +8,9 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:renderer": "vitest run",
|
||||
"test:renderer:watch": "vitest",
|
||||
"test:renderer:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src tests",
|
||||
"lint:fix": "eslint src tests --fix",
|
||||
"format": "prettier --write src tests",
|
||||
@@ -43,10 +46,16 @@
|
||||
"url": "https://github.com/amitwh/markdown-converter"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@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",
|
||||
@@ -58,6 +67,7 @@
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"jest": "^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",
|
||||
@@ -66,7 +76,8 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
@@ -83,6 +94,22 @@
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@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",
|
||||
"core-util-is": "^1.0.3",
|
||||
@@ -93,18 +120,25 @@
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"immer": "^11.1.8",
|
||||
"marked": "^17.0.3",
|
||||
"marked-footnote": "^1.4.0",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"mermaid": "^11.12.3",
|
||||
"motion": "^12.40.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pdfkit": "^0.17.2",
|
||||
"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",
|
||||
"sonner": "^2.0.7",
|
||||
"tslib": "^2.8.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
+49
@@ -3039,6 +3039,55 @@ ipcMain.on('select-folder', (event, type) => {
|
||||
}
|
||||
});
|
||||
|
||||
// List directory contents as FileEntry[]
|
||||
ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
return entries.map((dirent) => {
|
||||
const fullPath = path.join(dirPath, dirent.name);
|
||||
let size;
|
||||
let modifiedAt;
|
||||
if (!dirent.isDirectory()) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
size = stat.size;
|
||||
modifiedAt = stat.mtime.toISOString();
|
||||
} catch {
|
||||
// ignore stat errors
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: dirent.name,
|
||||
path: fullPath,
|
||||
isDirectory: dirent.isDirectory(),
|
||||
size,
|
||||
modifiedAt,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Show folder picker dialog and return selected path or null
|
||||
ipcMain.handle('pick-folder', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
// Show file picker dialog for markdown files and return selected path or null
|
||||
ipcMain.handle('pick-file', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
||||
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
||||
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
||||
|
||||
+10
-2
@@ -237,7 +237,12 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'load-template-menu',
|
||||
'toggle-command-palette',
|
||||
'toggle-sidebar-panel',
|
||||
'toggle-bottom-panel'
|
||||
'toggle-bottom-panel',
|
||||
|
||||
// File dialog / directory listing
|
||||
'list-directory',
|
||||
'pick-folder',
|
||||
'pick-file'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -344,7 +349,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
|
||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
|
||||
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
|
||||
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
||||
pickFile: () => ipcRenderer.invoke('pick-file')
|
||||
},
|
||||
|
||||
// Theme Operations
|
||||
|
||||
+8
-41
@@ -1,47 +1,14 @@
|
||||
import React from 'react';
|
||||
import { AppHeader } from './components/layout/AppHeader';
|
||||
import { TabBar } from './components/layout/TabBar';
|
||||
import { Toolbar } from './components/layout/Toolbar';
|
||||
import { Breadcrumb } from './components/layout/Breadcrumb';
|
||||
import { Sidebar } from './components/sidebar/Sidebar';
|
||||
import { EditorPane } from './components/editor/EditorPane';
|
||||
import { PreviewPane } from './components/preview/PreviewPane';
|
||||
import { StatusBar } from './components/layout/StatusBar';
|
||||
import { CommandPalette } from './components/modals/CommandPalette';
|
||||
import { ExportDialog } from './components/modals/ExportDialog';
|
||||
import { SettingsDialog } from './components/modals/SettingsDialog';
|
||||
import { useAppStore } from './stores/appStore';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { ModalLayer } from './components/modals/ModalLayer';
|
||||
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
||||
|
||||
function App() {
|
||||
const { sidebarVisible, previewVisible, activeTabId } = useAppStore();
|
||||
|
||||
useWelcomeTrigger();
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background text-foreground overflow-hidden">
|
||||
<AppHeader />
|
||||
<TabBar />
|
||||
<Toolbar />
|
||||
<Breadcrumb />
|
||||
|
||||
<main className="flex flex-1 overflow-hidden">
|
||||
{sidebarVisible && <Sidebar />}
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<EditorPane className={previewVisible ? 'flex-1' : 'flex-1'} />
|
||||
{previewVisible && (
|
||||
<>
|
||||
<div className="w-px bg-border cursor-col-resize hover:bg-brand transition-colors" />
|
||||
<PreviewPane className="flex-1" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<StatusBar />
|
||||
<CommandPalette />
|
||||
<ExportDialog />
|
||||
<SettingsDialog />
|
||||
</div>
|
||||
<>
|
||||
<AppShell />
|
||||
<ModalLayer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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" />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
@@ -0,0 +1,70 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 }} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 }
|
||||
@@ -0,0 +1,27 @@
|
||||
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 }
|
||||
@@ -0,0 +1,11 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,200 @@
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 }
|
||||
@@ -0,0 +1,20 @@
|
||||
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 }
|
||||
@@ -0,0 +1,41 @@
|
||||
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 }
|
||||
@@ -0,0 +1,62 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,47 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,159 @@
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,26 @@
|
||||
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 }
|
||||
@@ -0,0 +1,91 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,24 @@
|
||||
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 }
|
||||
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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');
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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```';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
});
|
||||
|
||||
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
// Mark mermaid blocks with a placeholder we can replace client-side.
|
||||
const placeholders: string[] = [];
|
||||
const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => {
|
||||
const idx = placeholders.length;
|
||||
placeholders.push(code.trim());
|
||||
return `<div class="mermaid-block" data-mermaid-source="${idx}"></div>`;
|
||||
});
|
||||
|
||||
const rawHtml = marked.parse(withPlaceholders, { async: false }) as string;
|
||||
const clean = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'],
|
||||
});
|
||||
|
||||
// The sanitized HTML still has placeholders; we leave the actual mermaid
|
||||
// rendering to the React layer (MermaidLazy component) so the heavy
|
||||
// mermaid library only loads when needed.
|
||||
return clean;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Transition, Variants } from 'motion/react';
|
||||
|
||||
export const fadeIn: Transition = {
|
||||
duration: 0.2,
|
||||
ease: 'easeOut',
|
||||
};
|
||||
|
||||
export const slideInRight: Variants = {
|
||||
x: '100%',
|
||||
initial: { x: '100%', opacity: 0 },
|
||||
animate: { x: 0, opacity: 1, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] } },
|
||||
exit: { x: '100%', opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } },
|
||||
};
|
||||
|
||||
export const modalPop: Variants = {
|
||||
scale: 0.96,
|
||||
opacity: 0,
|
||||
initial: { scale: 0.96, opacity: 0 },
|
||||
animate: { scale: 1, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } },
|
||||
exit: { scale: 0.96, opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } },
|
||||
};
|
||||
|
||||
export const toastSpring: Transition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
export const sidebarToggle: Transition = {
|
||||
duration: 0.25,
|
||||
ease: 'easeInOut',
|
||||
width: {
|
||||
duration: 0.25,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
};
|
||||
|
||||
export const tabSwitch: Transition = {
|
||||
duration: 0.2,
|
||||
ease: 'easeOut',
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
fontSize: z.number().min(10).max(24).default(14),
|
||||
tabSize: z.union([z.literal(2), z.literal(4), z.literal(8)]).default(4),
|
||||
lineNumbers: z.boolean().default(true),
|
||||
wordWrap: z.boolean().default(true),
|
||||
minimap: z.boolean().default(true),
|
||||
theme: z.enum(['light', 'dark', 'auto']).default('auto'),
|
||||
accentColor: z.enum(['brand', 'blue', 'green', 'purple', 'orange']).default('brand'),
|
||||
fontFamily: z.enum(['system', 'jetbrains', 'fira']).default('system'),
|
||||
pdfFormat: z.enum(['letter', 'a4', 'legal']).default('a4'),
|
||||
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
|
||||
pdfEmbedFonts: z.boolean().default(true),
|
||||
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
|
||||
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
|
||||
renderTablesAsAscii: z.boolean().default(false),
|
||||
welcomeDismissed: z.boolean().default(false),
|
||||
});
|
||||
export type Settings = z.infer<typeof settingsSchema>;
|
||||
|
||||
export const exportPdfSchema = z.object({
|
||||
format: z.enum(['letter', 'a4', 'legal']),
|
||||
margins: z.enum(['normal', 'narrow', 'wide']),
|
||||
embedFonts: z.boolean(),
|
||||
renderTablesAsAscii: z.boolean().optional(),
|
||||
});
|
||||
export type ExportPdfOptions = z.infer<typeof exportPdfSchema>;
|
||||
|
||||
export const exportDocxSchema = z.object({
|
||||
template: z.enum(['standard', 'minimal', 'modern']),
|
||||
renderTablesAsAscii: z.boolean().optional(),
|
||||
});
|
||||
export type ExportDocxOptions = z.infer<typeof exportDocxSchema>;
|
||||
|
||||
export const exportHtmlSchema = z.object({
|
||||
standalone: z.boolean(),
|
||||
highlightStyle: z.enum(['github', 'monokai', 'nord', 'none']),
|
||||
renderTablesAsAscii: z.boolean().optional(),
|
||||
});
|
||||
export type ExportHtmlOptions = z.infer<typeof exportHtmlSchema>;
|
||||
|
||||
export const exportBatchSchema = z.object({
|
||||
format: z.enum(['pdf', 'docx', 'html', 'png']),
|
||||
concurrency: z.number().int().min(1).max(16),
|
||||
filePaths: z.array(z.string()).min(1),
|
||||
});
|
||||
export type ExportBatchOptions = z.infer<typeof exportBatchSchema>;
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface PaneSizes {
|
||||
sidebar: number;
|
||||
editor: number;
|
||||
preview: number;
|
||||
}
|
||||
|
||||
export interface ConfirmProps {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
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 type ModalKind = ModalState['kind'];
|
||||
|
||||
interface AppState {
|
||||
sidebarVisible: boolean;
|
||||
previewVisible: boolean;
|
||||
zenMode: boolean;
|
||||
paneSizes: PaneSizes;
|
||||
modal: ModalState;
|
||||
toggleSidebar: () => void;
|
||||
togglePreview: () => void;
|
||||
setZenMode: (value: boolean) => void;
|
||||
setPaneSizes: (sizes: PaneSizes) => void;
|
||||
openModal: <K extends NonNullable<ModalKind>>(
|
||||
kind: K,
|
||||
...args: Extract<ModalState, { kind: K }> extends { props: infer P } ? [props?: P] : []
|
||||
) => void;
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
zenMode: false,
|
||||
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||
modal: { kind: null },
|
||||
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
|
||||
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
|
||||
setZenMode: (value) => set({ zenMode: value }),
|
||||
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
|
||||
openModal: (kind, ...args) =>
|
||||
set(() => {
|
||||
const candidate = { kind, ...(args[0] ? { props: args[0] } : {}) } as ModalState;
|
||||
return { modal: candidate };
|
||||
}),
|
||||
closeModal: () => set({ modal: { kind: null } }),
|
||||
}),
|
||||
{
|
||||
name: 'mc-app-store',
|
||||
partialize: (state) => ({
|
||||
sidebarVisible: state.sidebarVisible,
|
||||
previewVisible: state.previewVisible,
|
||||
zenMode: state.zenMode,
|
||||
paneSizes: state.paneSizes,
|
||||
// modal is intentionally NOT persisted (runtime-only)
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type CommandId = string;
|
||||
|
||||
export type CommandHandler = (args?: unknown) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* User-customizable key binding for a command.
|
||||
* Format: same as useShortcut combo ('mod+s', 'mod+shift+s', etc.)
|
||||
* Undefined means "use the default binding" (if any).
|
||||
*/
|
||||
export type UserBindings = Record<string, string>;
|
||||
|
||||
interface CommandState {
|
||||
handlers: Record<string, CommandHandler>;
|
||||
userBindings: UserBindings;
|
||||
register: (id: CommandId, handler: CommandHandler) => void;
|
||||
registerMany: (handlers: Record<string, CommandHandler>) => void;
|
||||
unregister: (id: CommandId) => void;
|
||||
dispatch: (id: CommandId, args?: unknown) => void;
|
||||
get: (id: CommandId) => CommandHandler | undefined;
|
||||
setUserBinding: (id: CommandId, combo: string) => void;
|
||||
clearUserBinding: (id: CommandId) => void;
|
||||
getUserBinding: (id: CommandId) => string | undefined;
|
||||
}
|
||||
|
||||
export const useCommandStore = create<CommandState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
handlers: {},
|
||||
userBindings: {},
|
||||
register: (id, handler) =>
|
||||
set((s) => ({ handlers: { ...s.handlers, [id]: handler } })),
|
||||
registerMany: (handlers) =>
|
||||
set((s) => ({ handlers: { ...s.handlers, ...handlers } })),
|
||||
unregister: (id) =>
|
||||
set((s) => {
|
||||
const next = { ...s.handlers };
|
||||
delete next[id];
|
||||
return { handlers: next };
|
||||
}),
|
||||
dispatch: (id, args) => {
|
||||
const handler = get().handlers[id];
|
||||
if (handler) {
|
||||
void handler(args);
|
||||
}
|
||||
},
|
||||
get: (id) => get().handlers[id],
|
||||
setUserBinding: (id, combo) =>
|
||||
set((s) => ({ userBindings: { ...s.userBindings, [id]: combo } })),
|
||||
clearUserBinding: (id) =>
|
||||
set((s) => {
|
||||
const next = { ...s.userBindings };
|
||||
delete next[id];
|
||||
return { userBindings: next };
|
||||
}),
|
||||
getUserBinding: (id) => get().userBindings[id],
|
||||
}),
|
||||
{
|
||||
name: 'mc-command-store',
|
||||
// Only persist user-customizable bindings; handlers are runtime-only.
|
||||
partialize: (state) => ({ userBindings: state.userBindings }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { enableMapSet } from 'immer';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
export interface Buffer {
|
||||
id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
dirty: boolean;
|
||||
cursor?: { line: number; column: number };
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
buffers: Map<string, Buffer>;
|
||||
activeId: string | null;
|
||||
openBuffer: (id: string, path: string, content: string) => void;
|
||||
updateContent: (id: string, content: string) => void;
|
||||
markSaved: (id: string) => void;
|
||||
setCursor: (id: string, line: number, column: number) => void;
|
||||
closeBuffer: (id: string) => void;
|
||||
setActive: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
immer((set) => ({
|
||||
buffers: new Map(),
|
||||
activeId: null,
|
||||
openBuffer: (id, path, content) =>
|
||||
set((s) => {
|
||||
s.buffers.set(id, { id, path, content, dirty: false });
|
||||
s.activeId = id;
|
||||
}),
|
||||
updateContent: (id, content) =>
|
||||
set((s) => {
|
||||
const buf = s.buffers.get(id);
|
||||
if (buf) {
|
||||
buf.content = content;
|
||||
buf.dirty = true;
|
||||
}
|
||||
}),
|
||||
markSaved: (id) =>
|
||||
set((s) => {
|
||||
const buf = s.buffers.get(id);
|
||||
if (buf) buf.dirty = false;
|
||||
}),
|
||||
setCursor: (id, line, column) =>
|
||||
set((s) => {
|
||||
const buf = s.buffers.get(id);
|
||||
if (buf) buf.cursor = { line, column };
|
||||
}),
|
||||
closeBuffer: (id) =>
|
||||
set((s) => {
|
||||
s.buffers.delete(id);
|
||||
if (s.activeId === id) s.activeId = null;
|
||||
}),
|
||||
setActive: (id) =>
|
||||
set((s) => {
|
||||
s.activeId = id;
|
||||
}),
|
||||
}))
|
||||
);
|
||||
@@ -0,0 +1,235 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { enableMapSet } from 'immer';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import type { FileEntry } from '@/types/ipc';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children: FileNode[] | null;
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
interface FileState {
|
||||
tree: FileNode | null;
|
||||
rootPath: string | null;
|
||||
expanded: Set<string>;
|
||||
openTabs: OpenTab[];
|
||||
activeTabId: string | null;
|
||||
|
||||
openFolder: (path: string) => Promise<void>;
|
||||
loadChildren: (dirPath: string) => Promise<void>;
|
||||
toggleExpanded: (dirPath: string) => void;
|
||||
openFile: (filePath: string) => Promise<void>;
|
||||
closeTab: (id: string) => void;
|
||||
setActiveTab: (id: string) => void;
|
||||
markTabClean: (id: string) => void;
|
||||
markTabDirty: (id: string) => void;
|
||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||
openFolderDialog: () => Promise<void>;
|
||||
openFileDialog: () => Promise<void>;
|
||||
saveActiveBuffer: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function entryToNode(entry: FileEntry): FileNode {
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
children: null, // lazy: start unloaded; loadChildren populates this
|
||||
};
|
||||
}
|
||||
|
||||
// Recursively find and update a directory node by path using immer's produce
|
||||
function updateNode(tree: FileNode, dirPath: string, updater: (node: FileNode) => void): boolean {
|
||||
if (tree.path === dirPath && tree.isDirectory) {
|
||||
updater(tree);
|
||||
return true;
|
||||
}
|
||||
if (tree.children) {
|
||||
for (const child of tree.children) {
|
||||
if (updateNode(child, dirPath, updater)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const useFileStore = create<FileState>()(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
tree: null,
|
||||
rootPath: null,
|
||||
expanded: new Set<string>(),
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
|
||||
openFolder: async (path) => {
|
||||
const result = await ipc.file.list(path);
|
||||
if (!result.ok) return;
|
||||
|
||||
const children: FileNode[] = result.data!.map(entryToNode);
|
||||
|
||||
set((s) => {
|
||||
s.tree = {
|
||||
name: path.split('/').filter(Boolean).pop() ?? path,
|
||||
path,
|
||||
isDirectory: true,
|
||||
children,
|
||||
loaded: true,
|
||||
};
|
||||
s.rootPath = path;
|
||||
});
|
||||
},
|
||||
|
||||
loadChildren: async (dirPath) => {
|
||||
// Find the node first
|
||||
const tree = useFileStore.getState().tree;
|
||||
if (!tree) return;
|
||||
|
||||
let found = false;
|
||||
updateNode(tree, dirPath, (node) => {
|
||||
if (node.loaded === true) return;
|
||||
found = true;
|
||||
});
|
||||
if (!found) return;
|
||||
|
||||
const result = await ipc.file.list(dirPath);
|
||||
if (!result.ok) return;
|
||||
|
||||
set((s) => {
|
||||
updateNode(s.tree!, dirPath, (node) => {
|
||||
node.children = result.data!.map(entryToNode);
|
||||
node.loaded = true;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
toggleExpanded: (dirPath) => {
|
||||
set((s) => {
|
||||
if (s.expanded.has(dirPath)) {
|
||||
s.expanded.delete(dirPath);
|
||||
} else {
|
||||
s.expanded.add(dirPath);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
openFile: async (filePath) => {
|
||||
// If already open, sync dirty flag from editor buffer and activate
|
||||
const existing = useFileStore.getState().openTabs.find((t) => t.id === filePath);
|
||||
if (existing) {
|
||||
const buffer = useEditorStore.getState().buffers.get(filePath);
|
||||
set((s) => {
|
||||
s.activeTabId = filePath;
|
||||
if (buffer) {
|
||||
const tab = s.openTabs.find((t) => t.id === filePath);
|
||||
if (tab) tab.dirty = buffer.dirty;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ipc.file.read(filePath);
|
||||
if (!result.ok) return;
|
||||
|
||||
const content = result.data!;
|
||||
const title = filePath.split('/').pop() ?? filePath;
|
||||
|
||||
// Open in editor store
|
||||
useEditorStore.getState().openBuffer(filePath, filePath, content);
|
||||
|
||||
set((s) => {
|
||||
s.openTabs.push({ id: filePath, path: filePath, title, dirty: false });
|
||||
s.activeTabId = filePath;
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
set((s) => {
|
||||
const idx = s.openTabs.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return;
|
||||
s.openTabs.splice(idx, 1);
|
||||
if (s.activeTabId === id) {
|
||||
s.activeTabId = idx > 0 ? s.openTabs[idx - 1]?.id ?? null : null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setActiveTab: (id) => {
|
||||
set((s) => {
|
||||
s.activeTabId = id;
|
||||
});
|
||||
},
|
||||
|
||||
markTabClean: (id) => {
|
||||
set((s) => {
|
||||
const tab = s.openTabs.find((t) => t.id === id);
|
||||
if (tab) tab.dirty = false;
|
||||
});
|
||||
},
|
||||
|
||||
markTabDirty: (id) => {
|
||||
set((s) => {
|
||||
const tab = s.openTabs.find((t) => t.id === id);
|
||||
if (tab) tab.dirty = true;
|
||||
});
|
||||
},
|
||||
|
||||
reorderTabs: (fromIndex, toIndex) => {
|
||||
set((s) => {
|
||||
const tabs = s.openTabs;
|
||||
const [moved] = tabs.splice(fromIndex, 1);
|
||||
tabs.splice(toIndex, 0, moved);
|
||||
});
|
||||
},
|
||||
|
||||
openFolderDialog: async () => {
|
||||
const result = await ipc.file.pickFolder();
|
||||
if (!result.ok || result.data === null) return;
|
||||
await useFileStore.getState().openFolder(result.data);
|
||||
},
|
||||
|
||||
openFileDialog: async () => {
|
||||
const result = await ipc.file.pickFile();
|
||||
if (!result.ok || result.data === null) return;
|
||||
await useFileStore.getState().openFile(result.data);
|
||||
},
|
||||
|
||||
saveActiveBuffer: async () => {
|
||||
const { activeTabId } = useFileStore.getState();
|
||||
if (!activeTabId) return false;
|
||||
|
||||
const buffer = useEditorStore.getState().buffers.get(activeTabId);
|
||||
if (!buffer) return false;
|
||||
|
||||
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
||||
if (!writeResult.ok) return false;
|
||||
|
||||
useEditorStore.getState().markSaved(activeTabId);
|
||||
useFileStore.getState().markTabClean(activeTabId);
|
||||
return true;
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: 'mc-file-store',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// Only persist the folder path; tree, expanded Set, and openTabs are
|
||||
// rebuilt on demand. The app re-opens the folder on startup.
|
||||
partialize: (state) => ({ rootPath: state.rootPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface PreviewState {
|
||||
source: string;
|
||||
scrollRatio: number;
|
||||
setSource: (s: string) => void;
|
||||
setScrollRatio: (r: number) => void;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending: string = '';
|
||||
|
||||
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||
source: '',
|
||||
scrollRatio: 0,
|
||||
setSource: (s) => {
|
||||
pending = s;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
set({ source: pending });
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
setScrollRatio: (r) => set({ scrollRatio: r }),
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { settingsSchema, type Settings } from '@/lib/validators';
|
||||
|
||||
type SettingsLeaf = keyof Omit<Settings, never>;
|
||||
|
||||
interface SettingsState extends Settings {
|
||||
setSetting: <K extends SettingsLeaf>(key: K, value: Settings[K]) => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS = settingsSchema.parse({});
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULTS,
|
||||
setSetting: (key, value) => set((s) => ({ ...s, [key]: value }) as Partial<SettingsState>),
|
||||
resetToDefaults: () => set(() => ({ ...DEFAULTS })),
|
||||
}),
|
||||
{
|
||||
name: 'mc-settings-store',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => {
|
||||
const { setSetting, resetToDefaults, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
const result = settingsSchema.safeParse(state);
|
||||
if (!result.success) {
|
||||
console.warn('[settings-store] invalid persisted state, resetting to defaults', result.error);
|
||||
useSettingsStore.setState({ ...DEFAULTS } as any);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -24,6 +24,14 @@
|
||||
--input: 0 0% 90%;
|
||||
--ring: 11 79% 51%;
|
||||
--radius: 0.5rem;
|
||||
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -46,6 +54,14 @@
|
||||
--border: 30 6% 15%;
|
||||
--input: 30 6% 15%;
|
||||
--ring: 11 79% 51%;
|
||||
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Mock window.electronAPI for tests
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: any;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||
window.electronAPI = {};
|
||||
}
|
||||
|
||||
// Mock matchMedia for next-themes (jsdom doesn't have it)
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Mock react-resizable-panels for jsdom environment
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
|
||||
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
|
||||
}
|
||||
|
||||
// Polyfill ResizeObserver for @radix-ui/react-use-size
|
||||
if (typeof window !== 'undefined' && !window.ResizeObserver) {
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
}
|
||||
|
||||
// Polyfill hasPointerCapture for @radix-ui/react-select (jsdom doesn't implement it)
|
||||
if (typeof window !== 'undefined' && !Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = function () {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// Polyfill scrollIntoView for @radix-ui/react-select (jsdom doesn't implement it)
|
||||
if (typeof window !== 'undefined' && !Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = function () {};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type IpcResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: { code: string; message: string } };
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface FileResult {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PdfOptions {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
format?: 'letter' | 'a4' | 'legal';
|
||||
margins?: { top: number; right: number; bottom: number; left: number };
|
||||
toc?: boolean;
|
||||
embedFonts?: boolean;
|
||||
}
|
||||
|
||||
export interface DocxOptions {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
template?: string;
|
||||
referenceDoc?: string;
|
||||
}
|
||||
|
||||
export interface HtmlOptions {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
standalone?: boolean;
|
||||
highlightStyle?: string;
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
outputPath: string;
|
||||
bytes: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface BatchItem {
|
||||
inputPath: string;
|
||||
outputPath: string;
|
||||
}
|
||||
|
||||
export interface BatchOptions {
|
||||
format: 'pdf' | 'docx' | 'html' | 'png';
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
export interface BatchResult {
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: Array<{ item: BatchItem; ok: boolean; error?: string }>;
|
||||
}
|
||||
+60
-20
@@ -1,3 +1,5 @@
|
||||
const animate = require('tailwindcss-animate')
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
@@ -6,42 +8,80 @@ module.exports = {
|
||||
'./src/renderer/index.html',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: { '2xl': '1400px' },
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
brand: {
|
||||
DEFAULT: '#e5461f',
|
||||
dark: '#c93a18',
|
||||
light: '#ff6b47',
|
||||
},
|
||||
success: '#1a7a56',
|
||||
warning: '#eab308',
|
||||
danger: '#ef4444',
|
||||
info: '#0ea5e9',
|
||||
warning: 'hsl(45 93% 47%)',
|
||||
danger: 'hsl(0 84% 60%)',
|
||||
info: 'hsl(199 89% 48%)',
|
||||
warm: {
|
||||
50: '#fafbfc',
|
||||
100: '#f5f5f5',
|
||||
200: '#e3e3e3',
|
||||
300: '#d1d1d1',
|
||||
400: '#b0b0b0',
|
||||
500: '#9a9696',
|
||||
600: '#7a7878',
|
||||
700: '#5a5858',
|
||||
800: '#4e4e4e',
|
||||
900: '#464646',
|
||||
950: '#0d0b09',
|
||||
50: '#fafbfc', 100: '#f5f5f5', 200: '#e3e3e3', 300: '#d1d1d1',
|
||||
400: '#b0b0b0', 500: '#9a9696', 600: '#7a7878', 700: '#5a5858',
|
||||
800: '#4e4e4e', 900: '#464646', 950: '#0d0b09',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: '0.75rem',
|
||||
md: '0.5rem',
|
||||
sm: '0.25rem',
|
||||
keyframes: {
|
||||
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
||||
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
plugins: [animate],
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { EditorPane } from '@/components/editor/EditorPane';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { CodeMirrorEditor } from '@/components/editor/CodeMirrorEditor';
|
||||
|
||||
vi.mock('@/components/editor/CodeMirrorEditor', () => ({
|
||||
CodeMirrorEditor: ({ initialContent }: { initialContent: string }) => (
|
||||
<div data-testid="codemirror-mock">{initialContent}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('EditorPane', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||
});
|
||||
|
||||
it('renders the empty state when no buffer is open', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<EditorPane />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the editor when a buffer is open', () => {
|
||||
useEditorStore.getState().openBuffer('b1', '/x.md', '# hello');
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<EditorPane />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByTestId('codemirror-mock')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('codemirror-mock')).toHaveTextContent('# hello');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { AppHeader } from '@/components/layout/AppHeader';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useCommandStore } from '@/stores/command-store';
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||
useCommandStore.setState({ handlers: {} });
|
||||
// Mirror what AppShell registers so dispatching actually does something.
|
||||
useCommandStore.getState().register('view.toggleSidebar', () => {
|
||||
useAppStore.getState().toggleSidebar();
|
||||
});
|
||||
useCommandStore.getState().register('view.togglePreview', () => {
|
||||
useAppStore.getState().togglePreview();
|
||||
});
|
||||
useCommandStore.getState().register('shortcuts.show', () => {});
|
||||
useCommandStore.getState().register('settings.open', () => {
|
||||
useAppStore.getState().openModal('settings');
|
||||
});
|
||||
useCommandStore.getState().register('help.about', () => {
|
||||
useAppStore.getState().openModal('about');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the app title', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles sidebar when sidebar button clicked', async () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||
await userEvent.click(btn);
|
||||
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles preview when preview button clicked', async () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: /toggle preview/i });
|
||||
await userEvent.click(btn);
|
||||
expect(useAppStore.getState().previewVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('disables-shortcut button dispatches shortcuts.show', async () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
const btn = screen.getByTestId('header-shortcuts');
|
||||
await userEvent.click(btn);
|
||||
// No observable side effect; the command was registered as a no-op.
|
||||
// The test passes if no error is thrown and the click is processed.
|
||||
expect(btn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('settings button opens settings modal', async () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'settings' });
|
||||
});
|
||||
|
||||
it('about button opens about modal', async () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppHeader />
|
||||
</ThemeProvider>
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^about$/i }));
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: 'about' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
// Mock react-resizable-panels for jsdom environment
|
||||
vi.mock('@/components/ui/resizable', () => ({
|
||||
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
|
||||
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
|
||||
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||
}));
|
||||
|
||||
describe('AppShell', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||
});
|
||||
|
||||
it('renders all shell surfaces when sidebar and preview are visible', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppShell />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no file selected/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides sidebar when sidebarVisible is false', () => {
|
||||
useAppStore.setState({ sidebarVisible: false });
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<AppShell />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatusBar } from '@/components/layout/StatusBar';
|
||||
|
||||
describe('StatusBar', () => {
|
||||
it('renders zero word count when no file is open', () => {
|
||||
render(<StatusBar />);
|
||||
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { TabBar } from '@/components/layout/TabBar';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
|
||||
describe('TabBar', () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({ openTabs: [], activeTabId: null });
|
||||
});
|
||||
|
||||
it('renders "No files open" when openTabs is empty', () => {
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a tab for each OpenTab', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText('foo.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('bar.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights the active tab with aria-current', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
const fooTab = screen.getByText('foo.md').closest('[role="tab"]');
|
||||
expect(fooTab).toHaveAttribute('aria-current', 'page');
|
||||
const barTab = screen.getByText('bar.md').closest('[role="tab"]');
|
||||
expect(barTab).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('clicking a tab calls setActiveTab', async () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
await act(async () => {
|
||||
screen.getByText('bar.md').closest('[role="tab"]')!.click();
|
||||
});
|
||||
expect(useFileStore.getState().activeTabId).toBe('/tmp/bar.md');
|
||||
});
|
||||
|
||||
it('clicking the close button calls closeTab and does not call setActiveTab separately', async () => {
|
||||
// Use a non-active tab to test that closeTab doesn't change activeTabId
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/bar.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
|
||||
const fooTab = screen.getByText('foo.md');
|
||||
const closeBtn = fooTab.closest('[role="tab"]')!.querySelector('button')!;
|
||||
await act(async () => {
|
||||
closeBtn.click();
|
||||
});
|
||||
|
||||
const state = useFileStore.getState();
|
||||
expect(state.openTabs.find((t) => t.id === '/tmp/foo.md')).toBeUndefined();
|
||||
// activeTabId stays on bar since we closed a non-active tab
|
||||
expect(state.activeTabId).toBe('/tmp/bar.md');
|
||||
});
|
||||
|
||||
it('renders a dirty indicator when dirty === true', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: true },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
const dirtyDot = screen.getByLabelText('Unsaved changes');
|
||||
expect(dirtyDot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reorders tabs when reorderTabs is called (drag end handler integration)', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/a.md', path: '/tmp/a.md', title: 'a.md', dirty: false },
|
||||
{ id: '/tmp/b.md', path: '/tmp/b.md', title: 'b.md', dirty: false },
|
||||
{ id: '/tmp/c.md', path: '/tmp/c.md', title: 'c.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/a.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
// Initial DOM order
|
||||
const initialOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||
expect(initialOrder).toEqual(['tab-/tmp/a.md', 'tab-/tmp/b.md', 'tab-/tmp/c.md']);
|
||||
|
||||
// Simulate what dnd-kit's onDragEnd would do: move a.md (idx 0) to idx 2
|
||||
act(() => {
|
||||
useFileStore.getState().reorderTabs(0, 2);
|
||||
});
|
||||
|
||||
const newOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||
expect(newOrder).toEqual(['tab-/tmp/b.md', 'tab-/tmp/c.md', 'tab-/tmp/a.md']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { Toolbar } from '@/components/layout/Toolbar';
|
||||
import { useCommandStore } from '@/stores/command-store';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
describe('Toolbar', () => {
|
||||
beforeEach(() => {
|
||||
useCommandStore.setState({ handlers: {} });
|
||||
useAppStore.setState({
|
||||
sidebarVisible: true,
|
||||
previewVisible: true,
|
||||
zenMode: false,
|
||||
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders formatting buttons', () => {
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all toolbar buttons', () => {
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByTestId('toolbar-open-file')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toolbar-open-folder')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toolbar-save')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toolbar-toggle-sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toolbar-toggle-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Open file button dispatches file.open', () => {
|
||||
const handler = vi.fn();
|
||||
useCommandStore.getState().register('file.open', handler);
|
||||
render(<Toolbar />);
|
||||
fireEvent.click(screen.getByTestId('toolbar-open-file'));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Open folder button dispatches file.openFolder', () => {
|
||||
const handler = vi.fn();
|
||||
useCommandStore.getState().register('file.openFolder', handler);
|
||||
render(<Toolbar />);
|
||||
fireEvent.click(screen.getByTestId('toolbar-open-folder'));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Save button dispatches file.save', () => {
|
||||
const handler = vi.fn();
|
||||
useCommandStore.getState().register('file.save', handler);
|
||||
render(<Toolbar />);
|
||||
fireEvent.click(screen.getByTestId('toolbar-save'));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Toggle sidebar button dispatches view.toggleSidebar and reflects aria-pressed', () => {
|
||||
const handler = vi.fn();
|
||||
useCommandStore.getState().register('view.toggleSidebar', handler);
|
||||
render(<Toolbar />);
|
||||
const btn = screen.getByTestId('toolbar-toggle-sidebar');
|
||||
expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(btn);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Toggle preview button dispatches view.togglePreview', () => {
|
||||
const handler = vi.fn();
|
||||
useCommandStore.getState().register('view.togglePreview', handler);
|
||||
render(<Toolbar />);
|
||||
fireEvent.click(screen.getByTestId('toolbar-toggle-preview'));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggle sidebar end-to-end: command flips appStore state, aria-pressed reflects it', () => {
|
||||
useCommandStore.getState().register('view.toggleSidebar', () => {
|
||||
useAppStore.getState().toggleSidebar();
|
||||
});
|
||||
render(<Toolbar />);
|
||||
const btn = screen.getByTestId('toolbar-toggle-sidebar');
|
||||
expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||
act(() => {
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||
expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('formatting buttons (bold, italic, etc.) are disabled (Phase 9 work)', () => {
|
||||
render(<Toolbar />);
|
||||
expect(screen.getByLabelText('Bold')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Italic')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Unordered list')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Ordered list')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Code')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Link')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AboutDialog } from '@/components/modals/AboutDialog';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
describe('AboutDialog', () => {
|
||||
beforeEach(() => {
|
||||
window.electronAPI = {
|
||||
app: {
|
||||
getVersion: vi.fn().mockResolvedValue('5.0.0'),
|
||||
openExternal: vi.fn().mockResolvedValue({ ok: true }),
|
||||
},
|
||||
} as any;
|
||||
// Reset store to about modal so dialog renders
|
||||
useAppStore.setState({ modal: { kind: 'about' } } as any);
|
||||
});
|
||||
|
||||
it('renders title and version', async () => {
|
||||
render(<AboutDialog />);
|
||||
expect(screen.getByText(/about markdownconverter/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/5\.0\.0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes when the close button is clicked', async () => {
|
||||
render(<AboutDialog />);
|
||||
// dialog-footer has the visible Close button; the X button also has sr-only "Close"
|
||||
const closeButtons = await screen.findAllByRole('button', { name: /close/i });
|
||||
const close = closeButtons[0];
|
||||
await userEvent.click(close);
|
||||
// closeModal sets modal.kind = null, so isOpen=false, Dialog unmounts
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ConfirmDialog } from '@/components/modals/ConfirmDialog';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({ modal: { kind: null } } as any);
|
||||
});
|
||||
|
||||
it('renders title, body, and confirm/cancel labels', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
title="Delete file?"
|
||||
body="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/delete file/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm and closes on confirm click', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ConfirmDialog title="T" body="B" onConfirm={onConfirm} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: null });
|
||||
});
|
||||
|
||||
it('destructive variant uses destructive button class', () => {
|
||||
render(
|
||||
<ConfirmDialog title="T" body="B" destructive onConfirm={() => {}} />
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(btn.className).toContain('bg-destructive');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ExportBatchDialog } from '@/components/modals/ExportBatchDialog';
|
||||
|
||||
describe('ExportBatchDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.electronAPI = {
|
||||
export: { batch: vi.fn().mockResolvedValue({ ok: true, data: { total: 2, succeeded: 2, failed: 0, results: [] } }) },
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('renders the file list passed via sourcePaths', () => {
|
||||
render(<ExportBatchDialog sourcePaths={['/a.md', '/b.md']} />);
|
||||
expect(screen.getByText('/a.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('/b.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selecting format and concurrency submits correct options', async () => {
|
||||
render(<ExportBatchDialog sourcePaths={['/a.md', '/b.md']} />);
|
||||
await userEvent.click(screen.getByRole('combobox', { name: /format/i }));
|
||||
await userEvent.click(screen.getByRole('option', { name: /^pdf$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
const call = (window.electronAPI.export.batch as any).mock.calls[0];
|
||||
expect(call[0]).toEqual([{ inputPath: '/a.md', outputPath: expect.any(String) }, { inputPath: '/b.md', outputPath: expect.any(String) }]);
|
||||
expect(call[1].format).toBe('pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ExportDocxDialog } from '@/components/modals/ExportDocxDialog';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
describe('ExportDocxDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.electronAPI = {
|
||||
export: { docx: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.docx' } }) },
|
||||
} as any;
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
|
||||
});
|
||||
|
||||
it('renders with standard template selected by default', () => {
|
||||
render(<ExportDocxDialog sourcePath="/test.md" />);
|
||||
expect(screen.getByText(/export to docx/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: /template/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submitting with default options sends template=standard', async () => {
|
||||
render(<ExportDocxDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
const call = (window.electronAPI.export.docx as any).mock.calls[0][0];
|
||||
expect(call.template).toBe('standard');
|
||||
});
|
||||
|
||||
it('selecting "modern" template sends template=modern', async () => {
|
||||
render(<ExportDocxDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('combobox', { name: /template/i }));
|
||||
await userEvent.click(screen.getByRole('option', { name: /modern/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
const call = (window.electronAPI.export.docx as any).mock.calls[0][0];
|
||||
expect(call.template).toBe('modern');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ExportHtmlDialog } from '@/components/modals/ExportHtmlDialog';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
describe('ExportHtmlDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.electronAPI = {
|
||||
export: { html: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.html' } }) },
|
||||
} as any;
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
|
||||
});
|
||||
|
||||
it('renders with default github highlight style', () => {
|
||||
render(<ExportHtmlDialog sourcePath="/test.md" />);
|
||||
expect(screen.getByText(/export to html/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles standalone and submits with chosen highlight', async () => {
|
||||
render(<ExportHtmlDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /standalone/i }));
|
||||
await userEvent.click(screen.getByRole('combobox', { name: /highlight/i }));
|
||||
await userEvent.click(screen.getByRole('option', { name: /monokai/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
const call = (window.electronAPI.export.html as any).mock.calls[0][0];
|
||||
expect(call.standalone).toBe(false);
|
||||
expect(call.highlightStyle).toBe('monokai');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ExportPdfDialog } from '@/components/modals/ExportPdfDialog';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
describe('ExportPdfDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
window.electronAPI = {
|
||||
export: {
|
||||
pdf: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.pdf', bytes: 1024, durationMs: 100 } }),
|
||||
},
|
||||
} as any;
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
|
||||
});
|
||||
|
||||
it('renders with default PDF options from settings', () => {
|
||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||
expect(screen.getByText(/export to pdf/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: /format/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles ASCII tables and submits merged options', async () => {
|
||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
const call = (window.electronAPI.export.pdf as any).mock.calls[0][0];
|
||||
expect(call.renderTablesAsAscii).toBe(true);
|
||||
expect(call.format).toBe('a4');
|
||||
});
|
||||
|
||||
it('renders an error banner when IPC fails', async () => {
|
||||
(window.electronAPI.export.pdf as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'Pandoc not found' } });
|
||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ModalLayer } from '@/components/modals/ModalLayer';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
describe('ModalLayer', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppStore.setState({ modal: { kind: null } } as any);
|
||||
});
|
||||
|
||||
it('renders nothing when modal is null', () => {
|
||||
const { container } = render(<ModalLayer />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders AboutDialog when kind is "about"', () => {
|
||||
useAppStore.getState().openModal('about');
|
||||
render(<ModalLayer />);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches from about to settings when modal kind changes', () => {
|
||||
useAppStore.getState().openModal('about');
|
||||
const { rerender } = render(<ModalLayer />);
|
||||
expect(screen.getByText(/about markdownconverter/i)).toBeInTheDocument();
|
||||
useAppStore.getState().openModal('settings');
|
||||
rerender(<ModalLayer />);
|
||||
expect(screen.queryByText(/about markdownconverter/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SettingsSheet } from '@/components/modals/SettingsSheet';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
describe('SettingsSheet', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
});
|
||||
|
||||
it('renders 5 tab triggers', () => {
|
||||
render(<SettingsSheet />);
|
||||
expect(screen.getByRole('tab', { name: /editor/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /theme/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /export/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /plugins/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /about/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('editor tab is open by default and shows font size', () => {
|
||||
render(<SettingsSheet />);
|
||||
expect(screen.getByText(/font size/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('theme tab shows theme radio group', async () => {
|
||||
render(<SettingsSheet />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: /theme/i }));
|
||||
expect(screen.getByText(/accent color/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('export tab shows ascii toggle and template picker', async () => {
|
||||
render(<SettingsSheet />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: /export/i }));
|
||||
expect(screen.getByText(/render tables as ascii by default/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/default docx template/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('plugins tab shows coming soon message', async () => {
|
||||
render(<SettingsSheet />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: /plugins/i }));
|
||||
expect(screen.getByText(/coming soon/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('about tab shows version', async () => {
|
||||
render(<SettingsSheet />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: /about/i }));
|
||||
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reset to defaults button clears all settings', async () => {
|
||||
useSettingsStore.getState().setSetting('fontSize', 22);
|
||||
render(<SettingsSheet />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /reset/i }));
|
||||
expect(useSettingsStore.getState().fontSize).toBe(14);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { WelcomeDialog } from '@/components/modals/WelcomeDialog';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
describe('WelcomeDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useAppStore.setState({ modal: { kind: null } } as any);
|
||||
});
|
||||
|
||||
it('renders a heading and quick-start content', () => {
|
||||
render(<WelcomeDialog />);
|
||||
expect(screen.getByRole('heading', { name: /welcome/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/open a folder/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closing without the checkbox does not dismiss future welcome dialogs', async () => {
|
||||
render(<WelcomeDialog />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
expect(useSettingsStore.getState().welcomeDismissed).toBe(false);
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: null });
|
||||
});
|
||||
|
||||
it('checking "don\'t show again" persists the flag', async () => {
|
||||
render(<WelcomeDialog />);
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /don't show again/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
expect(useSettingsStore.getState().welcomeDismissed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MermaidLazy } from '@/components/preview/MermaidLazy';
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn().mockResolvedValue('<svg></svg>'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MermaidLazy', () => {
|
||||
it('renders an svg after mermaid resolves', async () => {
|
||||
render(<MermaidLazy code="graph TD; A-->B" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mermaid-output').innerHTML).toContain('<svg');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error message if mermaid throws', async () => {
|
||||
const mermaid = (await import('mermaid')).default as any;
|
||||
mermaid.render.mockRejectedValueOnce(new Error('mermaid failed'));
|
||||
render(<MermaidLazy code="bad code" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/mermaid failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
|
||||
describe('PreviewPane', () => {
|
||||
beforeEach(() => {
|
||||
usePreviewStore.setState({ scrollRatio: 0, source: '' });
|
||||
});
|
||||
|
||||
it('renders empty state when no source', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<PreviewPane />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByText(/nothing to preview/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders markdown when source is set', () => {
|
||||
usePreviewStore.setState({ source: '# Hello' });
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<PreviewPane />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 1, name: /hello/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { FileTree } from '@/components/sidebar/FileTree';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
|
||||
vi.mock('@/lib/ipc', () => ({
|
||||
ipc: {
|
||||
file: {
|
||||
list: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||
read: vi.fn().mockResolvedValue({ ok: true, data: '' }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FileTree', () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||
});
|
||||
|
||||
it('renders nothing when tree is null', () => {
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<FileTree />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders root children after openFolder', async () => {
|
||||
useFileStore.setState({
|
||||
tree: {
|
||||
name: 'project',
|
||||
path: '/project',
|
||||
isDirectory: true,
|
||||
loaded: true,
|
||||
children: [
|
||||
{ name: 'src', path: '/project/src', isDirectory: true, children: null, loaded: false },
|
||||
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
|
||||
],
|
||||
},
|
||||
rootPath: '/project',
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<FileTree />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('src')).toBeInTheDocument();
|
||||
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows folder as expanded after toggle and reveals children', async () => {
|
||||
useFileStore.setState({
|
||||
tree: {
|
||||
name: 'project',
|
||||
path: '/project',
|
||||
isDirectory: true,
|
||||
loaded: true,
|
||||
children: [
|
||||
{
|
||||
name: 'src',
|
||||
path: '/project/src',
|
||||
isDirectory: true,
|
||||
loaded: true,
|
||||
children: [
|
||||
{ name: 'index.ts', path: '/project/src/index.ts', isDirectory: false, children: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
rootPath: '/project',
|
||||
expanded: new Set(['/project/src']),
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<FileTree />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('index.ts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights the active tab path', () => {
|
||||
useFileStore.setState({
|
||||
tree: {
|
||||
name: 'project',
|
||||
path: '/project',
|
||||
isDirectory: true,
|
||||
loaded: true,
|
||||
children: [
|
||||
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
|
||||
],
|
||||
},
|
||||
rootPath: '/project',
|
||||
activeTabId: '/project/README.md',
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||
<FileTree />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /README.md/i });
|
||||
expect(btn).toHaveClass('bg-accent');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user