mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb4e874809 | ||
|
|
36422a9ab3 | ||
|
|
7eb90d467a | ||
|
|
809c266e54 | ||
|
|
2e3f4dda0f | ||
|
|
21a00a53a4 | ||
|
|
f2398e6e1a | ||
|
|
6b564a4569 | ||
|
|
2389d4f297 | ||
|
|
50f4f62575 | ||
|
|
9ef5317d71 | ||
|
|
cf6b6817b9 | ||
|
|
e25a5e1d75 | ||
|
|
c5d4b113bd | ||
|
|
1715e26e5f | ||
|
|
81484a8d33 | ||
|
|
6df1389cc8 | ||
|
|
aeadde5f40 | ||
|
|
74dde8d6d1 | ||
|
|
cd57f34c36 | ||
|
|
4da701deb5 | ||
|
|
41c68fcf82 | ||
|
|
9b899b5205 | ||
|
|
691b00a2b8 | ||
|
|
ade6c115b8 | ||
|
|
cc33e6c7a8 | ||
|
|
59fd9b8646 | ||
|
|
d87f175212 | ||
|
|
ef88b6343b | ||
|
|
e8c9f95d25 | ||
|
|
f85c1a8107 | ||
|
|
b1b9aa3727 | ||
|
|
77ad2fdb9d | ||
|
|
c62070304f | ||
|
|
9f4bfbdfee | ||
|
|
2e41b59da5 | ||
|
|
57c8f92f42 | ||
|
|
9da4b96f76 | ||
|
|
5b81988f30 | ||
|
|
a5da5d19bc | ||
|
|
74ff6afb19 | ||
|
|
5ef1610873 | ||
|
|
69afd5fc54 | ||
|
|
9e315af1b9 | ||
|
|
78914a4d65 | ||
|
|
3c4cc985fb | ||
|
|
09d9f6bdd1 | ||
|
|
f36d918871 | ||
|
|
ab16998922 | ||
|
|
58ca3014d8 | ||
|
|
7c1a79c724 |
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [master, react-electron]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [master, react-electron]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -24,5 +24,8 @@ jobs:
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Run renderer tests
|
||||
run: npm run test:renderer
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run: npm test
|
||||
|
||||
- name: Build Linux packages
|
||||
run: npm run build:linux-ci -- --publish=never
|
||||
run: npm run build:linux-ci -- --publish=always
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -77,13 +77,13 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: code-signing-cert.pfx
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: npm run build:win-signed -- --publish=never
|
||||
run: npm run build:win-signed -- --publish=always
|
||||
|
||||
- name: Build Windows packages (unsigned)
|
||||
if: ${{ env.CERT_AVAILABLE != 'true' }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: npm run build:win-unsigned -- --publish=never
|
||||
run: npm run build:win-unsigned -- --publish=always
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -94,8 +94,42 @@ jobs:
|
||||
dist/*.zip
|
||||
retention-days: 5
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download external tools (pandoc)
|
||||
run: node scripts/download-tools.js
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build macOS packages (unsigned)
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: npm run build:mac -- --publish=always
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-artifacts
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.zip
|
||||
retention-days: 5
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-windows]
|
||||
needs: [build-linux, build-windows, build-macos]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -115,8 +149,28 @@ jobs:
|
||||
name: windows-artifacts
|
||||
path: dist
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: macos-artifacts
|
||||
path: dist
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: dist/*
|
||||
|
||||
- name: Mirror artifacts to ConcreteInfo update feed
|
||||
if: env.CONCRETEINFO_DEPLOY_HOOK != ''
|
||||
env:
|
||||
CONCRETEINFO_DEPLOY_HOOK: ${{ secrets.CONCRETEINFO_DEPLOY_HOOK }}
|
||||
run: |
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.CONCRETEINFO_DEPLOY_HOOK }}" \
|
||||
-F "version=${GITHUB_REF_NAME#v}" \
|
||||
-F "artifacts=@dist/latest-mac.yml" \
|
||||
-F "artifacts=@dist/latest-linux.yml" \
|
||||
-F "artifacts=@dist/latest-windows.yml" \
|
||||
https://updates.concreteinfo.co.in/api/v1/ingest
|
||||
|
||||
@@ -5,6 +5,15 @@ All notable changes to markdown-converter will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [5.1.0] - 2026-06-08
|
||||
|
||||
### Added
|
||||
- Auto-update via electron-updater against GitHub Releases (default) or ConcreteInfo self-hosted feed.
|
||||
- Light, skippable first-run wizard: theme, update channel, starter template.
|
||||
- One-shot v4.4.1 → v5 settings migration with backup at `settings.v4.bak.json`.
|
||||
- Local crash dump capture (cap 20, auto-prune) and a CrashReportModal.
|
||||
- "Updates" section in Settings: channel picker, Check now, auto-check toggle.
|
||||
|
||||
## [5.0.0] - 2026-06-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# CLAUDE.md — MarkdownConverter (react-electron)
|
||||
|
||||
> General code-quality, TypeScript, git, security, and testing standards are in the **global CLAUDE.md**. This file holds project- and branch-specific notes.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Electron desktop app for Markdown editing and universal file conversion powered by Pandoc. Cross-platform (Win/macOS/Linux). Features: multi-tab editor with live preview, 25+ themes, PDF viewer/editor (merge/split/compress/rotate/watermark/password), export to 20+ formats (PDF/DOCX/ODT/EPUB/HTML/LaTeX/RTF/PPTX), batch conversion, syntax highlighting, diagram support (Mermaid), Git integration, plugin system, and auto-updater.
|
||||
|
||||
- **Version:** 5.0.1
|
||||
- **License:** MIT
|
||||
- **App ID:** `com.concreteinfo.markdownconverter`
|
||||
|
||||
## Branch Specifics
|
||||
|
||||
This is the **React rewrite branch** — the renderer has been rebuilt with React 19 + TypeScript + Tailwind CSS + shadcn/ui, replacing the vanilla JS renderer from master. The main process remains vanilla CommonJS JavaScript. A dual dev workflow runs Vite (renderer) and Electron (main) concurrently.
|
||||
|
||||
Key differences from master:
|
||||
- Renderer: React 19 + TypeScript (TSX) instead of vanilla JS DOM manipulation
|
||||
- Bundler: Vite for the renderer (`vite.renderer.config.ts`)
|
||||
- UI: shadcn/ui (Radix primitives + Tailwind) instead of hand-rolled CSS
|
||||
- State: Zustand 5 stores instead of global mutable state
|
||||
- Security: `contextIsolation: true` + `nodeIntegration: false` (preload with channel whitelisting) instead of master's open renderer
|
||||
- Build: two-stage (Vite build renderer, then electron-builder packages) instead of single electron-builder pass
|
||||
- Testing: dual test runners — Vitest (renderer/React/TS) + Jest (main process/JS)
|
||||
- Main process is now in `src/main/` (modular) instead of flat `src/main.js`
|
||||
- Adds auto-updater via `electron-updater` with GitHub Releases + self-hosted feed support
|
||||
- Adds React Hook Form + Zod for form validation, Lucide React icons, Motion for animations
|
||||
- Legacy sidebar modules (`src/sidebar/*.js`) still exist alongside new React sidebar (`src/renderer/components/sidebar/`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Process (`src/main/`)
|
||||
Modular structure (improvement over master's monolith):
|
||||
- `src/main/index.js` — IPC handlers, Pandoc invocation, export logic (~3,500 lines)
|
||||
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib`
|
||||
- `src/main/GitOperations.js` — Git operations via `simple-git`
|
||||
- `src/main/store.js` — Custom JSON settings store (NOT electron-store)
|
||||
- `src/main/window/index.js` — BrowserWindow creation with three-mode loading (dev/prod/packaged)
|
||||
- `src/main/menu/` — Application menu definitions
|
||||
- `src/main/ipc/` — Crash handlers, updater handlers
|
||||
- `src/main/updater/` — Auto-update service, feed config, migration runner
|
||||
- `src/main/files/` — File operation modules
|
||||
- `src/main/word-template/` — Word template export
|
||||
|
||||
### Preload (`src/preload.js`)
|
||||
Properly isolated. Uses `contextBridge` with **channel whitelisting** (`ALLOWED_SEND_CHANNELS`, `ALLOWED_RECEIVE_CHANNELS` arrays). The renderer has no direct Node access.
|
||||
|
||||
### Renderer (`src/renderer/`)
|
||||
React 19 + TypeScript application bundled by Vite:
|
||||
- `src/renderer/App.tsx` — Root component: assembles AppShell, modals, command palette, toaster
|
||||
- `src/renderer/components/layout/AppShell.tsx` — Three-panel resizable layout (sidebar | editor | preview)
|
||||
- `src/renderer/stores/` — Zustand stores: `app-store`, `editor-store`, `file-store`, `preview-store`, `settings-store`, `command-store`
|
||||
- `src/renderer/hooks/` — Custom hooks: `use-shortcut`, `use-file-shortcuts`, `use-menu-action`, `use-scroll-sync`, `use-zen-mode`, `use-export-source`
|
||||
- `src/renderer/components/modals/` — 30+ modal dialogs as React components
|
||||
- `src/renderer/components/editor/` — CodeMirror 6 editor React wrapper
|
||||
- `src/renderer/components/preview/` — Markdown renderer with Mermaid lazy loading
|
||||
- `src/renderer/components/sidebar/` — React sidebar panels (FileTree, GitStatus, Outline, Snippets, Templates)
|
||||
- `src/renderer/components/ui/` — shadcn/ui primitives (button, dialog, sheet, select, tabs, etc.)
|
||||
- `src/renderer/lib/` — Utilities: typed IPC wrapper (`ipc.ts`), export modules, validators
|
||||
- `src/renderer/types/` — TypeScript declarations: `electron.d.ts` (window.electronAPI), `ipc.ts` (IPC types)
|
||||
|
||||
### Security Model
|
||||
- **`contextIsolation: true` + `nodeIntegration: false`** (properly secured, unlike master)
|
||||
- Preload with explicit channel whitelisting
|
||||
- TypeScript declarations ensure type-safe IPC
|
||||
- Pandoc invoked via `execFile` (not `exec`)
|
||||
- Permission handler: only `clipboard-read`/`clipboard-write` allowed
|
||||
- ESLint enforces `no-eval`, `no-implied-eval`, `no-new-func`
|
||||
- CSP in `index.html` restricts script/style/img/font/connect sources
|
||||
|
||||
### Plugin System (`src/plugins/`)
|
||||
Unchanged from master. Manifest-based discovery, built-in `writing-studio` plugin.
|
||||
|
||||
## System Dependencies
|
||||
|
||||
| Dependency | Required | Notes |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x; Vite 8 requires Node 18+ |
|
||||
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. |
|
||||
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` |
|
||||
| **MiKTeX / TeX Live** | Optional | LaTeX PDF export; MiKTeX PATH injected on Windows |
|
||||
| **ImageMagick** | Optional | Linux image conversion; deb dependency |
|
||||
| **LibreOffice** | Optional | Enhanced document conversion; deb dependency |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev mode: Vite dev server (port 5173) + Electron (concurrently)
|
||||
npm run dev:renderer # Vite dev server only (port 5173)
|
||||
npm run dev:electron # Electron only (waits for Vite on tcp:5173)
|
||||
npm start # Launch Electron app (prod mode, requires built renderer)
|
||||
npm run preview # Build renderer then launch Electron
|
||||
npm test # Jest — main process tests (vanilla JS)
|
||||
npm run test:renderer # Vitest — renderer tests (React/TS)
|
||||
npm run lint # ESLint check
|
||||
npm run lint:fix # ESLint auto-fix
|
||||
npm run format # Prettier write
|
||||
npm run format:check # Prettier check
|
||||
npm run download-tools # Download Pandoc binaries
|
||||
npm run generate-icons # Generate app icons
|
||||
```
|
||||
|
||||
**Dev workflow:** `npm run dev` starts Vite (renderer HMR on `:5173`) and Electron concurrently. The main process loads from `http://localhost:5173` in dev mode.
|
||||
|
||||
## Build & Package
|
||||
|
||||
**Two-stage build process:**
|
||||
1. `npm run build:renderer` — Vite builds renderer to `dist/renderer/`
|
||||
2. `npm run build` — electron-builder packages main process + preload + built renderer
|
||||
|
||||
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json`.
|
||||
|
||||
| Target | Platforms |
|
||||
|---|---|
|
||||
| `npm run build:win` | Windows: NSIS + portable + zip (x64) |
|
||||
| `npm run build:mac` | macOS: dmg + zip (x64 + arm64) |
|
||||
| `npm run build:linux` | Linux: deb + AppImage + snap |
|
||||
|
||||
**Packaged files:** `src/main/**`, `src/preload.js`, `src/plugins/**`, `package.json`. Renderer built output copied to resources as `renderer/`.
|
||||
|
||||
**Bundled with builds:** Pandoc binary per platform, FFmpeg (asarUnpacked).
|
||||
|
||||
**Output:** `dist/` directory.
|
||||
|
||||
**Auto-updater:** `electron-updater` with GitHub Releases (default) and optional ConcreteInfo self-hosted feed.
|
||||
|
||||
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
|
||||
|
||||
## Project Conventions / Gotchas
|
||||
|
||||
- **Dual-process architecture.** Main process is vanilla CommonJS JavaScript (`src/main/`). Renderer is React 19 + TypeScript + Tailwind (`src/renderer/`). They are separate build targets.
|
||||
- **Vite for renderer only.** Main process is NOT bundled — Electron loads `src/main/index.js` directly. Do not add TypeScript to main process files.
|
||||
- **Tailwind + shadcn/ui.** Use shadcn/ui components (`src/renderer/components/ui/`) for all UI primitives. Custom theme in `tailwind.config.js`. Path alias `@` maps to `src/renderer/`. Brand color: `#e5461f`.
|
||||
- **Zustand for state.** All renderer state lives in `src/renderer/stores/`. Each store is a separate file. Use immer for immutable updates.
|
||||
- **Typed IPC.** `src/renderer/types/electron.d.ts` declares `window.electronAPI`. `src/renderer/lib/ipc.ts` provides type-safe wrappers. When adding new IPC channels, update both the preload whitelist AND the TypeScript declarations.
|
||||
- **Legacy + new sidebar.** `src/sidebar/*.js` (vanilla JS) and `src/renderer/components/sidebar/` (React TSX) both exist. New sidebar features go in the React version.
|
||||
- **Pandoc is external.** Must be present for non-HTML/PDF exports. Download via `npm run download-tools` or install system-wide.
|
||||
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
|
||||
- **PDF rendering:** `pdfjs-dist` (viewer). **PDF manipulation:** `pdf-lib` in main process.
|
||||
- **Editor:** CodeMirror 6, configured in `src/editor/codemirror-setup.js`, wrapped as React component in `src/renderer/components/editor/CodeMirrorEditor.tsx`.
|
||||
- **Tests:** Jest (main process, `tests/**/*.test.js`, 15% threshold) + Vitest (renderer, `tests/**/*.{test,spec}.{ts,tsx}`, v8 coverage on `src/renderer/`).
|
||||
- **ESLint flat config** with ECMAScript 2022. Prettier: 2-space, single quotes, semicolons, 100-char width.
|
||||
- **No tsconfig.json at root** — TypeScript is renderer-only, handled by Vite. Do not add `tsconfig.json` for the main process.
|
||||
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
|
||||
- **Single instance lock** via `app.requestSingleInstanceLock()`.
|
||||
@@ -140,6 +140,21 @@ Open PDF files directly in MarkdownConverter:
|
||||
- Rotate pages left or right
|
||||
- Close PDF to return to editor
|
||||
|
||||
## Distribution & Updates
|
||||
|
||||
v5.x uses `electron-updater` against two feeds:
|
||||
|
||||
- **GitHub Releases** (default, public): the public release at `https://github.com/amitwh/markdown-converter/releases`. The CI workflow publishes `latest-{mac,linux,windows}.yml` on every tag.
|
||||
- **ConcreteInfo self-hosted** (opt-in for enterprise deployments): `https://updates.concreteinfo.co.in/v5/`. CI mirrors artifacts on every release when `CONCRETEINFO_DEPLOY_HOOK` is set as a repository secret.
|
||||
|
||||
Users switch the feed in **Settings → Updates → Update channel**. Auto-check is enabled by default; disable it in the same panel.
|
||||
|
||||
### Manual mirror
|
||||
|
||||
```bash
|
||||
CONCRETEINFO_DEPLOY_HOOK=... npm run publish:concreteinfo -- 5.1.0
|
||||
```
|
||||
|
||||
## Open Source
|
||||
|
||||
MarkdownConverter is 100% open-source. All dependencies are permissively licensed:
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,270 @@
|
||||
# Packaging — All-Platform Build + GitHub Releases
|
||||
|
||||
**Goal:** Make `npm run dist:all` produce Linux + Windows + macOS artifacts, and have the existing `.github/workflows/release.yml` create a GitHub Release on tag push with those artifacts. No code signing in this iteration.
|
||||
|
||||
**Scope (user decisions 2026-06-06):**
|
||||
- Target platforms: **all three** (Linux .deb/AppImage/snap, Windows NSIS/portable/zip, macOS dmg/zip)
|
||||
- Distribution: **GitHub Releases** (no auto-update; static artifacts only)
|
||||
- Code signing: **skip for now** (unsigned binaries; Windows SmartScreen will warn, macOS Gatekeeper will quarantine)
|
||||
|
||||
**Architecture:** This is mostly about completing the existing scaffold. `electron-builder` 26.0.12, `scripts/download-tools.js`, `scripts/generate-icons.js`, the `package.json` scripts, and `release.yml` are all already in place. We need to (1) close a real bug in the `files` config, (2) add the macOS job to the workflow, and (3) declare macOS targets in the package config.
|
||||
|
||||
**Tech stack:** electron-builder 26.0.12, GitHub Actions `ubuntu-latest`/`windows-latest`/`macos-latest` runners, softprops/action-gh-release@v2 for the release creation.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Fix the `files` config in `package.json`
|
||||
|
||||
The current `files: ["src/**/*", ...]` matches `src/renderer/**/*.tsx` and ships dev source into the asar. The shipped renderer should be the built `dist/renderer/**/*` output, not TS source. asarUnpack for ffmpeg-static is correct and stays.
|
||||
|
||||
**Files:** `package.json` (build.files, build.extraResources)
|
||||
|
||||
### Step 1.1 — Replace `files` block
|
||||
|
||||
```json
|
||||
"files": [
|
||||
"src/main/**/*",
|
||||
"src/preload.js",
|
||||
"src/plugins/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist/renderer",
|
||||
"to": "renderer"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `src/main/**/*` — main process source (entry point + all main-process modules)
|
||||
- `src/preload.js` — single preload file at the root of `src/`
|
||||
- `src/plugins/**/*` — built-in plugins (referenced from main process; the `extraFiles: []` line in the current config is empty, so this is the right pattern)
|
||||
- `package.json` — required for the asar `app.asar/package.json` lookup electron does at runtime
|
||||
- `dist/renderer` moves to `extraResources` because it should live alongside the asar (not inside it) — the asar is sealed and we want the renderer to be a separately-updatable resource path. Production main loads it via `loadFile(path.join(__dirname, '../../dist/renderer/index.html'))` which, in the packaged app, resolves to `process.resourcesPath/renderer/index.html`. **This requires changing the loadFile path in `src/main/window/index.js` too** — see Task 4.
|
||||
|
||||
### Step 1.2 — Verify
|
||||
|
||||
- `npm run build:linux` should produce a working .AppImage
|
||||
- Inside the .AppImage (extract with `--appimage-extract`), check `resources/` has `app.asar` and a `renderer/` directory containing `index.html` and `assets/`
|
||||
- Inside `app.asar`, check `src/main/index.js` is present and `src/renderer/` is absent
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Add macOS targets + icon to `package.json` build config
|
||||
|
||||
**Files:** `package.json`
|
||||
|
||||
### Step 2.1 — Replace the existing `mac` block
|
||||
|
||||
```json
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"identity": null,
|
||||
"target": [
|
||||
{ "target": "dmg", "arch": ["x64", "arm64"] },
|
||||
{ "target": "zip", "arch": ["x64", "arm64"] }
|
||||
],
|
||||
"icon": "assets/icon.icns",
|
||||
"darkModeSupport": true,
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": null
|
||||
}
|
||||
```
|
||||
|
||||
- `identity: null` is intentional for unsigned builds
|
||||
- `darkModeSupport: true` — the app already supports dark mode; this is just metadata
|
||||
- `hardenedRuntime: false` and `gatekeeperAssess: false` — without signing, these are the only way the build succeeds
|
||||
- `entitlements: null` — no entitlements file; some features (jit) won't work but Electron's main process doesn't need them in this app
|
||||
- **`.icns` is missing.** For this iteration we'll use the default Electron icon and add a follow-up to generate one. The build will succeed without it but the produced .app will have the default icon.
|
||||
|
||||
### Step 2.2 — Generate a placeholder `.icns` (optional, can be deferred)
|
||||
|
||||
If we want a proper icon, the path is:
|
||||
1. `npm run generate-icons` to produce `assets/icons/*.png` (already done)
|
||||
2. Use `png2icns` (Linux) or `iconutil` (mac) to bundle them into `assets/icon.icns`
|
||||
3. We don't have either on this Linux container; defer to a follow-up plan
|
||||
|
||||
For this iteration: ship without `.icns` and accept the default icon.
|
||||
|
||||
### Step 2.3 — Add `publish` config
|
||||
|
||||
```json
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "amitwh",
|
||||
"repo": "markdown-converter",
|
||||
"releaseType": "release"
|
||||
}
|
||||
```
|
||||
|
||||
Even without auto-update, this tells `electron-builder` to write `latest-mac.yml` / `latest-linux.yml` next to the artifacts. Useful for future auto-update wiring. CI passes `--publish=never` to override.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Add macOS job to `.github/workflows/release.yml`
|
||||
|
||||
**Files:** `.github/workflows/release.yml`
|
||||
|
||||
### Step 3.1 — Add a third job between `build-windows` and `release`
|
||||
|
||||
Insert after the `build-windows` job (around line 96):
|
||||
|
||||
```yaml
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download external tools (pandoc)
|
||||
run: node scripts/download-tools.js
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build macOS packages (unsigned)
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
run: npm run build:mac -- --publish=never
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-artifacts
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.zip
|
||||
retention-days: 5
|
||||
```
|
||||
|
||||
### Step 3.2 — Wire macOS into the `release` job's `needs` and downloads
|
||||
|
||||
Change the `release` job:
|
||||
|
||||
```yaml
|
||||
release:
|
||||
needs: [build-linux, build-windows, build-macos]
|
||||
...
|
||||
```
|
||||
|
||||
And add a third download step (after the Windows download):
|
||||
|
||||
```yaml
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: macos-artifacts
|
||||
path: dist
|
||||
```
|
||||
|
||||
`continue-on-error: true` is intentional — a failed macOS build shouldn't block a release of working Linux+Windows artifacts.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Update `src/main/window/index.js` for the resource path
|
||||
|
||||
**Files:** `src/main/window/index.js`
|
||||
|
||||
### Step 4.1 — Update the loadFile path
|
||||
|
||||
The current code (line 35):
|
||||
```js
|
||||
const prodPath = path.join(__dirname, '../../dist/renderer/index.html');
|
||||
```
|
||||
|
||||
Inside the packaged app, `__dirname` points into the asar. `process.resourcesPath` points to the directory holding `app.asar` + the `renderer/` extraResources dir. Change to:
|
||||
|
||||
```js
|
||||
const rendererIndex = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'renderer', 'index.html')
|
||||
: path.join(__dirname, '../../dist/renderer/index.html');
|
||||
win.loadFile(rendererIndex);
|
||||
```
|
||||
|
||||
This needs to happen after the dev/prod branch is decided. The existing `if (!app.isPackaged && devServerUrl)` block already handles dev mode; the else branch needs this update.
|
||||
|
||||
### Step 4.2 — Add a guard for missing renderer in prod
|
||||
|
||||
```js
|
||||
if (app.isPackaged) {
|
||||
try {
|
||||
fs.accessSync(path.join(process.resourcesPath, 'renderer', 'index.html'));
|
||||
} catch {
|
||||
console.error('[WINDOW] Renderer not found at', process.resourcesPath, '— did you run `npm run build:renderer` before packaging?');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Failure mode: someone runs `npm run build` without first building the renderer. Without this guard, the window opens blank and the user has no idea why.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Test on this Linux box
|
||||
|
||||
### Step 5.1 — Local verification
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run download-tools # idempotent; bin/linux/pandoc already exists
|
||||
npm run build:renderer
|
||||
npm run build:linux # produces .deb, .AppImage in dist/
|
||||
```
|
||||
|
||||
Inspect the .AppImage:
|
||||
```bash
|
||||
chmod +x dist/*.AppImage
|
||||
./dist/MarkdownConverter-*.AppImage --appimage-extract
|
||||
ls squashfs-root/resources/
|
||||
# Expect: app.asar renderer/
|
||||
ls squashfs-root/resources/renderer/
|
||||
# Expect: index.html assets/
|
||||
```
|
||||
|
||||
### Step 5.2 — Run the produced app
|
||||
|
||||
```bash
|
||||
./dist/MarkdownConverter-*.AppImage
|
||||
```
|
||||
|
||||
The app should:
|
||||
- Launch with the AppShell, header, sidebar
|
||||
- Allow opening a folder / file
|
||||
- Export to PDF/DOCX/HTML work (pandoc bundled)
|
||||
|
||||
### Step 5.3 — Tag + push to test the release workflow (optional)
|
||||
|
||||
Only do this if the user wants to verify the full pipeline. A dry-run on the CI side via `act` would be cleaner but `act` doesn't run macos-latest jobs locally. If the user wants to verify the workflow, push a `v5.0.1-rc1` tag and watch the Actions tab.
|
||||
|
||||
---
|
||||
|
||||
## Success criteria
|
||||
|
||||
1. `npm run dist:all` produces all three platform families on this Linux box (or at least Linux + mac, since Windows is built on Windows)
|
||||
2. The .AppImage launches and the app works end-to-end
|
||||
3. `release.yml` runs all three jobs on a tag push and creates a GitHub release with the union of artifacts
|
||||
4. No regressions: 306/306 tests still pass, dev workflow still works
|
||||
5. The `files` bug is fixed: no `.tsx` in the packaged asar
|
||||
|
||||
## Known limitations (intentional, can be follow-ups)
|
||||
|
||||
- **No `.icns`** — the macOS app will have the default Electron icon. Generating a proper `.icns` requires `iconutil` (mac) or `png2icns` (cross-platform); neither is on this Linux box.
|
||||
- **No code signing** — Windows SmartScreen will warn; macOS Gatekeeper will quarantine unsigned `.dmg`. Setting up signing later is a config-only change (no structural rework): add `CSC_LINK`/`CSC_KEY_PASSWORD` for Windows, `CSC_LINK` (Apple Developer ID) for mac.
|
||||
- **No auto-update** — user decisions for this iteration. Wiring `electron-updater` is a follow-up; the `publish` config from Task 2 puts metadata in the right place.
|
||||
- **Cross-build quirks** — building macOS from Linux produces a `.dmg` that is generally usable but may show a "this app is from an unidentified developer" prompt the first time. Right-click → Open to bypass once. Documented in the release notes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- CHANGELOG.md updates (not part of packaging)
|
||||
- electron-updater wiring
|
||||
- Code signing cert procurement
|
||||
- Snap store / Microsoft Store / Mac App Store submission
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
# Production polish — v5 shippable build
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Branch:** `react-electron`
|
||||
**Status:** Draft, pending implementation plan
|
||||
**Author:** Claude (brainstormed with Amit Haridas)
|
||||
|
||||
## 1. Goals & non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
v5 is reliably shippable to two distribution channels:
|
||||
|
||||
- **GitHub Releases** (public, free, existing workflow).
|
||||
- **ConcreteInfo update server** at `https://updates.concreteinfo.co.in/v5/` (self-hosted, for ConcreteInfo's own users).
|
||||
|
||||
Concretely:
|
||||
|
||||
- A non-blocking update banner surfaces "v5.0.2 is available" with a user-confirmed restart-to-install flow. No silent background installs.
|
||||
- A light first-run wizard (theme + update channel + starter template), all skippable.
|
||||
- One-shot auto-migration from v4.4.1 settings, with a v4 backup file and a toast on success/failure.
|
||||
- Local crash dump capture (no third-party). CrashReportModal lets the user open the dump folder and copy/delete dumps.
|
||||
- GitHub Actions CI that builds + uploads artifacts on tag and runs the test suite on every PR.
|
||||
|
||||
### Non-goals (deferred to v5.1+)
|
||||
|
||||
- **Code signing.** macOS Gatekeeper and Windows SmartScreen will warn. Re-evaluated for v5.1.
|
||||
- **Sentry or any third-party crash reporting.** Local dumps only.
|
||||
- **Auto-install on quit.** Always user-confirmed.
|
||||
- **Delta updates** (full downloads only in v5).
|
||||
- **Plugin system, cloud/licensing, perf/a11y** — each is its own subsequent spec.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
GitHub Releases (public) ConcreteInfo (CI feed)
|
||||
└── *.zip, *.dmg, *.exe, └── *.zip, *.dmg, *.exe,
|
||||
*.deb, *.rpm, *.deb, *.rpm,
|
||||
latest.yml, latest- latest.yml, latest-
|
||||
mac.yml, latest- mac.yml, latest-
|
||||
linux.yml, latest- linux.yml, latest-
|
||||
windows.yml windows.yml
|
||||
▲ ▲
|
||||
│ │
|
||||
└──────┬───────────────────────┘
|
||||
│ electron-updater reads
|
||||
│ the channel chosen in
|
||||
│ Settings (default: GitHub)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Main process (src/main/updater/) │
|
||||
│ - Updater service: wraps electron-updater │
|
||||
│ - Channel resolver: returns feed URL from setting │
|
||||
│ - State machine: idle → checking → available → │
|
||||
│ downloading → ready → installing │
|
||||
│ - Crash writer: catches process.on('uncaught…) │
|
||||
│ - Migration runner: idempotent, runs on app start│
|
||||
└─────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ IPC (renderer→main, │ IPC (renderer→main,
|
||||
│ allowlisted SEND) │ allowlisted SEND)
|
||||
│ updater:check, │ crash:read,
|
||||
│ updater:install, │ crash:open-dir,
|
||||
│ updater:get-state │ crash:delete
|
||||
│ │
|
||||
│ IPC (main→renderer, │
|
||||
│ allowlisted RECEIVE) │
|
||||
│ updater:status │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Renderer (preload bridge + React) │
|
||||
│ - Update banner: "v5.0.2 available" │
|
||||
│ - Settings: Update channel radio │
|
||||
│ - First-run wizard: skip-links, all optional │
|
||||
│ - Crash dialog: open dump dir, copy file │
|
||||
│ - Migration: first-launch indicator in settings │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key boundaries
|
||||
|
||||
- **`src/main/updater/`** is a single new directory. `updater-service.js` owns the `electron-updater` lifecycle, never the renderer.
|
||||
- **Renderer talks to updater over IPC only.** No direct `autoUpdater` import in preload. Channels must be in `ALLOWED_SEND_CHANNELS` / `ALLOWED_RECEIVE_CHANNELS`.
|
||||
- **Channel switching is a settings change.** No restart required; the next `checkForUpdates()` reads the new feed URL.
|
||||
- **Migration is idempotent.** Stores a `migration.version` key; skips if already at v5.
|
||||
- **Crash writer never blocks startup.** Initialized in main entry, but a write failure is non-fatal.
|
||||
|
||||
## 3. Component breakdown
|
||||
|
||||
| Unit | What it does | How it's used | Depends on |
|
||||
|---|---|---|---|
|
||||
| `main/updater/updater-service.js` | Owns `electron-updater`; exposes check/download/install; emits status events | Main process singleton; started after `app.whenReady()` | `electron-updater`, `electron` |
|
||||
| `main/updater/feed-config.js` | Maps channel setting → feed URL; reads from `app.getPath('userData')/settings.json` | Called by updater-service on each check | settings store |
|
||||
| `main/updater/crash-writer.js` | Hooks `process.on('uncaughtException', …)`; writes minidump + stack to `app.getPath('userData')/crashDumps/{timestamp}.json` | Initialized in main entry, before anything else | `electron`, `app.getPath` |
|
||||
| `main/updater/migration-runner.js` | Reads v4 settings; transforms to v5 schema; writes backup; updates `migration.version` | Run from main entry on `app.whenReady()` | `fs`, settings store |
|
||||
| `main/ipc/updater-handlers.js` | IPC handlers: `updater:check`, `updater:install`, `updater:get-state` | Registered in main entry | updater-service |
|
||||
| `main/ipc/crash-handlers.js` | IPC handlers: `crash:read`, `crash:open-dir` | Registered in main entry | crash-writer |
|
||||
| `preload.js` (extension) | Add `updater.check()`, `updater.install()`, `crash.read()`, `crash.openDir()` to `electronAPI`; add channels to allowlist | Preload runs at boot | existing preload |
|
||||
| `renderer/lib/updater-store.ts` | Zustand store mirroring updater state; subscribes to `updater:status` events | Renderer; consumed by banner, settings, modals | `zustand`, IPC bridge |
|
||||
| `renderer/components/UpdateBanner.tsx` | Non-blocking banner shown when `updater-store.status` is `available | downloading | ready` | Mounted in `App.tsx` next to header | updater-store, sonner |
|
||||
| `renderer/components/FirstRunWizard.tsx` | 3-step modal with skip links; opens on first launch when `app-store.firstRun === true` | Mounted in `App.tsx` | app-store, settings-store, command-store |
|
||||
| `renderer/components/modals/SettingsModal.tsx` (extend) | Add "Updates" section: channel radio, "Check now" button, auto-check toggle | Mounted via `useAppStore.modal` | updater-store, settings-store |
|
||||
| `renderer/components/modals/CrashReportModal.tsx` | List local crash dumps; "Open dump folder", "Copy to clipboard", "Delete" actions | Mounted via `useAppStore.modal` | crash ipc, sonner |
|
||||
| `renderer/lib/migrations/v4-to-v5.ts` | Pure function: `(v4Settings) => v5Settings` | Called from `migration-runner` | zod schemas |
|
||||
|
||||
### Data flow — "user clicks Check for updates"
|
||||
|
||||
1. Settings panel: `useSettingsStore.getState().setSetting('updateChannel', 'github' | 'concreteinfo')`.
|
||||
2. `SettingsModal` calls `await ipc.updater.check()`.
|
||||
3. Preload `safeCall` → `window.electronAPI.updater.check()` → `ipcRenderer.invoke('updater:check')`.
|
||||
4. Main handler asks `updater-service` to `autoUpdater.checkForUpdates()` with the channel's feed URL.
|
||||
5. `updater-service` emits `'updater:status', { state: 'available', version: '5.0.2' }`.
|
||||
6. Preload forwards to `updater-store` via `window.electronAPI.on('updater:status', cb)`.
|
||||
7. `updater-store` state flips to `available`; `UpdateBanner` renders.
|
||||
8. User clicks "View release notes" → opens `https://github.com/.../releases/tag/v5.0.2` via `ipc.app.openExternal`.
|
||||
9. User clicks "Restart to update" → calls `ipc.updater.install()`. `updater-service` calls `autoUpdater.quitAndInstall()`. App quits, relaunches into v5.0.2.
|
||||
|
||||
## 4. Error handling
|
||||
|
||||
### Updater
|
||||
|
||||
- **No network:** `autoUpdater.checkForUpdates()` rejects with `ENOTFOUND` / `ETIMEDOUT`. We catch, log to main log, emit `updater:status { state: 'error', code: 'NETWORK' }`. Banner shows "Couldn't check for updates. Try again." Toast fires inline at the wire point in `UpdateBanner` / `SettingsModal` on retry, matching the existing pattern (`toasts-inline-at-wire-points`).
|
||||
- **Feed 404 / signature missing (N/A in v5 since unsigned):** Treat as configuration error. Log to main log with full URL. Banner shows "Update feed is misconfigured. Report this on GitHub." Crash-log-style entry is written.
|
||||
- **Update available but download fails:** Banner flips to "Download failed. Try again." Clicking "Try again" re-invokes `autoUpdater.downloadUpdate()`. Three consecutive failures show a "Report issue" link in the banner.
|
||||
- **Update installs but launch fails:** Worst case. We catch the post-`quitAndInstall` crash via `crash-writer` and write a marker file `app.getPath('userData')/update-failed.json` with the previous version. On next boot, the user is offered to revert manually by re-downloading from the GitHub release page (a "rolled back" banner instead of the wizard).
|
||||
- **Concurrent checks:** Debounce 60s. A second `checkForUpdates()` within 60s is a no-op; returns the current state. The Check-Now button is disabled during a check.
|
||||
|
||||
### Migration
|
||||
|
||||
- **v4 settings file missing/corrupt:** Treat as "nothing to migrate." App starts with v5 defaults. Toast: "Welcome to v5 — using default settings."
|
||||
- **Migration throws mid-transform:** Original v4 file is preserved as `settings.v4.bak.json` (already on disk). App starts with v5 defaults. Toast: "Couldn't migrate v4 settings — your old settings are preserved at {path}."
|
||||
- **`migration.version === 5`:** Skip entirely. No-op.
|
||||
|
||||
### Crash writer
|
||||
|
||||
- **Dump write fails (disk full, permissions):** Print to stderr; do not crash the app. Recovery is "user's problem" — we never block app startup on the crash writer.
|
||||
- **No dumps in dir:** CrashReportModal shows empty state: "No crashes recorded — nice work!"
|
||||
|
||||
### First-run wizard
|
||||
|
||||
- **Skip clicked at any step:** Sets `app-store.firstRun = false` and proceeds. All defaults already match the unselected choice, so this is non-destructive.
|
||||
- **Wizard cannot reach settings (corrupt store):** Falls back to defaults silently. Wizard still closes.
|
||||
|
||||
## 5. Testing
|
||||
|
||||
### Unit (Vitest, renderer-side)
|
||||
|
||||
- `useUpdaterStore`: state transitions on each `updater:status` event; debounce on `check()`; reject on missing channel.
|
||||
- `FirstRunWizard`: each step's CTA writes the right slice; "Skip" leaves defaults; "Skip at step 1" still closes the wizard and sets `firstRun = false`.
|
||||
- `migrations/v4-to-v5`: golden-file tests — every v4 shape we ship transforms to expected v5 output. Idempotency: running twice yields identical v5 settings.
|
||||
- `feed-config`: each channel setting resolves to the right feed URL; missing/unknown channel falls back to GitHub.
|
||||
|
||||
### Component (Vitest + RTL)
|
||||
|
||||
- `UpdateBanner`: hidden when state is `idle` or `error-network`; visible with the right copy in `available`/`downloading`/`ready`; Restart button calls `ipc.updater.install()`; "View release notes" opens the right URL via `ipc.app.openExternal`.
|
||||
- `FirstRunWizard`: skip links work; theme picker updates the store; channel picker persists; template picker inserts into the new buffer.
|
||||
- `CrashReportModal`: lists dumps from the IPC response; "Open folder" invokes `crash.openDir`; delete removes the file.
|
||||
- `SettingsModal > Updates`: channel radio flips `settings-store.updateChannel`; "Check now" fires `ipc.updater.check`; auto-check toggle persists.
|
||||
|
||||
### Integration (Vitest, full app)
|
||||
|
||||
- **Migration end-to-end:** pre-seed a v4 settings file in `app.getPath('userData')/settings.json`, launch the app, verify v5 file is written, v4 backup exists, `migration.version === 5`, and the wizard opens on first run.
|
||||
- **Update flow mock:** stub `electron-updater` to emit `available` → `downloading` → `ready`; verify the banner appears, Restart click triggers `quitAndInstall`, and the IPC sequence matches the allowlist.
|
||||
|
||||
### E2E (Playwright + Electron, runs the live app)
|
||||
|
||||
- Open the app, verify the FirstRun wizard renders. Skip. Verify the editor is visible.
|
||||
- Open Settings, switch update channel from GitHub to ConcreteInfo, click Check now. Stub the feed response. Verify the banner appears with the right version.
|
||||
- Click Restart. Verify the app calls `quitAndInstall` (assert on a mocked `autoUpdater.quitAndInstall`).
|
||||
- Trigger an unhandled rejection in the renderer. Verify a dump is written to `crashDumps/`. Open the CrashReportModal, verify it appears in the list.
|
||||
|
||||
### Coverage targets
|
||||
|
||||
- Updater service: ≥ 90% (small surface, hot path).
|
||||
- Migration runner: 100% of branches (idempotency, corrupt file, missing file, transform failure).
|
||||
- FirstRunWizard: ≥ 80%.
|
||||
- Component tests above any preset.
|
||||
|
||||
## 6. Distribution feed contracts
|
||||
|
||||
### GitHub Releases (default)
|
||||
|
||||
Feed URL pattern:
|
||||
```
|
||||
https://github.com/{owner}/{repo}/releases/download/v{version}/
|
||||
```
|
||||
|
||||
For v5.0.2:
|
||||
```
|
||||
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-mac.yml
|
||||
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-linux.yml
|
||||
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-windows.yml
|
||||
```
|
||||
|
||||
`electron-updater` reads these automatically given the GitHub owner/repo config. The release workflow already exists at `.github/workflows/release.yml` and builds `.dmg`, `.zip`, `.exe`, `.deb`, `.rpm`. We add `latest.yml` generation via `electron-builder --publish always` in the CI.
|
||||
|
||||
### ConcreteInfo feed
|
||||
|
||||
Feed URL:
|
||||
```
|
||||
https://updates.concreteinfo.co.in/v5/latest.yml
|
||||
https://updates.concreteinfo.co.in/v5/latest-mac.yml
|
||||
https://updates.concreteinfo.co.in/v5/latest-linux.yml
|
||||
https://updates.concreteinfo.co.in/v5/latest-windows.yml
|
||||
```
|
||||
|
||||
CI mirrors the GitHub Release artifacts to this path on every release. ConcreteInfo infra is documented in `~/.claude-mmax/CLAUDE.md`; the coolify server is `localhost:8000`. The mirror is a static-file route — no auth, just version-prefixed.
|
||||
|
||||
## 7. First-run wizard contract
|
||||
|
||||
Three steps. Each step has "Skip" and "Back" links. Final step is "Done."
|
||||
|
||||
1. **Theme:** Light / Dark / System. Default: System. Persists to `settings-store.theme`.
|
||||
2. **Update channel:** GitHub Releases / ConcreteInfo. Default: GitHub Releases. Persists to `settings-store.updateChannel`.
|
||||
3. **Starter template:** Blank / README / Meeting notes / Blog post. Default: Blank. On "Done," creates an untitled buffer with the chosen template content.
|
||||
|
||||
`firstRun` lives in `app-store` (not persisted across app data resets) and is checked on every launch. Setting it to `false` either explicitly (skip/done) or implicitly (v4 migration succeeded) prevents re-showing.
|
||||
|
||||
## 8. Settings migration contract (v4.4.1 → v5.0.0)
|
||||
|
||||
`migration-runner` runs once on first v5 launch. It:
|
||||
|
||||
1. Reads `app.getPath('userData')/settings.json` if it exists.
|
||||
2. Validates the v4 shape with a zod schema (`v4SettingsSchema`).
|
||||
3. If valid: transforms via `migrations/v4-to-v5.ts` and writes the v5 settings to `settings.json`. Backs up v4 to `settings.v4.bak.json`. Sets `migration.version = 5` in the v5 file.
|
||||
4. If missing or invalid: writes a fresh v5 settings file with defaults. `migration.version = 5`.
|
||||
5. If transform throws: leaves v4 file in place as the "backup." App starts with defaults. Toast warns the user.
|
||||
|
||||
The transform handles:
|
||||
|
||||
- v4 `theme` ('light' | 'dark' | 'auto') → v5 `theme` ('light' | 'dark' | 'system').
|
||||
- v4 `customCss` string → v5 `customCssPath` file path or `null`.
|
||||
- v4 `recentFiles: string[]` → v5 `recentFiles` (unchanged shape).
|
||||
- v4 `editorFontSize: number` → v5 `editorFontSize` (unchanged shape).
|
||||
- v4 `keyBindings: object` → v5 `userBindings` (keymap).
|
||||
- v4 `snippets: array` → v5 `snippets` (unchanged shape).
|
||||
- v4 `pdfExportOptions` etc. → v5 schema for the new export pipeline.
|
||||
|
||||
Anything not in the v4 schema is dropped. Defaults are taken from the v5 zod schema.
|
||||
|
||||
## 9. IPC allowlist additions
|
||||
|
||||
`src/preload.js` adds these to `ALLOWED_SEND_CHANNELS`:
|
||||
|
||||
```js
|
||||
'updater:check',
|
||||
'updater:install',
|
||||
'updater:get-state',
|
||||
'crash:read',
|
||||
'crash:open-dir',
|
||||
'crash:delete',
|
||||
```
|
||||
|
||||
`ALLOWED_RECEIVE_CHANNELS`:
|
||||
|
||||
```js
|
||||
'updater:status',
|
||||
```
|
||||
|
||||
The `electronAPI` object gets:
|
||||
|
||||
```js
|
||||
updater: {
|
||||
check: () => ipcRenderer.invoke('updater:check'),
|
||||
install: () => ipcRenderer.invoke('updater:install'),
|
||||
getState: () => ipcRenderer.invoke('updater:get-state'),
|
||||
onStatus: (cb) => ipcRenderer.on('updater:status', (_, payload) => cb(payload)),
|
||||
},
|
||||
crash: {
|
||||
read: () => ipcRenderer.invoke('crash:read'),
|
||||
openDir: () => ipcRenderer.send('crash:open-dir'),
|
||||
delete: (filename) => ipcRenderer.invoke('crash:delete', filename),
|
||||
},
|
||||
```
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| First-run wizard is annoying to long-time users | Skippable; only shows on first launch. `firstRun = false` is set permanently on first skip/done. |
|
||||
| Auto-migration corrupts settings | Original v4 file is preserved as `settings.v4.bak.json`. Transform errors fall back to defaults and toast the user. |
|
||||
| Crash writer fills disk | Cap at 20 dumps; oldest auto-pruned. |
|
||||
| Update feed mirror drift between GitHub and CI | Mirror runs in CI on every release; manual `npm run publish:concreteinfo` available as fallback. |
|
||||
| `electron-updater` is unsigned, so anyone can publish a "v5.0.2" to a mirror | We do not enable auto-install in v5 — every install is user-confirmed via the release-notes page. v5.1 adds signing. |
|
||||
| First-run wizard blocks app start on a slow renderer | Wizard is non-blocking. App shell mounts and is interactive behind the modal. |
|
||||
| Debounced `checkForUpdates()` may mask real errors | "Check now" button bypasses the debounce; debounce only applies to automatic checks. |
|
||||
| CI release workflow is on a tag — broken tags must be re-tagged manually | Documented in CONTRIBUTING.md; pre-release tag re-runs the workflow. |
|
||||
|
||||
## 11. Out of scope (deferred)
|
||||
|
||||
- Code signing.
|
||||
- Delta updates.
|
||||
- Per-channel pre-release / beta tracks (only stable feeds in v5).
|
||||
- Crash analytics / Sentry.
|
||||
- Squirrel.Windows-specific quirks (we publish NSIS only).
|
||||
+1
-1
@@ -85,7 +85,7 @@ module.exports = [
|
||||
},
|
||||
rules: {
|
||||
// Error prevention
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||
'no-undef': 'error',
|
||||
'no-console': 'off', // Allow console for Electron apps
|
||||
|
||||
|
||||
Generated
+490
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "markdown-converter",
|
||||
"version": "4.4.2",
|
||||
"version": "5.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "markdown-converter",
|
||||
"version": "4.4.2",
|
||||
"version": "5.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
@@ -46,6 +46,7 @@
|
||||
"docx4js": "^2.0.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-store": "^10.1.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"figlet": "^1.11.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -86,6 +87,7 @@
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"cross-env": "^10.0.0",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
@@ -104,7 +106,8 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.8",
|
||||
"wait-on": "^9.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -1902,6 +1905,60 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/formula": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz",
|
||||
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/pinpoint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
|
||||
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/tlds": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz",
|
||||
"integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/topo": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz",
|
||||
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||
@@ -6844,7 +6901,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
@@ -6982,6 +7038,46 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "30.3.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
|
||||
@@ -7889,6 +7985,185 @@
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.4",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/index.js",
|
||||
"concurrently": "dist/bin/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/conf": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz",
|
||||
@@ -9469,6 +9744,82 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-updater": {
|
||||
"version": "6.8.9",
|
||||
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
|
||||
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"builder-util-runtime": "9.7.0",
|
||||
"fs-extra": "^10.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lazy-val": "^1.0.5",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"semver": "~7.7.3",
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/builder-util-runtime": {
|
||||
"version": "9.7.0",
|
||||
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
|
||||
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
|
||||
@@ -10378,6 +10729,27 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
@@ -10563,6 +10935,19 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -10810,7 +11195,6 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/hachure-fill": {
|
||||
@@ -12696,6 +13080,25 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.2.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
|
||||
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/address": "^5.1.1",
|
||||
"@hapi/formula": "^3.0.2",
|
||||
"@hapi/hoek": "^11.0.7",
|
||||
"@hapi/pinpoint": "^2.0.1",
|
||||
"@hapi/tlds": "^1.1.1",
|
||||
"@hapi/topo": "^6.0.2",
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
@@ -12714,7 +13117,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -12980,7 +13382,6 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leven": {
|
||||
@@ -13357,6 +13758,19 @@
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.escaperegexp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
|
||||
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -15183,6 +15597,16 @@
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
@@ -15758,6 +16182,16 @@
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -15958,6 +16392,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -16739,6 +17186,12 @@
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -16878,6 +17331,16 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/truncate-utf8-bytes": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||
@@ -17441,6 +17904,26 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz",
|
||||
"integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"joi": "^18.2.1",
|
||||
"lodash": "^4.18.1",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"wait-on": "bin/wait-on"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||
|
||||
+50
-8
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "markdown-converter",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||
"main": "src/main/index.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev:renderer": "vite --config vite.renderer.config.ts",
|
||||
"dev:electron": "wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 ELECTRON_DISABLE_SANDBOX=1 electron . -- --no-sandbox --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage",
|
||||
"dev": "concurrently -k -n vite,electron -c blue,green \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"build:renderer": "vite build --config vite.renderer.config.ts",
|
||||
"preview": "npm run build:renderer && electron .",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
@@ -27,7 +32,9 @@
|
||||
"dist": "electron-builder --publish=never",
|
||||
"dist:all": "electron-builder -mwl",
|
||||
"download-tools": "node scripts/download-tools.js",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
"generate-icons": "node scripts/generate-icons.js",
|
||||
"build:icon-icns": "node scripts/build-icon-icns.js",
|
||||
"publish:concreteinfo": "node scripts/publish-concreteinfo.js"
|
||||
},
|
||||
"keywords": [
|
||||
"markdown",
|
||||
@@ -60,6 +67,7 @@
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"cross-env": "^10.0.0",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
@@ -78,7 +86,8 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.8",
|
||||
"wait-on": "^9.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
@@ -118,6 +127,7 @@
|
||||
"docx4js": "^2.0.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-store": "^10.1.0",
|
||||
"electron-updater": "^6.8.9",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"figlet": "^1.11.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -159,15 +169,20 @@
|
||||
},
|
||||
"icon": "assets/icon",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"assets/**/*",
|
||||
"scripts/**/*",
|
||||
"node_modules/**/*",
|
||||
"src/main/**/*",
|
||||
"src/preload.js",
|
||||
"src/plugins/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/ffmpeg-static/**"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist/renderer",
|
||||
"to": "renderer"
|
||||
}
|
||||
],
|
||||
"extraFiles": [],
|
||||
"fileAssociations": [
|
||||
{
|
||||
@@ -194,7 +209,28 @@
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"identity": null
|
||||
"identity": null,
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icon.icns",
|
||||
"darkModeSupport": true,
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": null
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
@@ -267,6 +303,12 @@
|
||||
],
|
||||
"description": "Professional Markdown editor and universal file converter",
|
||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "amitwh",
|
||||
"repo": "markdown-converter",
|
||||
"releaseType": "release"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Build assets/icon.icns from the PNGs in assets/icons/.
|
||||
*
|
||||
* ICNS file format (Apple Icon Image):
|
||||
* Header: 'icns' (4 bytes) + total file length (uint32 BE, includes header)
|
||||
* For each size:
|
||||
* Type tag (4 bytes) + length (uint32 BE, includes the 8-byte entry header) + PNG data
|
||||
*
|
||||
* Standard PNG type tags we'll include:
|
||||
* icp4 = 16x16, icp5 = 32x32, icp6 = 64x64
|
||||
* ic07 = 128x128, ic08 = 256x256, ic09 = 512x512, ic10 = 1024x1024
|
||||
* icsb = 48x48 (small)
|
||||
* ic11 = 16x16@2x (32x32 retina), ic12 = 32x32@2x (64x64 retina)
|
||||
* ic13 = 128x128@2x (256x256 retina), ic14 = 256x256@2x (512x512 retina)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const iconsDir = path.join(__dirname, '..', 'assets', 'icons');
|
||||
const outFile = path.join(__dirname, '..', 'assets', 'icon.icns');
|
||||
|
||||
// (size-in-pixels, icns type tag) — we skip sizes we don't have on disk
|
||||
const sizes = [
|
||||
{ size: 16, type: 'icp4' },
|
||||
{ size: 32, type: 'icp5' },
|
||||
{ size: 64, type: 'icp6' },
|
||||
{ size: 128, type: 'ic07' },
|
||||
{ size: 256, type: 'ic08' },
|
||||
{ size: 512, type: 'ic09' },
|
||||
{ size: 1024, type: 'ic10' },
|
||||
{ size: 48, type: 'icsb' },
|
||||
{ size: 32, type: 'ic11' }, // 16@2x
|
||||
{ size: 64, type: 'ic12' }, // 32@2x
|
||||
{ size: 256, type: 'ic13' }, // 128@2x
|
||||
{ size: 512, type: 'ic14' }, // 256@2x
|
||||
];
|
||||
|
||||
const entries = [];
|
||||
for (const { size, type } of sizes) {
|
||||
const pngPath = path.join(iconsDir, `${size}x${size}.png`);
|
||||
if (!fs.existsSync(pngPath)) continue;
|
||||
const png = fs.readFileSync(pngPath);
|
||||
// ICNS requires 8-byte alignment per entry. paddedLen is the TOTAL entry size
|
||||
// (8-byte type+length header + PNG + padding), rounded up to a multiple of 8.
|
||||
const paddedLen = 8 + Math.ceil(png.length / 8) * 8;
|
||||
entries.push({ type, png, paddedLen });
|
||||
console.log(` ${type} (${size}x${size}) — ${png.length} bytes`);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.error('No source PNGs found in', iconsDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const totalLen = 8 + entries.reduce((s, e) => s + e.paddedLen, 0);
|
||||
const buf = Buffer.alloc(totalLen);
|
||||
let off = 0;
|
||||
|
||||
// Header
|
||||
buf.write('icns', off, 4, 'ascii'); off += 4;
|
||||
buf.writeUInt32BE(totalLen, off); off += 4;
|
||||
|
||||
// Entries
|
||||
for (const { type, png, paddedLen } of entries) {
|
||||
buf.write(type, off, 4, 'ascii'); off += 4;
|
||||
buf.writeUInt32BE(paddedLen, off); off += 4;
|
||||
png.copy(buf, off);
|
||||
off += png.length;
|
||||
// Pad to 8-byte boundary within the entry. paddedLen already includes the 8-byte header.
|
||||
const pad = paddedLen - 8 - png.length;
|
||||
if (pad > 0) off += pad;
|
||||
}
|
||||
|
||||
fs.writeFileSync(outFile, buf);
|
||||
console.log(`\nWrote ${outFile} (${buf.length} bytes, ${entries.length} sizes)`);
|
||||
@@ -14,6 +14,30 @@ const { execSync } = require('child_process');
|
||||
|
||||
const PANDOC_VERSION = '3.9.0.2';
|
||||
|
||||
/**
|
||||
* Pandoc's archive inner layout has shifted across releases and platforms:
|
||||
* linux tarball: pandoc-3.9.0.2/bin/pandoc
|
||||
* win32 zip: pandoc-3.9.0.2/pandoc.exe
|
||||
* darwin zip: pandoc-3.9.0.2-x86_64/bin/pandoc (arch-suffixed inner dir)
|
||||
* pandoc-3.9.0.2-arm64/bin/pandoc (on Apple Silicon builds)
|
||||
* Rather than hard-coding the intermediate path — which has already broken
|
||||
* once when pandoc 3.9 added the arch suffix to the macOS archive — we walk
|
||||
* the extracted tree and find the binary by name. This is robust to future
|
||||
* layout shifts (e.g., universal binaries renaming the inner dir).
|
||||
*/
|
||||
function findFile(rootDir, targetName) {
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(rootDir, entry.name);
|
||||
if (entry.isFile() && entry.name === targetName) return full;
|
||||
if (entry.isDirectory()) {
|
||||
const found = findFile(full, targetName);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PANDOC_CONFIG = {
|
||||
linux: {
|
||||
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`,
|
||||
@@ -22,8 +46,9 @@ const PANDOC_CONFIG = {
|
||||
extract(archivePath, destDir) {
|
||||
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}" pandoc-${PANDOC_VERSION}/bin/pandoc`);
|
||||
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc');
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`);
|
||||
const src = findFile(tmpDir, 'pandoc');
|
||||
if (!src) throw new Error(`pandoc binary not found under ${tmpDir}`);
|
||||
fs.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
@@ -39,7 +64,8 @@ const PANDOC_CONFIG = {
|
||||
execSync(
|
||||
`powershell -Command "Expand-Archive -Force '${archivePath}' '${tmpDir}'"`,
|
||||
);
|
||||
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'pandoc.exe');
|
||||
const src = findFile(tmpDir, 'pandoc.exe');
|
||||
if (!src) throw new Error(`pandoc.exe not found under ${tmpDir}`);
|
||||
fs.copyFileSync(src, path.join(destDir, 'pandoc.exe'));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
},
|
||||
@@ -52,7 +78,8 @@ const PANDOC_CONFIG = {
|
||||
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`);
|
||||
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc');
|
||||
const src = findFile(tmpDir, 'pandoc');
|
||||
if (!src) throw new Error(`pandoc binary not found under ${tmpDir}`);
|
||||
fs.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const version = process.argv[2] || require('../package.json').version;
|
||||
const hook = process.env.CONCRETEINFO_DEPLOY_HOOK;
|
||||
if (!hook) {
|
||||
console.error('CONCRETEINFO_DEPLOY_HOOK not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
'latest-mac.yml',
|
||||
'latest-linux.yml',
|
||||
'latest-windows.yml',
|
||||
`MarkdownConverter-${version}.dmg`,
|
||||
`markdown-converter_${version}_amd64.deb`,
|
||||
`MarkdownConverter-Setup-${version}.exe`,
|
||||
];
|
||||
|
||||
const dist = path.resolve(__dirname, '..', 'dist');
|
||||
for (const f of candidates) {
|
||||
if (!fs.existsSync(path.join(dist, f))) {
|
||||
console.error(`missing ${f} — run electron-builder first`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('version', version);
|
||||
for (const f of candidates) {
|
||||
if (fs.existsSync(path.join(dist, f))) {
|
||||
form.append('artifacts', fs.createReadStream(path.join(dist, f)), f);
|
||||
}
|
||||
}
|
||||
|
||||
fetch('https://updates.concreteinfo.co.in/api/v1/ingest', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${hook}` },
|
||||
body: form,
|
||||
})
|
||||
.then((r) => {
|
||||
console.log('ingest status', r.status);
|
||||
process.exit(r.ok ? 0 : 1);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('ingest failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { _electron as electron } from 'playwright-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const APP_DIR = '/home/amith/apps/markdown-converter';
|
||||
const electronBin = path.join(APP_DIR, 'node_modules/electron/dist/electron');
|
||||
|
||||
const testMd = '/tmp/verify-test.md';
|
||||
const outputDocx = '/tmp/verify-output.docx';
|
||||
const outputHtml = '/tmp/verify-output.html';
|
||||
|
||||
// Cleanup previous outputs
|
||||
if (fs.existsSync(testMd)) fs.unlinkSync(testMd);
|
||||
if (fs.existsSync(outputDocx)) fs.unlinkSync(outputDocx);
|
||||
if (fs.existsSync(outputHtml)) fs.unlinkSync(outputHtml);
|
||||
|
||||
// Write test Markdown file
|
||||
fs.writeFileSync(
|
||||
testMd,
|
||||
'# Test Document\n\nThis is a verification test for opening and exporting.\n\n- Point A\n- Point B\n'
|
||||
);
|
||||
|
||||
console.log('Launching Electron...');
|
||||
const app = await electron.launch({
|
||||
executablePath: electronBin,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-software-rasterizer',
|
||||
'--disable-dev-shm-usage',
|
||||
'.',
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
DISPLAY: ':0',
|
||||
ELECTRON_DISABLE_SANDBOX: '1',
|
||||
VITE_DEV_SERVER_URL: 'http://localhost:5173',
|
||||
},
|
||||
cwd: APP_DIR,
|
||||
});
|
||||
|
||||
app.process().stdout.on('data', (data) => console.log('[MAIN-OUT]', data.toString().trim()));
|
||||
app.process().stderr.on('data', (data) => console.log('[MAIN-ERR]', data.toString().trim()));
|
||||
|
||||
const win = await app.firstWindow();
|
||||
await win.waitForLoadState('domcontentloaded');
|
||||
await win.waitForSelector('.cm-editor, [role="toolbar"]', { timeout: 10000 });
|
||||
console.log('App loaded.');
|
||||
|
||||
// Dismiss welcome wizard if present
|
||||
const wizardCount = await win.locator('[data-testid="first-run-wizard"]').count();
|
||||
if (wizardCount > 0) {
|
||||
console.log('Dismissing first run wizard...');
|
||||
await win.click('[data-testid="first-run-wizard"] >> text=Skip');
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
|
||||
// Stub dialog.showSaveDialogSync in main process to automatically return output paths
|
||||
await app.evaluate(({ dialog }, { outputDocx, outputHtml }) => {
|
||||
dialog.showSaveDialogSync = (window, options) => {
|
||||
const filters = options?.filters || [];
|
||||
if (filters.some(f => f.extensions.includes('docx'))) {
|
||||
return outputDocx;
|
||||
}
|
||||
return outputHtml;
|
||||
};
|
||||
}, { outputDocx, outputHtml });
|
||||
|
||||
// Simulate opening the test Md file by sending IPC from main
|
||||
console.log('Opening test markdown file...');
|
||||
const fileContent = fs.readFileSync(testMd, 'utf-8');
|
||||
await app.evaluate(({ BrowserWindow }, { filePath, content }) => {
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
const main = wins.find((w) => !w.isDestroyed());
|
||||
if (!main) throw new Error('No main window');
|
||||
main.webContents.send('file-opened', { path: filePath, content });
|
||||
}, { filePath: testMd, content: fileContent });
|
||||
|
||||
// Wait for editor to display the content
|
||||
await win.waitForFunction(
|
||||
(content) => {
|
||||
const editor = document.querySelector('.cm-content');
|
||||
return editor && editor.textContent.includes('Test Document');
|
||||
},
|
||||
fileContent,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
console.log('File successfully opened in editor.');
|
||||
|
||||
// Let's wait a moment for currentFile synchronization to trigger in the main process
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
|
||||
// Trigger DOCX Export (calls performExportWithOptions under the hood)
|
||||
console.log('Exporting to DOCX...');
|
||||
await win.evaluate(() => {
|
||||
window.electronAPI.export.withOptions('docx', {});
|
||||
});
|
||||
|
||||
// Wait for file to be written to disk
|
||||
let docxExported = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (fs.existsSync(outputDocx) && fs.statSync(outputDocx).size > 0) {
|
||||
docxExported = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
|
||||
if (docxExported) {
|
||||
console.log('✅ DOCX exported successfully.');
|
||||
} else {
|
||||
console.error('❌ DOCX export failed (file not created or empty).');
|
||||
}
|
||||
|
||||
// Wait 2.5 seconds to bypass the conversion rate limiter (2000ms debounce)
|
||||
console.log('Waiting for rate limiter...');
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// Trigger HTML Export
|
||||
console.log('Exporting to HTML...');
|
||||
await win.evaluate(() => {
|
||||
window.electronAPI.export.withOptions('html', {});
|
||||
});
|
||||
|
||||
let htmlExported = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (fs.existsSync(outputHtml) && fs.statSync(outputHtml).size > 0) {
|
||||
htmlExported = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
|
||||
if (htmlExported) {
|
||||
console.log('✅ HTML exported successfully.');
|
||||
} else {
|
||||
console.error('❌ HTML export failed (file not created or empty).');
|
||||
}
|
||||
|
||||
await app.close();
|
||||
console.log('Verification completed.');
|
||||
|
||||
if (docxExported && htmlExported) {
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
+89
-89
@@ -12,100 +12,100 @@
|
||||
* @type {import('../types').FileSystemAdapter}
|
||||
*/
|
||||
const electronFsAdapter = {
|
||||
/**
|
||||
* Read file content
|
||||
* @param {string} path - File path
|
||||
* @returns {Promise<string>} File content
|
||||
*/
|
||||
async readFile(path) {
|
||||
return await window.electronAPI.file.read(path);
|
||||
},
|
||||
/**
|
||||
* Read file content
|
||||
* @param {string} path - File path
|
||||
* @returns {Promise<string>} File content
|
||||
*/
|
||||
async readFile(path) {
|
||||
return await window.electronAPI.file.read(path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Write content to file
|
||||
* @param {string} path - File path
|
||||
* @param {string} content - File content
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async writeFile(path, content) {
|
||||
return await window.electronAPI.file.write(path, content);
|
||||
},
|
||||
/**
|
||||
* Write content to file
|
||||
* @param {string} path - File path
|
||||
* @param {string} content - File content
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async writeFile(path, content) {
|
||||
return await window.electronAPI.file.write(path, content);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete file
|
||||
* @param {string} path - File path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteFile(path) {
|
||||
return await window.electronAPI.file.delete(path);
|
||||
},
|
||||
/**
|
||||
* Delete file
|
||||
* @param {string} path - File path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteFile(path) {
|
||||
return await window.electronAPI.file.delete(path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
* @param {string} path - Directory path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ensureDir(path) {
|
||||
return await window.electronAPI.file.ensureDir(path);
|
||||
},
|
||||
/**
|
||||
* Ensure directory exists
|
||||
* @param {string} path - Directory path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ensureDir(path) {
|
||||
return await window.electronAPI.file.ensureDir(path);
|
||||
},
|
||||
|
||||
/**
|
||||
* List directory contents
|
||||
* @param {string} path - Directory path
|
||||
* @returns {Promise<Array<import('../types').FileInfo>>}
|
||||
*/
|
||||
async listDirectory(path) {
|
||||
const result = await window.electronAPI.invoke('list-directory', path);
|
||||
if (!result?.entries) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDir: entry.isDirectory,
|
||||
size: entry.size ?? 0,
|
||||
modified: entry.modified ?? 0,
|
||||
path: entry.path
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if path exists
|
||||
* @param {string} path - Path to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(path) {
|
||||
return await window.electronAPI.file.exists(path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if path is a directory
|
||||
* @param {string} path - Path to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isDirectory(path) {
|
||||
return await window.electronAPI.file.isDirectory(path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy file or directory
|
||||
* @param {string} source - Source path
|
||||
* @param {string} dest - Destination path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async copy(source, dest) {
|
||||
return await window.electronAPI.file.copy(source, dest);
|
||||
},
|
||||
|
||||
/**
|
||||
* Move file or directory
|
||||
* @param {string} source - Source path
|
||||
* @param {string} dest - Destination path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async move(source, dest) {
|
||||
return await window.electronAPI.file.move(source, dest);
|
||||
/**
|
||||
* List directory contents
|
||||
* @param {string} path - Directory path
|
||||
* @returns {Promise<Array<import('../types').FileInfo>>}
|
||||
*/
|
||||
async listDirectory(path) {
|
||||
const result = await window.electronAPI.invoke('list-directory', path);
|
||||
if (!result?.entries) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDir: entry.isDirectory,
|
||||
size: entry.size ?? 0,
|
||||
modified: entry.modified ?? 0,
|
||||
path: entry.path,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if path exists
|
||||
* @param {string} path - Path to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async exists(path) {
|
||||
return await window.electronAPI.file.exists(path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if path is a directory
|
||||
* @param {string} path - Path to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isDirectory(path) {
|
||||
return await window.electronAPI.file.isDirectory(path);
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy file or directory
|
||||
* @param {string} source - Source path
|
||||
* @param {string} dest - Destination path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async copy(source, dest) {
|
||||
return await window.electronAPI.file.copy(source, dest);
|
||||
},
|
||||
|
||||
/**
|
||||
* Move file or directory
|
||||
* @param {string} source - Source path
|
||||
* @param {string} dest - Destination path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async move(source, dest) {
|
||||
return await window.electronAPI.file.move(source, dest);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { electronFsAdapter };
|
||||
|
||||
@@ -129,5 +129,5 @@
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// Type definitions are JSDoc only, no runtime exports needed
|
||||
// Type definitions are JSDoc only, no runtime exports needed
|
||||
};
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
const { analyze } = require('./writing-analytics');
|
||||
|
||||
function showAnalyticsModal(tabManager) {
|
||||
const existing = document.getElementById('analytics-modal');
|
||||
if (existing) existing.remove();
|
||||
const existing = document.getElementById('analytics-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const content = tabManager.getEditorContent();
|
||||
const metrics = analyze(content);
|
||||
const content = tabManager.getEditorContent();
|
||||
const metrics = analyze(content);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'analytics-modal';
|
||||
overlay.className = 'analytics-overlay';
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'analytics-modal';
|
||||
overlay.className = 'analytics-overlay';
|
||||
|
||||
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
|
||||
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
|
||||
|
||||
overlay.innerHTML = `
|
||||
overlay.innerHTML = `
|
||||
<div class="analytics-modal">
|
||||
<div class="analytics-header">
|
||||
<h2>Writing Analytics</h2>
|
||||
@@ -61,11 +61,15 @@ function showAnalyticsModal(tabManager) {
|
||||
<span class="analytics-label">Avg Sentence</span>
|
||||
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
||||
</div>
|
||||
${metrics.longestSentenceLength > 0 ? `
|
||||
${
|
||||
metrics.longestSentenceLength > 0
|
||||
? `
|
||||
<div class="analytics-row analytics-longest">
|
||||
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
||||
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
||||
</div>` : ''}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="analytics-section">
|
||||
@@ -74,39 +78,45 @@ function showAnalyticsModal(tabManager) {
|
||||
<span class="analytics-label">Unique</span>
|
||||
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
||||
</div>
|
||||
${metrics.topWords.length > 0 ? `
|
||||
${
|
||||
metrics.topWords.length > 0
|
||||
? `
|
||||
<div class="word-cloud">
|
||||
${metrics.topWords.map(w => {
|
||||
${metrics.topWords
|
||||
.map((w) => {
|
||||
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
||||
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
||||
}).join('')}
|
||||
</div>` : ''}
|
||||
})
|
||||
.join('')}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const closeBtn = overlay.querySelector('.analytics-close');
|
||||
closeBtn.addEventListener('click', () => overlay.remove());
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
const closeBtn = overlay.querySelector('.analytics-close');
|
||||
closeBtn.addEventListener('click', () => overlay.remove());
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
const escHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
module.exports = { showAnalyticsModal };
|
||||
|
||||
+177
-104
@@ -4,124 +4,197 @@
|
||||
*/
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
||||
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
||||
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with',
|
||||
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it',
|
||||
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
|
||||
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
|
||||
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too',
|
||||
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who'
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'be',
|
||||
'been',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'will',
|
||||
'would',
|
||||
'could',
|
||||
'should',
|
||||
'to',
|
||||
'of',
|
||||
'in',
|
||||
'for',
|
||||
'on',
|
||||
'with',
|
||||
'at',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'if',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'these',
|
||||
'those',
|
||||
'i',
|
||||
'me',
|
||||
'my',
|
||||
'we',
|
||||
'our',
|
||||
'you',
|
||||
'your',
|
||||
'he',
|
||||
'him',
|
||||
'his',
|
||||
'she',
|
||||
'her',
|
||||
'they',
|
||||
'them',
|
||||
'their',
|
||||
'not',
|
||||
'no',
|
||||
'so',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'also',
|
||||
'just',
|
||||
'about',
|
||||
'up',
|
||||
'out',
|
||||
'what',
|
||||
'which',
|
||||
'who',
|
||||
]);
|
||||
|
||||
function countSyllables(word) {
|
||||
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
||||
word = word.replace(/^y/, '');
|
||||
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
|
||||
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
||||
word = word.replace(/^y/, '');
|
||||
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
|
||||
}
|
||||
|
||||
function extractWords(text) {
|
||||
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
|
||||
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
|
||||
}
|
||||
|
||||
function getReadabilityLabel(score) {
|
||||
if (score >= 90) return 'Very Easy';
|
||||
if (score >= 70) return 'Easy';
|
||||
if (score >= 50) return 'Standard';
|
||||
if (score >= 30) return 'Difficult';
|
||||
return 'Very Difficult';
|
||||
if (score >= 90) return 'Very Easy';
|
||||
if (score >= 70) return 'Easy';
|
||||
if (score >= 50) return 'Standard';
|
||||
if (score >= 30) return 'Difficult';
|
||||
return 'Very Difficult';
|
||||
}
|
||||
|
||||
function analyze(text) {
|
||||
if (!text || !text.trim()) {
|
||||
return {
|
||||
wordCount: 0,
|
||||
sentenceCount: 0,
|
||||
paragraphCount: 0,
|
||||
fleschEase: 0,
|
||||
fleschGrade: 0,
|
||||
readabilityLabel: 'N/A',
|
||||
readingTime: 0,
|
||||
speakingTime: 0,
|
||||
uniqueWordCount: 0,
|
||||
lexicalDiversity: 0,
|
||||
avgSentenceLength: 0,
|
||||
longestSentence: '',
|
||||
longestSentenceLength: 0,
|
||||
topWords: []
|
||||
};
|
||||
}
|
||||
|
||||
const words = extractWords(text);
|
||||
const wordCount = words.length;
|
||||
|
||||
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
|
||||
const sentenceCount = Math.max(sentences.length, 1);
|
||||
|
||||
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
|
||||
const paragraphCount = Math.max(paragraphs.length, 1);
|
||||
|
||||
let totalSyllables = 0;
|
||||
for (const w of words) {
|
||||
totalSyllables += countSyllables(w);
|
||||
}
|
||||
|
||||
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10;
|
||||
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10;
|
||||
const readabilityLabel = getReadabilityLabel(fleschEase);
|
||||
|
||||
const readingTime = Math.ceil(wordCount / 200);
|
||||
const speakingTime = Math.ceil(wordCount / 130);
|
||||
|
||||
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
|
||||
const uniqueWordCount = uniqueWords.size;
|
||||
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
||||
|
||||
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
||||
|
||||
let longestSentence = '';
|
||||
let longestSentenceLength = 0;
|
||||
for (const s of sentences) {
|
||||
const sWords = extractWords(s);
|
||||
if (sWords.length > longestSentenceLength) {
|
||||
longestSentenceLength = sWords.length;
|
||||
longestSentence = s.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (longestSentence.length > 80) {
|
||||
longestSentence = longestSentence.substring(0, 80) + '...';
|
||||
}
|
||||
|
||||
const wordFreq = {};
|
||||
for (const w of words) {
|
||||
const lower = w.toLowerCase();
|
||||
if (!STOP_WORDS.has(lower) && lower.length > 1) {
|
||||
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const topWords = Object.entries(wordFreq)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([word, count]) => ({ word, count }));
|
||||
|
||||
if (!text || !text.trim()) {
|
||||
return {
|
||||
wordCount,
|
||||
sentenceCount,
|
||||
paragraphCount,
|
||||
fleschEase,
|
||||
fleschGrade,
|
||||
readabilityLabel,
|
||||
readingTime,
|
||||
speakingTime,
|
||||
uniqueWordCount,
|
||||
lexicalDiversity,
|
||||
avgSentenceLength,
|
||||
longestSentence,
|
||||
longestSentenceLength,
|
||||
topWords
|
||||
wordCount: 0,
|
||||
sentenceCount: 0,
|
||||
paragraphCount: 0,
|
||||
fleschEase: 0,
|
||||
fleschGrade: 0,
|
||||
readabilityLabel: 'N/A',
|
||||
readingTime: 0,
|
||||
speakingTime: 0,
|
||||
uniqueWordCount: 0,
|
||||
lexicalDiversity: 0,
|
||||
avgSentenceLength: 0,
|
||||
longestSentence: '',
|
||||
longestSentenceLength: 0,
|
||||
topWords: [],
|
||||
};
|
||||
}
|
||||
|
||||
const words = extractWords(text);
|
||||
const wordCount = words.length;
|
||||
|
||||
const sentences = text
|
||||
.split(/[.!?]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const sentenceCount = Math.max(sentences.length, 1);
|
||||
|
||||
const paragraphs = text
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const paragraphCount = Math.max(paragraphs.length, 1);
|
||||
|
||||
let totalSyllables = 0;
|
||||
for (const w of words) {
|
||||
totalSyllables += countSyllables(w);
|
||||
}
|
||||
|
||||
const fleschEase =
|
||||
Math.round(
|
||||
(206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10
|
||||
) / 10;
|
||||
const fleschGrade =
|
||||
Math.round(
|
||||
(0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10
|
||||
) / 10;
|
||||
const readabilityLabel = getReadabilityLabel(fleschEase);
|
||||
|
||||
const readingTime = Math.ceil(wordCount / 200);
|
||||
const speakingTime = Math.ceil(wordCount / 130);
|
||||
|
||||
const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
|
||||
const uniqueWordCount = uniqueWords.size;
|
||||
const lexicalDiversity =
|
||||
wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
||||
|
||||
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
||||
|
||||
let longestSentence = '';
|
||||
let longestSentenceLength = 0;
|
||||
for (const s of sentences) {
|
||||
const sWords = extractWords(s);
|
||||
if (sWords.length > longestSentenceLength) {
|
||||
longestSentenceLength = sWords.length;
|
||||
longestSentence = s.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (longestSentence.length > 80) {
|
||||
longestSentence = longestSentence.substring(0, 80) + '...';
|
||||
}
|
||||
|
||||
const wordFreq = {};
|
||||
for (const w of words) {
|
||||
const lower = w.toLowerCase();
|
||||
if (!STOP_WORDS.has(lower) && lower.length > 1) {
|
||||
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const topWords = Object.entries(wordFreq)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([word, count]) => ({ word, count }));
|
||||
|
||||
return {
|
||||
wordCount,
|
||||
sentenceCount,
|
||||
paragraphCount,
|
||||
fleschEase,
|
||||
fleschGrade,
|
||||
readabilityLabel,
|
||||
readingTime,
|
||||
speakingTime,
|
||||
uniqueWordCount,
|
||||
lexicalDiversity,
|
||||
avgSentenceLength,
|
||||
longestSentence,
|
||||
longestSentenceLength,
|
||||
topWords,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { analyze };
|
||||
|
||||
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
|
||||
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
||||
// Language extensions loaded lazily on first use
|
||||
let _javascript, _html, _css, _json, _python;
|
||||
const {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
indentWithTab,
|
||||
} = require('@codemirror/commands');
|
||||
const {
|
||||
searchKeymap,
|
||||
highlightSelectionMatches,
|
||||
} = require('@codemirror/search');
|
||||
const {
|
||||
autocompletion,
|
||||
completionKeymap,
|
||||
} = require('@codemirror/autocomplete');
|
||||
const {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
indentOnInput,
|
||||
} = require('@codemirror/language');
|
||||
const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
|
||||
const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
|
||||
const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
|
||||
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
|
||||
const { oneDark } = require('@codemirror/theme-one-dark');
|
||||
|
||||
// Custom theme for JetBrains Mono font
|
||||
const jetBrainsMonoTheme = EditorView.theme({
|
||||
'&': {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace"
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: 'inherit'
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'inherit'
|
||||
}
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -59,7 +44,14 @@ const jetBrainsMonoTheme = EditorView.theme({
|
||||
* @returns {EditorView} the created editor view
|
||||
*/
|
||||
function createEditor(parentElement, options = {}) {
|
||||
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
|
||||
console.log(
|
||||
'[createEditor] Called with parentElement:',
|
||||
parentElement?.id,
|
||||
'dimensions:',
|
||||
parentElement?.clientWidth,
|
||||
'x',
|
||||
parentElement?.clientHeight
|
||||
);
|
||||
if (!parentElement) {
|
||||
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
||||
return null;
|
||||
@@ -125,11 +117,26 @@ function createEditor(parentElement, options = {}) {
|
||||
*/
|
||||
function getLanguageExtension(lang) {
|
||||
const loaders = {
|
||||
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); },
|
||||
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); },
|
||||
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); },
|
||||
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); },
|
||||
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); },
|
||||
javascript: () => {
|
||||
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
|
||||
return _javascript();
|
||||
},
|
||||
html: () => {
|
||||
if (!_html) _html = require('@codemirror/lang-html').html;
|
||||
return _html();
|
||||
},
|
||||
css: () => {
|
||||
if (!_css) _css = require('@codemirror/lang-css').css;
|
||||
return _css();
|
||||
},
|
||||
json: () => {
|
||||
if (!_json) _json = require('@codemirror/lang-json').json;
|
||||
return _json();
|
||||
},
|
||||
python: () => {
|
||||
if (!_python) _python = require('@codemirror/lang-python').python;
|
||||
return _python();
|
||||
},
|
||||
markdown: () => markdown({ base: markdownLanguage }),
|
||||
};
|
||||
loaders.js = loaders.javascript;
|
||||
|
||||
@@ -7,9 +7,24 @@ function getGitInstance(dir) {
|
||||
async function getStatus(dir) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
return { error: 'Not a git repository' };
|
||||
const result = await git.status();
|
||||
const files = [];
|
||||
for (const [filePath, status] of Object.entries(result.files || {})) {
|
||||
files.push({
|
||||
filePath,
|
||||
status:
|
||||
status.working_dir === 'M'
|
||||
? 'modified'
|
||||
: status.working_dir === 'A' || status.index === 'A'
|
||||
? 'added'
|
||||
: status.working_dir === 'D' || status.index === 'D'
|
||||
? 'deleted'
|
||||
: 'untracked',
|
||||
});
|
||||
}
|
||||
return { files };
|
||||
} catch (_err) {
|
||||
return { files: [], error: 'Not a git repository' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +32,32 @@ async function stage(dir, files) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
await git.add(files);
|
||||
return await git.status();
|
||||
const result = await git.status();
|
||||
const staged = [];
|
||||
for (const [filePath, status] of Object.entries(result.files || {})) {
|
||||
staged.push({
|
||||
filePath,
|
||||
status:
|
||||
status.index === 'A'
|
||||
? 'added'
|
||||
: status.index === 'M'
|
||||
? 'modified'
|
||||
: status.index === 'D'
|
||||
? 'deleted'
|
||||
: 'untracked',
|
||||
});
|
||||
}
|
||||
return { files: staged };
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
return { files: [], error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(dir, message) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.commit(message);
|
||||
const result = await git.commit(message);
|
||||
return { summary: result?.summary || 'Committed' };
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
@@ -35,10 +66,29 @@ async function commit(dir, message) {
|
||||
async function log(dir, maxCount = 20) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.log({ maxCount });
|
||||
const result = await git.log({ maxCount });
|
||||
return {
|
||||
latest: result?.latest || null,
|
||||
all: (result?.all || []).map((entry) => ({
|
||||
hash: entry.hash,
|
||||
message: entry.message,
|
||||
author: entry.author_name,
|
||||
date: entry.date,
|
||||
})),
|
||||
};
|
||||
} catch (err) {
|
||||
return { all: [], error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function diff(dir, filePath) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
const args = filePath ? ['--', filePath] : [];
|
||||
return await git.diff(args);
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, stage, commit, log };
|
||||
module.exports = { getStatus, stage, commit, log, diff };
|
||||
|
||||
+62
-43
@@ -4,11 +4,11 @@ const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
||||
|
||||
function parsePageRanges(rangeString, totalPages) {
|
||||
const pages = [];
|
||||
const ranges = rangeString.split(',').map(r => r.trim());
|
||||
const ranges = rangeString.split(',').map((r) => r.trim());
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||
if (i > 0 && !pages.includes(i - 1)) {
|
||||
pages.push(i - 1);
|
||||
@@ -27,11 +27,13 @@ function parsePageRanges(rangeString, totalPages) {
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255
|
||||
} : { r: 0, g: 0, b: 0 };
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16) / 255,
|
||||
g: parseInt(result[2], 16) / 255,
|
||||
b: parseInt(result[3], 16) / 255,
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
async function pdfMerge(data) {
|
||||
@@ -42,7 +44,7 @@ async function pdfMerge(data) {
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||
copiedPages.forEach(page => mergedPdf.addPage(page));
|
||||
copiedPages.forEach((page) => mergedPdf.addPage(page));
|
||||
}
|
||||
|
||||
const pdfBytes = await mergedPdf.save();
|
||||
@@ -63,13 +65,13 @@ async function pdfSplit(data) {
|
||||
const splits = [];
|
||||
|
||||
if (data.splitMode === 'pages') {
|
||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||
const ranges = data.pageRanges.split(',').map((r) => r.trim());
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i];
|
||||
const pages = [];
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||
pages.push(p - 1);
|
||||
}
|
||||
@@ -108,7 +110,7 @@ async function pdfSplit(data) {
|
||||
for (const split of splits) {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
|
||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||
const newPdfBytes = await newPdf.save();
|
||||
@@ -129,18 +131,18 @@ async function pdfCompress(data) {
|
||||
const compressedPdfBytes = await pdf.save({
|
||||
useObjectStreams: true,
|
||||
addDefaultPage: false,
|
||||
objectsPerTick: 50
|
||||
objectsPerTick: 50,
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||
|
||||
const originalSize = fs.statSync(data.inputPath).size;
|
||||
const compressedSize = fs.statSync(data.outputPath).size;
|
||||
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||
const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
@@ -160,7 +162,7 @@ async function pdfRotate(data) {
|
||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||
}
|
||||
|
||||
pagesToRotate.forEach(pageIndex => {
|
||||
pagesToRotate.forEach((pageIndex) => {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
page.setRotation(degrees(data.angle));
|
||||
});
|
||||
@@ -170,7 +172,7 @@ async function pdfRotate(data) {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
|
||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
@@ -185,16 +187,18 @@ async function pdfDeletePages(data) {
|
||||
|
||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||
|
||||
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||
pdf.removePage(pageIndex);
|
||||
});
|
||||
pagesToDelete
|
||||
.sort((a, b) => b - a)
|
||||
.forEach((pageIndex) => {
|
||||
pdf.removePage(pageIndex);
|
||||
});
|
||||
|
||||
const newPdfBytes = await pdf.save();
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
@@ -207,7 +211,7 @@ async function pdfReorder(data) {
|
||||
const pdf = await PDFDocument.load(pdfBytes);
|
||||
const totalPages = pdf.getPageCount();
|
||||
|
||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
|
||||
const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
|
||||
|
||||
if (newOrder.length !== totalPages) {
|
||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||
@@ -215,7 +219,7 @@ async function pdfReorder(data) {
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
|
||||
const reorderedPdfBytes = await newPdf.save();
|
||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||
@@ -246,7 +250,9 @@ async function pdfWatermark(data) {
|
||||
const page = pdf.getPage(pageIndex);
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
let x, y, rotation = 0;
|
||||
let x,
|
||||
y,
|
||||
rotation = 0;
|
||||
|
||||
switch (data.position) {
|
||||
case 'center':
|
||||
@@ -294,7 +300,7 @@ async function pdfWatermark(data) {
|
||||
font,
|
||||
color: rgb(color.r, color.g, color.b),
|
||||
opacity: data.opacity,
|
||||
rotate: degrees(rotation)
|
||||
rotate: degrees(rotation),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,7 +309,7 @@ async function pdfWatermark(data) {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
@@ -325,8 +331,8 @@ async function pdfEncrypt(data) {
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
documentAssembly: data.permissions.documentAssembly,
|
||||
},
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||
@@ -336,7 +342,8 @@ async function pdfEncrypt(data) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
error:
|
||||
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
@@ -375,8 +382,8 @@ async function pdfSetPermissions(data) {
|
||||
annotating: data.permissions.annotating,
|
||||
fillingForms: data.permissions.fillingForms,
|
||||
contentAccessibility: data.permissions.contentAccessibility,
|
||||
documentAssembly: data.permissions.documentAssembly
|
||||
}
|
||||
documentAssembly: data.permissions.documentAssembly,
|
||||
},
|
||||
});
|
||||
|
||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||
@@ -386,7 +393,8 @@ async function pdfSetPermissions(data) {
|
||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||
error:
|
||||
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||
};
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
@@ -395,17 +403,28 @@ async function pdfSetPermissions(data) {
|
||||
|
||||
function executeOperation(operation, data) {
|
||||
switch (operation) {
|
||||
case 'merge': return pdfMerge(data);
|
||||
case 'split': return pdfSplit(data);
|
||||
case 'compress': return pdfCompress(data);
|
||||
case 'rotate': return pdfRotate(data);
|
||||
case 'delete': return pdfDeletePages(data);
|
||||
case 'reorder': return pdfReorder(data);
|
||||
case 'watermark': return pdfWatermark(data);
|
||||
case 'encrypt': return pdfEncrypt(data);
|
||||
case 'decrypt': return pdfDecrypt(data);
|
||||
case 'permissions': return pdfSetPermissions(data);
|
||||
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||
case 'merge':
|
||||
return pdfMerge(data);
|
||||
case 'split':
|
||||
return pdfSplit(data);
|
||||
case 'compress':
|
||||
return pdfCompress(data);
|
||||
case 'rotate':
|
||||
return pdfRotate(data);
|
||||
case 'delete':
|
||||
return pdfDeletePages(data);
|
||||
case 'reorder':
|
||||
return pdfReorder(data);
|
||||
case 'watermark':
|
||||
return pdfWatermark(data);
|
||||
case 'encrypt':
|
||||
return pdfEncrypt(data);
|
||||
case 'decrypt':
|
||||
return pdfDecrypt(data);
|
||||
case 'permissions':
|
||||
return pdfSetPermissions(data);
|
||||
default:
|
||||
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,5 +448,5 @@ module.exports = {
|
||||
pdfDecrypt,
|
||||
pdfSetPermissions,
|
||||
executeOperation,
|
||||
getPageCount
|
||||
getPageCount,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,11 @@ function register() {
|
||||
await fs.writeFile(filePath, Buffer.from(buffer));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('read-buffer', async (_event, { path: filePath }) => {
|
||||
const data = await fs.readFile(filePath);
|
||||
return { ok: true, data };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
module.exports = { register };
|
||||
|
||||
+26
-9
@@ -4,25 +4,42 @@ const { ipcMain } = require('electron');
|
||||
const GitOperations = require('../GitOperations');
|
||||
|
||||
function register(currentFileRef) {
|
||||
ipcMain.handle('git-status', async () => {
|
||||
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||
ipcMain.handle('git-status', async (_event, rootPath) => {
|
||||
const dir =
|
||||
rootPath ||
|
||||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
|
||||
return GitOperations.getStatus(dir);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-stage', async (_event, { files }) => {
|
||||
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||
ipcMain.handle('git-stage', async (_event, { rootPath, files }) => {
|
||||
const dir =
|
||||
rootPath ||
|
||||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
|
||||
return GitOperations.stage(dir, files);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-commit', async (_event, { message }) => {
|
||||
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||
ipcMain.handle('git-commit', async (_event, { rootPath, message }) => {
|
||||
const dir =
|
||||
rootPath ||
|
||||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
|
||||
return GitOperations.commit(dir, message);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-log', async () => {
|
||||
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||
ipcMain.handle('git-log', async (_event, rootPath) => {
|
||||
const dir =
|
||||
rootPath ||
|
||||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
|
||||
return GitOperations.log(dir);
|
||||
});
|
||||
|
||||
ipcMain.handle('git-diff', async (_event, filePath) => {
|
||||
const dir = filePath
|
||||
? require('path').dirname(filePath)
|
||||
: currentFileRef.current
|
||||
? require('path').dirname(currentFileRef.current)
|
||||
: process.cwd();
|
||||
return GitOperations.diff(dir, filePath);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
module.exports = { register };
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// src/main/files/index.js
|
||||
// File ops facade — registers all file-related IPC handlers
|
||||
const { ipcMain, dialog, shell } = require('electron');
|
||||
const { ipcMain, dialog } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { register: registerGit } = require('./git');
|
||||
const { register: registerBinary } = require('./binary');
|
||||
|
||||
function register({ validatePath, resolveWritablePath, isPathAccessible, currentFileRef, mainWindow }) {
|
||||
function register({
|
||||
validatePath,
|
||||
resolveWritablePath,
|
||||
isPathAccessible,
|
||||
currentFileRef,
|
||||
mainWindow,
|
||||
}) {
|
||||
// pick-folder
|
||||
ipcMain.handle('pick-folder', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
@@ -154,4 +160,4 @@ function register({ validatePath, resolveWritablePath, isPathAccessible, current
|
||||
registerBinary();
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
module.exports = { register };
|
||||
|
||||
+766
-616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
const { ipcMain, shell } = require('electron');
|
||||
|
||||
function register({ crash, getMainWindow: _getMainWindow }) {
|
||||
ipcMain.handle('crash:read', () => {
|
||||
return crash.list();
|
||||
});
|
||||
|
||||
ipcMain.on('crash:open-dir', () => {
|
||||
shell.openPath(crash.path());
|
||||
});
|
||||
|
||||
ipcMain.handle('crash:delete', (_event, filename) => {
|
||||
if (
|
||||
typeof filename === 'string' &&
|
||||
/^\d+(-\d+)?-(uncaughtException|unhandledRejection)\.json$/.test(filename)
|
||||
) {
|
||||
crash.delete(filename);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
@@ -0,0 +1,29 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { feedConfigFor } = require('../updater/feed-config');
|
||||
|
||||
function register({ updater, getMainWindow, getChannel }) {
|
||||
ipcMain.handle('updater:check', async () => {
|
||||
const channel = getChannel();
|
||||
updater.autoUpdater.setFeedURL(feedConfigFor(channel));
|
||||
await updater.check();
|
||||
return { state: updater.state };
|
||||
});
|
||||
|
||||
ipcMain.handle('updater:install', () => {
|
||||
updater.install();
|
||||
});
|
||||
|
||||
ipcMain.handle('updater:get-state', () => {
|
||||
return { state: updater.state };
|
||||
});
|
||||
|
||||
// Forward status events to renderer
|
||||
updater.on('status', (payload) => {
|
||||
const win = getMainWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('updater:status', payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
@@ -10,7 +10,7 @@ const {
|
||||
convertItems,
|
||||
pdfEditorItems,
|
||||
toolsItems,
|
||||
helpItems
|
||||
helpItems,
|
||||
} = require('./items');
|
||||
|
||||
function buildMenu(mainWindow) {
|
||||
@@ -22,7 +22,7 @@ function buildMenu(mainWindow) {
|
||||
{ label: '&Convert', submenu: convertItems(mainWindow) },
|
||||
{ label: 'PDF Editor', submenu: pdfEditorItems(mainWindow) },
|
||||
{ label: '&Tools', submenu: toolsItems(mainWindow) },
|
||||
{ label: '&Help', submenu: helpItems(mainWindow) }
|
||||
{ label: '&Help', submenu: helpItems(mainWindow) },
|
||||
];
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
||||
@@ -31,4 +31,4 @@ function register(mainWindow) {
|
||||
Menu.setApplicationMenu(buildMenu(mainWindow));
|
||||
}
|
||||
|
||||
module.exports = { register, buildMenu };
|
||||
module.exports = { register, buildMenu };
|
||||
|
||||
+440
-163
@@ -1,7 +1,7 @@
|
||||
// src/main/menu/items.js
|
||||
// Individual menu items — pure functions that take (mainWindow) and return menu item arrays
|
||||
|
||||
const { app, dialog, shell } = require('electron');
|
||||
const { app, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -11,14 +11,14 @@ function buildRecentFilesMenu(mainWindow) {
|
||||
const recentFilesPath = path.join(app.getPath('userData'), 'recent-files.json');
|
||||
if (!fs.existsSync(recentFilesPath)) return [{ label: 'No Recent Files', enabled: false }];
|
||||
const recentFiles = JSON.parse(fs.readFileSync(recentFilesPath, 'utf-8'));
|
||||
const existing = recentFiles.filter(file => fs.existsSync(file));
|
||||
const existing = recentFiles.filter((file) => fs.existsSync(file));
|
||||
if (existing.length === 0) return [{ label: 'No Recent Files', enabled: false }];
|
||||
const items = existing.map(file => ({
|
||||
const items = existing.map((file) => ({
|
||||
label: path.basename(file),
|
||||
click: () => {
|
||||
const { openFileFromPath } = require('../main');
|
||||
const { openFileFromPath } = require('../index');
|
||||
openFileFromPath(file);
|
||||
}
|
||||
},
|
||||
}));
|
||||
items.push(
|
||||
{ type: 'separator' },
|
||||
@@ -28,11 +28,11 @@ function buildRecentFilesMenu(mainWindow) {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('clear-recent-files');
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
return items;
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
return [{ label: 'No Recent Files', enabled: false }];
|
||||
}
|
||||
}
|
||||
@@ -42,134 +42,243 @@ function fileItems(mainWindow) {
|
||||
{
|
||||
label: 'New',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => mainWindow.webContents.send('file-new')
|
||||
click: () => mainWindow.webContents.send('file-new'),
|
||||
},
|
||||
{
|
||||
label: 'Open',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: () => {
|
||||
const { openFile } = require('../main');
|
||||
const { openFile } = require('../index');
|
||||
openFile();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open PDF',
|
||||
accelerator: 'CmdOrCtrl+Shift+O',
|
||||
click: () => {
|
||||
const { openPdfFile } = require('../main');
|
||||
const { openPdfFile } = require('../index');
|
||||
openPdfFile();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Save',
|
||||
accelerator: 'CmdOrCtrl+S',
|
||||
click: () => mainWindow.webContents.send('file-save')
|
||||
click: () => mainWindow.webContents.send('file-save'),
|
||||
},
|
||||
{
|
||||
label: 'Save As',
|
||||
accelerator: 'CmdOrCtrl+Shift+S',
|
||||
click: () => {
|
||||
const { saveAsFile } = require('../main');
|
||||
const { saveAsFile } = require('../index');
|
||||
saveAsFile();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
// NOTE: Print Preview submenu removed — handled by React <PrintPreview> overlay
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Recent Files',
|
||||
submenu: buildRecentFilesMenu(mainWindow)
|
||||
submenu: buildRecentFilesMenu(mainWindow),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'New from Template',
|
||||
submenu: [
|
||||
{ label: 'Blog Post', click: () => mainWindow.webContents.send('load-template-menu', 'blog-post.md') },
|
||||
{ label: 'Meeting Notes', click: () => mainWindow.webContents.send('load-template-menu', 'meeting-notes.md') },
|
||||
{ label: 'Technical Spec', click: () => mainWindow.webContents.send('load-template-menu', 'technical-spec.md') },
|
||||
{ label: 'Changelog', click: () => mainWindow.webContents.send('load-template-menu', 'changelog.md') },
|
||||
{ label: 'README', click: () => mainWindow.webContents.send('load-template-menu', 'readme.md') },
|
||||
{ label: 'Project Plan', click: () => mainWindow.webContents.send('load-template-menu', 'project-plan.md') },
|
||||
{ label: 'API Documentation', click: () => mainWindow.webContents.send('load-template-menu', 'api-docs.md') },
|
||||
{ label: 'Tutorial', click: () => mainWindow.webContents.send('load-template-menu', 'tutorial.md') },
|
||||
{ label: 'Release Notes', click: () => mainWindow.webContents.send('load-template-menu', 'release-notes.md') },
|
||||
{ label: 'Comparison', click: () => mainWindow.webContents.send('load-template-menu', 'comparison.md') }
|
||||
]
|
||||
{
|
||||
label: 'Blog Post',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'blog-post.md'),
|
||||
},
|
||||
{
|
||||
label: 'Meeting Notes',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'meeting-notes.md'),
|
||||
},
|
||||
{
|
||||
label: 'Technical Spec',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'technical-spec.md'),
|
||||
},
|
||||
{
|
||||
label: 'Changelog',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'changelog.md'),
|
||||
},
|
||||
{
|
||||
label: 'README',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'readme.md'),
|
||||
},
|
||||
{
|
||||
label: 'Project Plan',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'project-plan.md'),
|
||||
},
|
||||
{
|
||||
label: 'API Documentation',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'api-docs.md'),
|
||||
},
|
||||
{
|
||||
label: 'Tutorial',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'tutorial.md'),
|
||||
},
|
||||
{
|
||||
label: 'Release Notes',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'release-notes.md'),
|
||||
},
|
||||
{
|
||||
label: 'Comparison',
|
||||
click: () => mainWindow.webContents.send('load-template-menu', 'comparison.md'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Import Document...',
|
||||
accelerator: 'CmdOrCtrl+I',
|
||||
click: () => {
|
||||
const { importDocument } = require('../main');
|
||||
const { importDocument } = require('../index');
|
||||
importDocument();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Export',
|
||||
submenu: [
|
||||
{
|
||||
label: 'HTML', click: () => {
|
||||
const { exportFile } = require('../main');
|
||||
label: 'HTML',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('html');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'PDF', click: () => {
|
||||
const { exportFile } = require('../main');
|
||||
label: 'PDF',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('pdf');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'PDF (Enhanced)', click: () => {
|
||||
const { exportPDFViaWordTemplate } = require('../main');
|
||||
label: 'PDF (Enhanced)',
|
||||
click: () => {
|
||||
const { exportPDFViaWordTemplate } = require('../index');
|
||||
exportPDFViaWordTemplate();
|
||||
}, accelerator: 'Ctrl+Shift+P'
|
||||
},
|
||||
accelerator: 'Ctrl+Shift+P',
|
||||
},
|
||||
{
|
||||
label: 'DOCX', click: () => {
|
||||
const { exportFile } = require('../main');
|
||||
label: 'DOCX',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('docx');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'DOCX (Enhanced)', click: () => {
|
||||
const { exportWordWithTemplate } = require('../main');
|
||||
label: 'DOCX (Enhanced)',
|
||||
click: () => {
|
||||
const { exportWordWithTemplate } = require('../index');
|
||||
exportWordWithTemplate();
|
||||
}, accelerator: 'Ctrl+Shift+W'
|
||||
},
|
||||
accelerator: 'Ctrl+Shift+W',
|
||||
},
|
||||
{
|
||||
label: 'LaTeX',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('latex');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'RTF',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('rtf');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'ODT',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('odt');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'EPUB',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('epub');
|
||||
},
|
||||
},
|
||||
{ label: 'LaTeX', click: () => { const { exportFile } = require('../main'); exportFile('latex'); } },
|
||||
{ label: 'RTF', click: () => { const { exportFile } = require('../main'); exportFile('rtf'); } },
|
||||
{ label: 'ODT', click: () => { const { exportFile } = require('../main'); exportFile('odt'); } },
|
||||
{ label: 'EPUB', click: () => { const { exportFile } = require('../main'); exportFile('epub'); } },
|
||||
{ type: 'separator' },
|
||||
{ label: 'PowerPoint (PPTX)', click: () => { const { exportFile } = require('../main'); exportFile('pptx'); } },
|
||||
{ label: 'OpenDocument Presentation (ODP)', click: () => { const { exportFile } = require('../main'); exportFile('odp'); } },
|
||||
{
|
||||
label: 'PowerPoint (PPTX)',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('pptx');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'OpenDocument Presentation (ODP)',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('odp');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'CSV (Tables)', click: () => { const { exportSpreadsheet } = require('../main'); exportSpreadsheet('csv'); } },
|
||||
{
|
||||
label: 'CSV (Tables)',
|
||||
click: () => {
|
||||
const { exportSpreadsheet } = require('../index');
|
||||
exportSpreadsheet('csv');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'JSON (.json)', click: () => { const { exportFile } = require('../main'); exportFile('json'); } },
|
||||
{ label: 'YAML (.yaml)', click: () => { const { exportFile } = require('../main'); exportFile('yaml'); } },
|
||||
{ label: 'XML (.xml)', click: () => { const { exportFile } = require('../main'); exportFile('xml'); } },
|
||||
{
|
||||
label: 'JSON (.json)',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('json');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'YAML (.yaml)',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('yaml');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'XML (.xml)',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('xml');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Confluence Wiki (.txt)', click: () => { const { exportFile } = require('../main'); exportFile('confluence'); } },
|
||||
{ label: 'MOBI E-book (.mobi)', click: () => { const { exportFile } = require('../main'); exportFile('mobi'); } }
|
||||
]
|
||||
{
|
||||
label: 'Confluence Wiki (.txt)',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('confluence');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'MOBI E-book (.mobi)',
|
||||
click: () => {
|
||||
const { exportFile } = require('../index');
|
||||
exportFile('mobi');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Select Word Template...',
|
||||
click: () => {
|
||||
const { selectWordTemplate } = require('../main');
|
||||
const { selectWordTemplate } = require('../index');
|
||||
selectWordTemplate();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Template Settings...',
|
||||
click: () => {
|
||||
const { showTemplateSettings } = require('../main');
|
||||
const { showTemplateSettings } = require('../index');
|
||||
showTemplateSettings();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Header & Footer Settings...',
|
||||
@@ -177,14 +286,14 @@ function fileItems(mainWindow) {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('open-header-footer-dialog');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
|
||||
click: () => app.quit()
|
||||
}
|
||||
click: () => app.quit(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -193,12 +302,12 @@ function editItems(mainWindow) {
|
||||
{
|
||||
label: 'Undo',
|
||||
accelerator: 'CmdOrCtrl+Z',
|
||||
click: () => mainWindow.webContents.send('undo')
|
||||
click: () => mainWindow.webContents.send('undo'),
|
||||
},
|
||||
{
|
||||
label: 'Redo',
|
||||
accelerator: 'CmdOrCtrl+Shift+Z',
|
||||
click: () => mainWindow.webContents.send('redo')
|
||||
click: () => mainWindow.webContents.send('redo'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||||
@@ -209,8 +318,8 @@ function editItems(mainWindow) {
|
||||
{
|
||||
label: 'Find & Replace',
|
||||
accelerator: 'CmdOrCtrl+F',
|
||||
click: () => mainWindow.webContents.send('toggle-find')
|
||||
}
|
||||
click: () => mainWindow.webContents.send('toggle-find'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -219,54 +328,222 @@ function viewItems(mainWindow) {
|
||||
{
|
||||
label: 'Toggle Preview',
|
||||
accelerator: 'CmdOrCtrl+Shift+V',
|
||||
click: () => mainWindow.webContents.send('toggle-preview')
|
||||
click: () => mainWindow.webContents.send('toggle-preview'),
|
||||
},
|
||||
{
|
||||
label: 'Writing Analytics',
|
||||
accelerator: 'CmdOrCtrl+Shift+A',
|
||||
click: () => mainWindow.webContents.send('show-analytics-dialog'),
|
||||
},
|
||||
// NOTE: Command Palette removed — handled by useCommandStore
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Sidebar',
|
||||
submenu: [
|
||||
{ label: 'File Explorer', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
|
||||
{
|
||||
label: 'Files',
|
||||
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer'),
|
||||
},
|
||||
{
|
||||
label: 'Outline',
|
||||
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'outline'),
|
||||
},
|
||||
{
|
||||
label: 'Snippets',
|
||||
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets'),
|
||||
},
|
||||
{
|
||||
label: 'Templates',
|
||||
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates'),
|
||||
},
|
||||
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') },
|
||||
{ label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
|
||||
{ label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') }
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Bottom Panel (REPL)',
|
||||
click: () => mainWindow.webContents.send('toggle-bottom-panel')
|
||||
click: () => mainWindow.webContents.send('toggle-bottom-panel'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Theme',
|
||||
submenu: [
|
||||
{ label: 'Atom One Light (Default)', click: () => { const { setTheme } = require('../main'); setTheme('atomonelight'); } },
|
||||
{ label: 'GitHub Light', click: () => { const { setTheme } = require('../main'); setTheme('github'); } },
|
||||
{ label: 'Light', click: () => { const { setTheme } = require('../main'); setTheme('light'); } },
|
||||
{ label: 'Solarized Light', click: () => { const { setTheme } = require('../main'); setTheme('solarized'); } },
|
||||
{ label: 'Gruvbox Light', click: () => { const { setTheme } = require('../main'); setTheme('gruvbox-light'); } },
|
||||
{ label: 'Ayu Light', click: () => { const { setTheme } = require('../main'); setTheme('ayu-light'); } },
|
||||
{ label: 'Sepia', click: () => { const { setTheme } = require('../main'); setTheme('sepia'); } },
|
||||
{ label: 'Paper', click: () => { const { setTheme } = require('../main'); setTheme('paper'); } },
|
||||
{ label: 'Rose Pine Dawn', click: () => { const { setTheme } = require('../main'); setTheme('rosepine-dawn'); } },
|
||||
{ label: 'Concrete Light', click: () => { const { setTheme } = require('../main'); setTheme('concrete-light'); } },
|
||||
{
|
||||
label: 'Atom One Light (Default)',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('atomonelight');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'GitHub Light',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('github');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Light',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('light');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Solarized Light',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('solarized');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Gruvbox Light',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('gruvbox-light');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Ayu Light',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('ayu-light');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Sepia',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('sepia');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Paper',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('paper');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Rose Pine Dawn',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('rosepine-dawn');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Concrete Light',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('concrete-light');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Dark', click: () => { const { setTheme } = require('../main'); setTheme('dark'); } },
|
||||
{ label: 'One Dark', click: () => { const { setTheme } = require('../main'); setTheme('onedark'); } },
|
||||
{ label: 'Dracula', click: () => { const { setTheme } = require('../main'); setTheme('dracula'); } },
|
||||
{ label: 'Nord', click: () => { const { setTheme } = require('../main'); setTheme('nord'); } },
|
||||
{ label: 'Monokai', click: () => { const { setTheme } = require('../main'); setTheme('monokai'); } },
|
||||
{ label: 'Material', click: () => { const { setTheme } = require('../main'); setTheme('material'); } },
|
||||
{ label: 'Gruvbox Dark', click: () => { const { setTheme } = require('../main'); setTheme('gruvbox-dark'); } },
|
||||
{ label: 'Tokyo Night', click: () => { const { setTheme } = require('../main'); setTheme('tokyonight'); } },
|
||||
{ label: 'Palenight', click: () => { const { setTheme } = require('../main'); setTheme('palenight'); } },
|
||||
{ label: 'Ayu Dark', click: () => { const { setTheme } = require('../main'); setTheme('ayu-dark'); } },
|
||||
{ label: 'Ayu Mirage', click: () => { const { setTheme } = require('../main'); setTheme('ayu-mirage'); } },
|
||||
{ label: 'Oceanic Next', click: () => { const { setTheme } = require('../main'); setTheme('oceanic-next'); } },
|
||||
{ label: 'Cobalt2', click: () => { const { setTheme } = require('../main'); setTheme('cobalt2'); } },
|
||||
{ label: 'Concrete Dark', click: () => { const { setTheme } = require('../main'); setTheme('concrete-dark'); } },
|
||||
{ label: 'Concrete Warm', click: () => { const { setTheme } = require('../main'); setTheme('concrete-warm'); } }
|
||||
]
|
||||
{
|
||||
label: 'Dark',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('dark');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'One Dark',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('onedark');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Dracula',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('dracula');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Nord',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('nord');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Monokai',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('monokai');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Material',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('material');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Gruvbox Dark',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('gruvbox-dark');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Tokyo Night',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('tokyonight');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Palenight',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('palenight');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Ayu Dark',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('ayu-dark');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Ayu Mirage',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('ayu-mirage');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Oceanic Next',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('oceanic-next');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cobalt2',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('cobalt2');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Concrete Dark',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('concrete-dark');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Concrete Warm',
|
||||
click: () => {
|
||||
const { setTheme } = require('../index');
|
||||
setTheme('concrete-warm');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
@@ -275,19 +552,19 @@ function viewItems(mainWindow) {
|
||||
{
|
||||
label: 'Increase Font Size',
|
||||
accelerator: 'CmdOrCtrl+Shift+Plus',
|
||||
click: () => mainWindow.webContents.send('adjust-font-size', 'increase')
|
||||
click: () => mainWindow.webContents.send('adjust-font-size', 'increase'),
|
||||
},
|
||||
{
|
||||
label: 'Decrease Font Size',
|
||||
accelerator: 'CmdOrCtrl+Shift+-',
|
||||
click: () => mainWindow.webContents.send('adjust-font-size', 'decrease')
|
||||
click: () => mainWindow.webContents.send('adjust-font-size', 'decrease'),
|
||||
},
|
||||
{
|
||||
label: 'Reset Font Size',
|
||||
accelerator: 'CmdOrCtrl+Shift+0',
|
||||
click: () => mainWindow.webContents.send('adjust-font-size', 'reset')
|
||||
}
|
||||
]
|
||||
click: () => mainWindow.webContents.send('adjust-font-size', 'reset'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
@@ -296,7 +573,7 @@ function viewItems(mainWindow) {
|
||||
checked: true,
|
||||
click: (menuItem) => {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
@@ -304,13 +581,13 @@ function viewItems(mainWindow) {
|
||||
submenu: [
|
||||
{
|
||||
label: 'Load Custom Preview CSS...',
|
||||
click: () => mainWindow.webContents.send('load-custom-css')
|
||||
click: () => mainWindow.webContents.send('load-custom-css'),
|
||||
},
|
||||
{
|
||||
label: 'Clear Custom Preview CSS',
|
||||
click: () => mainWindow.webContents.send('clear-custom-css')
|
||||
}
|
||||
]
|
||||
click: () => mainWindow.webContents.send('clear-custom-css'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
||||
@@ -318,7 +595,7 @@ function viewItems(mainWindow) {
|
||||
{ type: 'separator' },
|
||||
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
|
||||
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
|
||||
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' }
|
||||
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -327,104 +604,104 @@ function batchItems(mainWindow) {
|
||||
{
|
||||
label: 'Convert Markdown Folder...',
|
||||
click: () => {
|
||||
const { showBatchConversionDialog } = require('../main');
|
||||
const { showBatchConversionDialog } = require('../index');
|
||||
showBatchConversionDialog();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Batch Image Conversion...',
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'image')
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'image'),
|
||||
},
|
||||
{
|
||||
label: 'Batch Audio Conversion...',
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'audio')
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'audio'),
|
||||
},
|
||||
{
|
||||
label: 'Batch Video Conversion...',
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'video')
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'video'),
|
||||
},
|
||||
{
|
||||
label: 'Batch PDF Conversion...',
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'pdf')
|
||||
}
|
||||
click: () => mainWindow.webContents.send('show-batch-converter', 'pdf'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function convertItems(mainWindow) {
|
||||
function convertItems(_mainWindow) {
|
||||
return [
|
||||
{
|
||||
label: 'Universal File Converter...',
|
||||
accelerator: 'CmdOrCtrl+Shift+C',
|
||||
click: () => {
|
||||
const { showUniversalConverterDialog } = require('../main');
|
||||
const { showUniversalConverterDialog } = require('../index');
|
||||
showUniversalConverterDialog();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function pdfEditorItems(mainWindow) {
|
||||
function pdfEditorItems(_mainWindow) {
|
||||
return [
|
||||
{
|
||||
label: 'Open PDF File...',
|
||||
accelerator: 'CmdOrCtrl+Shift+O',
|
||||
click: () => {
|
||||
const { openPdfFile } = require('../main');
|
||||
const { openPdfFile } = require('../index');
|
||||
openPdfFile();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Merge PDFs...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('merge');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Split PDF...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('split');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Compress PDF...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('compress');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Rotate Pages...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('rotate');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete Pages...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('delete');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Reorder Pages...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('reorder');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Add Watermark...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('watermark');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
@@ -433,34 +710,34 @@ function pdfEditorItems(mainWindow) {
|
||||
{
|
||||
label: 'Add Password Protection...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('encrypt');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Remove Password...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('decrypt');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Set Permissions...',
|
||||
click: () => {
|
||||
const { showPDFEditorDialog } = require('../main');
|
||||
const { showPDFEditorDialog } = require('../index');
|
||||
showPDFEditorDialog('permissions');
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'About PDF Editor',
|
||||
click: () => {
|
||||
const { showAboutDialog } = require('../main');
|
||||
const { showAboutDialog } = require('../index');
|
||||
showAboutDialog();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -472,41 +749,41 @@ function toolsItems(mainWindow) {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Document Compare',
|
||||
click: () => mainWindow.webContents.send('show-document-compare')
|
||||
}
|
||||
click: () => mainWindow.webContents.send('show-document-compare'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function helpItems(mainWindow) {
|
||||
function helpItems(_mainWindow) {
|
||||
return [
|
||||
{
|
||||
label: 'About MarkdownConverter',
|
||||
click: () => {
|
||||
const { showAboutDialog } = require('../main');
|
||||
const { showAboutDialog } = require('../index');
|
||||
showAboutDialog();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Dependencies & Requirements',
|
||||
click: () => {
|
||||
const { showDependenciesDialog } = require('../main');
|
||||
const { showDependenciesDialog } = require('../index');
|
||||
showDependenciesDialog();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Documentation',
|
||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter')
|
||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter'),
|
||||
},
|
||||
{
|
||||
label: 'Report Issue',
|
||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues')
|
||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues'),
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases')
|
||||
}
|
||||
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -518,5 +795,5 @@ module.exports = {
|
||||
convertItems,
|
||||
pdfEditorItems,
|
||||
toolsItems,
|
||||
helpItems
|
||||
};
|
||||
helpItems,
|
||||
};
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ const store = {
|
||||
} catch {}
|
||||
settings[key] = value;
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = store;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const MAX_DUMPS = 20;
|
||||
|
||||
class CrashWriter {
|
||||
constructor(dir) {
|
||||
this.dir = dir;
|
||||
this._counter = 0;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
handleUncaught(err, kind) {
|
||||
try {
|
||||
const filename = `${Date.now()}-${++this._counter}-${kind}.json`;
|
||||
const payload = {
|
||||
kind,
|
||||
message: err && err.message,
|
||||
stack: err && err.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(path.join(this.dir, filename), JSON.stringify(payload, null, 2));
|
||||
this._prune();
|
||||
} catch (writeErr) {
|
||||
console.error('[crash-writer] dump write failed:', writeErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
_prune() {
|
||||
const files = fs.readdirSync(this.dir).sort();
|
||||
while (files.length > MAX_DUMPS) {
|
||||
const oldest = files.shift();
|
||||
try {
|
||||
fs.unlinkSync(path.join(this.dir, oldest));
|
||||
} catch (_unlinkErr) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list() {
|
||||
if (!fs.existsSync(this.dir)) return [];
|
||||
return fs
|
||||
.readdirSync(this.dir)
|
||||
.filter((f) => f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((filename) => {
|
||||
const full = path.join(this.dir, filename);
|
||||
const data = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
||||
return { filename, ...data };
|
||||
});
|
||||
}
|
||||
|
||||
delete(filename) {
|
||||
const full = path.join(this.dir, filename);
|
||||
if (fs.existsSync(full)) fs.unlinkSync(full);
|
||||
}
|
||||
|
||||
path() {
|
||||
return this.dir;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { CrashWriter };
|
||||
@@ -0,0 +1,8 @@
|
||||
function feedConfigFor(channel) {
|
||||
if (channel === 'github') {
|
||||
return { provider: 'github', owner: 'amitwh', repo: 'markdown-converter' };
|
||||
}
|
||||
return { provider: 'generic', url: 'https://updates.concreteinfo.co.in/v5' };
|
||||
}
|
||||
|
||||
module.exports = { feedConfigFor };
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class MigrationRunner {
|
||||
constructor({ dir, transform }) {
|
||||
this.dir = dir;
|
||||
this.transform = transform;
|
||||
this.file = path.join(dir, 'settings.json');
|
||||
this.backup = path.join(dir, 'settings.v4.bak.json');
|
||||
}
|
||||
|
||||
run() {
|
||||
if (!fs.existsSync(this.file)) {
|
||||
this._writeDefaults();
|
||||
return 'fresh';
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
|
||||
if (raw && raw['migration.version'] === 5) {
|
||||
return 'skipped';
|
||||
}
|
||||
try {
|
||||
const v5 = this.transform(raw);
|
||||
fs.copyFileSync(this.file, this.backup);
|
||||
fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2));
|
||||
return 'migrated';
|
||||
} catch (err) {
|
||||
console.error('[migration-runner] transform failed:', err.message);
|
||||
// Back up the original and write v5 marker so future launches skip migration.
|
||||
// Without this, every launch would fail again and the user stays on defaults.
|
||||
fs.copyFileSync(this.file, this.backup);
|
||||
fs.writeFileSync(this.file, JSON.stringify({ ...raw, 'migration.version': 5 }, null, 2));
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
_writeDefaults() {
|
||||
const v5 = this.transform({});
|
||||
fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MigrationRunner };
|
||||
@@ -0,0 +1,117 @@
|
||||
// Mirror of src/renderer/lib/migrations/v4-to-v5.ts, plain JS, for main-process use.
|
||||
// Kept in sync manually; if the renderer transform changes, update this too.
|
||||
const { z } = require('zod');
|
||||
|
||||
const v4SettingsSchema = z
|
||||
.object({
|
||||
theme: z.enum(['light', 'dark', 'auto']).default('auto'),
|
||||
customCss: z.string().optional().nullable(),
|
||||
recentFiles: z.array(z.string()).default([]),
|
||||
editorFontSize: z.number().min(10).max(28).default(14),
|
||||
keyBindings: z.record(z.string(), z.string()).optional(),
|
||||
snippets: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const v5SettingsSchema = z
|
||||
.object({
|
||||
fontSize: z.number().default(14),
|
||||
tabSize: z.number().default(4),
|
||||
lineNumbers: z.boolean().default(true),
|
||||
wordWrap: z.boolean().default(true),
|
||||
minimap: z.boolean().default(true),
|
||||
theme: z.enum(['light', 'dark', 'system']).default('system'),
|
||||
accentColor: z.string().default('brand'),
|
||||
fontFamily: z.string().default('system'),
|
||||
pdfFormat: z.string().default('a4'),
|
||||
pdfMargins: z.string().default('normal'),
|
||||
pdfEmbedFonts: z.boolean().default(true),
|
||||
docxTemplate: z.string().default('standard'),
|
||||
docxCustomTemplatePath: z.string().nullable().default(null),
|
||||
replOpen: z.boolean().default(false),
|
||||
breadcrumbSymbols: z.boolean().default(true),
|
||||
htmlHighlightStyle: z.string().default('github'),
|
||||
renderTablesAsAscii: z.boolean().default(false),
|
||||
welcomeDismissed: z.boolean().default(false),
|
||||
editorFontSize: z.number().default(14),
|
||||
customCssPath: z.string().nullable().default(null),
|
||||
userBindings: z.record(z.string(), z.string()).default({}),
|
||||
updateChannel: z.enum(['github', 'concreteinfo']).default('github'),
|
||||
autoCheckUpdates: z.boolean().default(true),
|
||||
firstRun: z.boolean().default(true),
|
||||
'migration.version': z.literal(5).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun'];
|
||||
const v5ThemeValues = ['light', 'dark', 'system'];
|
||||
|
||||
function isAlreadyV5(data) {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
if (data['migration.version'] === 5) return true;
|
||||
// Check for v5-only fields — a v4 file would never have these
|
||||
return v5OnlyFields.some((f) => f in data);
|
||||
}
|
||||
|
||||
function normalizeAlreadyV5(data) {
|
||||
// Some earlier v5 builds wrote a legacy theme value (e.g. "ayu-light")
|
||||
// under the v5 marker. Trusting the marker blindly broke the renderer's
|
||||
// zod schema on every launch. Always normalize theme against the v5 enum
|
||||
// before returning, so persisted files are always valid v5.
|
||||
const out = { ...data };
|
||||
if (typeof out.theme !== 'string' || !v5ThemeValues.includes(out.theme)) {
|
||||
out.theme = 'system';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const v5SettingsShape = {
|
||||
fontSize: 14,
|
||||
tabSize: 4,
|
||||
lineNumbers: true,
|
||||
wordWrap: true,
|
||||
minimap: true,
|
||||
theme: 'system',
|
||||
accentColor: 'brand',
|
||||
fontFamily: 'system',
|
||||
pdfFormat: 'a4',
|
||||
pdfMargins: 'normal',
|
||||
pdfEmbedFonts: true,
|
||||
docxTemplate: 'standard',
|
||||
docxCustomTemplatePath: null,
|
||||
replOpen: false,
|
||||
breadcrumbSymbols: true,
|
||||
htmlHighlightStyle: 'github',
|
||||
renderTablesAsAscii: false,
|
||||
welcomeDismissed: false,
|
||||
editorFontSize: 14,
|
||||
customCssPath: null,
|
||||
userBindings: {},
|
||||
updateChannel: 'github',
|
||||
autoCheckUpdates: true,
|
||||
firstRun: true,
|
||||
};
|
||||
|
||||
function migrateV4ToV5(v4) {
|
||||
// If data already looks like v5 (has migration.version=5 or v5-only fields),
|
||||
// normalize and validate against the v5 schema. This handles the case where
|
||||
// a buggy v5 run wrote v5 fields without the marker, AND the case where a
|
||||
// v5 file has a legacy theme value (e.g. "ayu-light") that would otherwise
|
||||
// be rejected by the renderer.
|
||||
if (isAlreadyV5(v4)) {
|
||||
return v5SettingsSchema.parse(normalizeAlreadyV5(v4));
|
||||
}
|
||||
const parsed = v4SettingsSchema.parse(v4 || {});
|
||||
return {
|
||||
...v5SettingsShape,
|
||||
...parsed,
|
||||
theme: parsed.theme === 'auto' ? 'system' : parsed.theme,
|
||||
customCssPath: parsed.customCss ?? null,
|
||||
recentFiles: parsed.recentFiles,
|
||||
editorFontSize: parsed.editorFontSize,
|
||||
userBindings: parsed.keyBindings ?? v5SettingsShape.userBindings,
|
||||
snippets: parsed.snippets,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { migrateV4ToV5, v5SettingsShape };
|
||||
@@ -0,0 +1,43 @@
|
||||
const { EventEmitter } = require('events');
|
||||
const DEBOUNCE_MS = 60_000;
|
||||
|
||||
class UpdaterService extends EventEmitter {
|
||||
constructor(autoUpdater) {
|
||||
super();
|
||||
this.autoUpdater = autoUpdater;
|
||||
this.state = 'idle';
|
||||
this.lastCheckAt = 0;
|
||||
this._wire();
|
||||
}
|
||||
|
||||
_wire() {
|
||||
const au = this.autoUpdater;
|
||||
au.on('checking-for-update', () => this._emit({ state: 'checking' }));
|
||||
au.on('update-available', (info) => this._emit({ state: 'available', version: info.version }));
|
||||
au.on('download-progress', (p) => this._emit({ state: 'downloading', percent: p.percent }));
|
||||
au.on('update-downloaded', (info) => this._emit({ state: 'ready', version: info.version }));
|
||||
au.on('update-not-available', () => this._emit({ state: 'idle' }));
|
||||
au.on('error', (err) => {
|
||||
const code =
|
||||
err && /ENOTFOUND|ETIMEDOUT|ECONNREFUSED/.test(err.message) ? 'NETWORK' : 'UNKNOWN';
|
||||
this._emit({ state: 'error', code });
|
||||
});
|
||||
}
|
||||
|
||||
_emit(payload) {
|
||||
this.state = payload.state;
|
||||
this.emit('status', payload);
|
||||
}
|
||||
|
||||
async check() {
|
||||
if (Date.now() - this.lastCheckAt < DEBOUNCE_MS) return;
|
||||
this.lastCheckAt = Date.now();
|
||||
await this.autoUpdater.checkForUpdates();
|
||||
}
|
||||
|
||||
install() {
|
||||
this.autoUpdater.quitAndInstall();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UpdaterService };
|
||||
+10
-6
@@ -10,7 +10,7 @@ function getAllowedDirectories() {
|
||||
app.getPath('desktop'),
|
||||
app.getPath('downloads'),
|
||||
app.getPath('home'),
|
||||
process.cwd() // Current working directory
|
||||
process.cwd(), // Current working directory
|
||||
].filter(Boolean); // Remove any undefined paths
|
||||
return dirs;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ function validatePath(filePath) {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = path.resolve(filePath);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function resolveWritablePath(filePath) {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = path.normalize(path.resolve(filePath));
|
||||
} catch (err) {
|
||||
} catch (_err2) {
|
||||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||
}
|
||||
|
||||
@@ -88,9 +88,13 @@ function resolveWritablePath(filePath) {
|
||||
function isPathAccessible(resolvedPath) {
|
||||
// Block access to sensitive system directories
|
||||
const blockedPaths = [
|
||||
'/etc/passwd', '/etc/shadow', '/root',
|
||||
'C:\\Windows\\System32', 'C:\\Windows\\System',
|
||||
'/System', '/private/etc'
|
||||
'/etc/passwd',
|
||||
'/etc/shadow',
|
||||
'/root',
|
||||
'C:\\Windows\\System32',
|
||||
'C:\\Windows\\System',
|
||||
'/System',
|
||||
'/private/etc',
|
||||
];
|
||||
|
||||
const normalizedPath = resolvedPath.toLowerCase();
|
||||
|
||||
+63
-17
@@ -1,8 +1,9 @@
|
||||
// src/main/window/index.js
|
||||
// Main window creation
|
||||
|
||||
const { BrowserWindow } = require('electron');
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const state = require('./state');
|
||||
const menu = require('../menu');
|
||||
|
||||
@@ -13,16 +14,54 @@ function createMainWindow() {
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
show: false,
|
||||
show: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
spellcheck: true
|
||||
// The preload script exposes `window.electronAPI` — the only IPC
|
||||
// bridge the renderer uses. Without this, every renderer call returns
|
||||
// CHANNEL_MISSING and file/folder/save all silently no-op.
|
||||
preload: path.join(__dirname, '../../preload.js'),
|
||||
// contextIsolation MUST be true for the preload's contextBridge
|
||||
// exposeInMainWorld call to succeed. Without it, the preload throws
|
||||
// on load and the renderer never gets the IPC bridge.
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: true,
|
||||
},
|
||||
icon: path.join(__dirname, '../../assets/icon.png')
|
||||
icon: path.join(__dirname, '../../../assets/icon.png'),
|
||||
});
|
||||
|
||||
win.loadFile(path.join(__dirname, '../../renderer/index.html'));
|
||||
// Dev (Vite): load the running dev server so .tsx is transformed on the fly.
|
||||
// Production (running from dist/ directly): load the built renderer at the
|
||||
// relative path from this file.
|
||||
// Production (packaged installer build): the renderer ships under
|
||||
// process.resourcesPath/renderer (configured via build.extraResources in
|
||||
// package.json) — not inside the asar.
|
||||
// VITE_DEV_SERVER_URL is set by `npm run dev` via cross-env.
|
||||
// app.isPackaged is set by electron-builder for installer builds.
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (!app.isPackaged && devServerUrl) {
|
||||
console.log('[WINDOW] Dev mode — loading', devServerUrl);
|
||||
win.loadURL(devServerUrl);
|
||||
} else {
|
||||
const rendererIndex = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'renderer', 'index.html')
|
||||
: path.join(__dirname, '../../../dist/renderer/index.html');
|
||||
|
||||
if (app.isPackaged) {
|
||||
try {
|
||||
fs.accessSync(rendererIndex);
|
||||
} catch {
|
||||
console.error(
|
||||
'[WINDOW] Renderer not found at',
|
||||
rendererIndex,
|
||||
'— did you run `npm run build:renderer` before packaging?'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[WINDOW] Production mode — loading', rendererIndex);
|
||||
win.loadFile(rendererIndex);
|
||||
}
|
||||
|
||||
// Show window only after content is ready — avoids blank flash
|
||||
win.once('ready-to-show', () => {
|
||||
@@ -31,8 +70,10 @@ function createMainWindow() {
|
||||
|
||||
menu.register(win);
|
||||
|
||||
win.on('closed', () => {
|
||||
state.save(win);
|
||||
// Use 'close' (fires before destruction) — 'closed' fires after the
|
||||
// BrowserWindow object is destroyed, so getBounds() would throw.
|
||||
win.on('close', () => {
|
||||
if (!win.isDestroyed()) state.save(win);
|
||||
});
|
||||
|
||||
// Spell check context menu
|
||||
@@ -43,18 +84,23 @@ function createMainWindow() {
|
||||
// Add spell check suggestions
|
||||
if (params.misspelledWord) {
|
||||
for (const suggestion of params.dictionarySuggestions) {
|
||||
ctxMenu.append(new MenuItem({
|
||||
label: suggestion,
|
||||
click: () => win.webContents.replaceMisspelling(suggestion)
|
||||
}));
|
||||
ctxMenu.append(
|
||||
new MenuItem({
|
||||
label: suggestion,
|
||||
click: () => win.webContents.replaceMisspelling(suggestion),
|
||||
})
|
||||
);
|
||||
}
|
||||
if (params.dictionarySuggestions.length > 0) {
|
||||
ctxMenu.append(new MenuItem({ type: 'separator' }));
|
||||
}
|
||||
ctxMenu.append(new MenuItem({
|
||||
label: 'Add to Dictionary',
|
||||
click: () => win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
}));
|
||||
ctxMenu.append(
|
||||
new MenuItem({
|
||||
label: 'Add to Dictionary',
|
||||
click: () =>
|
||||
win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
|
||||
})
|
||||
);
|
||||
ctxMenu.append(new MenuItem({ type: 'separator' }));
|
||||
}
|
||||
|
||||
|
||||
+611
-547
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,7 @@
|
||||
"description": "Demonstrates the plugin system. Safe to delete.",
|
||||
"icon": "puzzle",
|
||||
"extensionPoints": {
|
||||
"commands": [
|
||||
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
|
||||
]
|
||||
"commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
|
||||
},
|
||||
"settings": []
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class WritingStudioPlugin extends PluginAPI {
|
||||
this.context = context;
|
||||
|
||||
this.sprintEngine = new SprintEngine({
|
||||
onEvent: (name, data) => context.events.emit(name, data)
|
||||
onEvent: (name, data) => context.events.emit(name, data),
|
||||
});
|
||||
this.goalTracker = new GoalTracker(context.settings);
|
||||
this.snapshotManager = new SnapshotManager(context.settings);
|
||||
@@ -17,14 +17,14 @@ class WritingStudioPlugin extends PluginAPI {
|
||||
readFile: (p) => context.ipc.invoke('read-file', p),
|
||||
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
||||
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
||||
listDir: (p) => context.ipc.invoke('list-directory', p)
|
||||
listDir: (p) => context.ipc.invoke('list-directory', p),
|
||||
});
|
||||
|
||||
this._engines = {
|
||||
sprint: this.sprintEngine,
|
||||
goals: this.goalTracker,
|
||||
snapshots: this.snapshotManager,
|
||||
projects: this.projectManager
|
||||
projects: this.projectManager,
|
||||
};
|
||||
|
||||
this._registerCommands(context);
|
||||
@@ -34,59 +34,89 @@ class WritingStudioPlugin extends PluginAPI {
|
||||
_registerCommands(context) {
|
||||
const { sprintEngine, snapshotManager, goalTracker } = this;
|
||||
|
||||
context.commands.register('start-sprint', 'Studio: Start Sprint', () => {
|
||||
const duration = context.settings.get('sprintDuration') || 25;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
sprintEngine.start(duration, words);
|
||||
}, 'Ctrl+Alt+S');
|
||||
context.commands.register(
|
||||
'start-sprint',
|
||||
'Studio: Start Sprint',
|
||||
() => {
|
||||
const duration = context.settings.get('sprintDuration') || 25;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
sprintEngine.start(duration, words);
|
||||
},
|
||||
'Ctrl+Alt+S'
|
||||
);
|
||||
|
||||
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => {
|
||||
if (!sprintEngine.isActive()) return;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
const result = sprintEngine.stop(words);
|
||||
goalTracker.addWords(result.wordDelta);
|
||||
context.events.emit('sprint:stopped', result);
|
||||
}, 'Ctrl+Alt+Shift+S');
|
||||
context.commands.register(
|
||||
'stop-sprint',
|
||||
'Studio: Stop Sprint',
|
||||
() => {
|
||||
if (!sprintEngine.isActive()) return;
|
||||
const content = context.editor.getContent() || '';
|
||||
const words = content.split(/\s+/).filter(Boolean).length;
|
||||
const result = sprintEngine.stop(words);
|
||||
goalTracker.addWords(result.wordDelta);
|
||||
context.events.emit('sprint:stopped', result);
|
||||
},
|
||||
'Ctrl+Alt+Shift+S'
|
||||
);
|
||||
|
||||
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => {
|
||||
const content = context.editor.getContent() || '';
|
||||
snapshotManager.create(content, 'manual');
|
||||
context.events.emit('snapshot:created', {});
|
||||
}, 'Ctrl+Alt+N');
|
||||
context.commands.register(
|
||||
'take-snapshot',
|
||||
'Studio: Take Snapshot',
|
||||
() => {
|
||||
const content = context.editor.getContent() || '';
|
||||
snapshotManager.create(content, 'manual');
|
||||
context.events.emit('snapshot:created', {});
|
||||
},
|
||||
'Ctrl+Alt+N'
|
||||
);
|
||||
|
||||
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => {
|
||||
const snaps = snapshotManager.list();
|
||||
if (snaps.length === 0) return;
|
||||
const content = snapshotManager.restore(snaps[0].id);
|
||||
context.editor.insertAtCursor(content);
|
||||
}, 'Ctrl+Alt+Z');
|
||||
context.commands.register(
|
||||
'restore-last-snapshot',
|
||||
'Studio: Restore Last Snapshot',
|
||||
() => {
|
||||
const snaps = snapshotManager.list();
|
||||
if (snaps.length === 0) return;
|
||||
const content = snapshotManager.restore(snaps[0].id);
|
||||
context.editor.insertAtCursor(content);
|
||||
},
|
||||
'Ctrl+Alt+Z'
|
||||
);
|
||||
|
||||
context.commands.register('new-project', 'Studio: New Project', () => {
|
||||
context.events.emit('studio:new-project', {});
|
||||
});
|
||||
|
||||
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => {
|
||||
context.events.emit('studio:compile', {});
|
||||
}, 'Ctrl+Alt+E');
|
||||
context.commands.register(
|
||||
'compile-manuscript',
|
||||
'Studio: Compile Manuscript',
|
||||
() => {
|
||||
context.events.emit('studio:compile', {});
|
||||
},
|
||||
'Ctrl+Alt+E'
|
||||
);
|
||||
|
||||
context.commands.register('proofread-document', 'Studio: Proofread Document', () => {
|
||||
if (context.events.hasHandler('ai:analyze')) {
|
||||
const content = context.editor.getContent() || '';
|
||||
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
||||
}
|
||||
}, 'Ctrl+Alt+G');
|
||||
context.commands.register(
|
||||
'proofread-document',
|
||||
'Studio: Proofread Document',
|
||||
() => {
|
||||
if (context.events.hasHandler('ai:analyze')) {
|
||||
const content = context.editor.getContent() || '';
|
||||
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
||||
}
|
||||
},
|
||||
'Ctrl+Alt+G'
|
||||
);
|
||||
}
|
||||
|
||||
_registerStatusBar(context) {
|
||||
context.statusBar.registerIndicator('word-goal', {
|
||||
text: '0/1000',
|
||||
tooltip: 'Daily word goal progress'
|
||||
tooltip: 'Daily word goal progress',
|
||||
});
|
||||
context.statusBar.registerIndicator('sprint-timer', {
|
||||
text: '',
|
||||
tooltip: 'Writing sprint timer'
|
||||
tooltip: 'Writing sprint timer',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,17 +15,34 @@
|
||||
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
||||
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
||||
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" },
|
||||
{
|
||||
"id": "restore-last-snapshot",
|
||||
"label": "Studio: Restore Last Snapshot",
|
||||
"shortcut": "Ctrl+Alt+Z"
|
||||
},
|
||||
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
||||
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" },
|
||||
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" }
|
||||
{
|
||||
"id": "compile-manuscript",
|
||||
"label": "Studio: Compile Manuscript",
|
||||
"shortcut": "Ctrl+Alt+E"
|
||||
},
|
||||
{
|
||||
"id": "proofread-document",
|
||||
"label": "Studio: Proofread Document",
|
||||
"shortcut": "Ctrl+Alt+G"
|
||||
}
|
||||
],
|
||||
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
||||
},
|
||||
"settings": [
|
||||
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
||||
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" },
|
||||
{
|
||||
"key": "autoSnapshotInterval",
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"label": "Auto-snapshot interval (min, 0=off)"
|
||||
},
|
||||
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ function renderGoalsPanel(container, { engines, settings }) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ws-stat-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
||||
label.textContent =
|
||||
progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'ws-pct';
|
||||
pct.textContent = progress.pct + '%';
|
||||
@@ -81,7 +82,7 @@ function renderGoalsPanel(container, { engines, settings }) {
|
||||
|
||||
const chart = document.createElement('div');
|
||||
chart.className = 'ws-chart';
|
||||
const maxWords = Math.max(...last30.map(d => d.words), 1);
|
||||
const maxWords = Math.max(...last30.map((d) => d.words), 1);
|
||||
for (const day of last30) {
|
||||
const barEl = document.createElement('div');
|
||||
const height = Math.max(2, (day.words / maxWords) * 60);
|
||||
|
||||
@@ -69,7 +69,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ws-stat-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
||||
label.textContent =
|
||||
stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
||||
const pct = document.createElement('span');
|
||||
pct.className = 'ws-pct';
|
||||
pct.textContent = stats.pctComplete + '%';
|
||||
|
||||
@@ -42,7 +42,7 @@ function renderProofreadPanel(container, { events, editor }) {
|
||||
if (result && result.issues) {
|
||||
renderIssues(issuesList, result.issues);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -80,7 +80,10 @@ function renderIssues(container, issues) {
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ws-issue-actions';
|
||||
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) {
|
||||
for (const [_action, label] of [
|
||||
['accept', 'Accept'],
|
||||
['dismiss', 'Dismiss'],
|
||||
]) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'ws-btn ws-btn-sm';
|
||||
actionBtn.textContent = label;
|
||||
|
||||
@@ -45,7 +45,11 @@ function renderSnapshotsPanel(container, { engines, editor }) {
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ws-snapshot-actions';
|
||||
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) {
|
||||
for (const [action, text, cls] of [
|
||||
['restore', 'Restore', ''],
|
||||
['diff', 'Diff', ''],
|
||||
['delete', 'Delete', 'ws-btn-danger'],
|
||||
]) {
|
||||
const actionBtn = document.createElement('button');
|
||||
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||
actionBtn.textContent = text;
|
||||
|
||||
@@ -12,7 +12,7 @@ class ProjectManager {
|
||||
type: opts.type || 'manuscript',
|
||||
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
||||
chapters: [],
|
||||
metadata: opts.metadata || {}
|
||||
metadata: opts.metadata || {},
|
||||
};
|
||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||
return project;
|
||||
@@ -66,7 +66,7 @@ class ProjectManager {
|
||||
totalWords,
|
||||
chapterCount: project.chapters.length,
|
||||
targetWords: target,
|
||||
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0
|
||||
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class SnapshotManager {
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
wordCount: content.split(/\s+/).filter(Boolean).length,
|
||||
label
|
||||
label,
|
||||
};
|
||||
snaps.unshift(snap);
|
||||
this._saveAll(snaps);
|
||||
@@ -36,7 +36,7 @@ class SnapshotManager {
|
||||
}
|
||||
|
||||
getById(id) {
|
||||
return this._getAll().find(s => s.id === id) || null;
|
||||
return this._getAll().find((s) => s.id === id) || null;
|
||||
}
|
||||
|
||||
restore(id) {
|
||||
@@ -46,7 +46,7 @@ class SnapshotManager {
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
const snaps = this._getAll().filter(s => s.id !== id);
|
||||
const snaps = this._getAll().filter((s) => s.id !== id);
|
||||
this._saveAll(snaps);
|
||||
}
|
||||
|
||||
@@ -59,8 +59,12 @@ class SnapshotManager {
|
||||
const newSet = new Set(newLines);
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const line of newLines) { if (!oldSet.has(line)) added++; }
|
||||
for (const line of oldLines) { if (!newSet.has(line)) removed++; }
|
||||
for (const line of newLines) {
|
||||
if (!oldSet.has(line)) added++;
|
||||
}
|
||||
for (const line of oldLines) {
|
||||
if (!newSet.has(line)) removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ class SprintEngine {
|
||||
}
|
||||
}
|
||||
|
||||
isActive() { return this._active; }
|
||||
isActive() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
getRemaining() {
|
||||
if (!this._active) return 0;
|
||||
|
||||
@@ -12,10 +12,11 @@ class PluginContext {
|
||||
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
||||
*/
|
||||
constructor(deps) {
|
||||
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps;
|
||||
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
|
||||
deps;
|
||||
|
||||
this.sidebar = {
|
||||
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
|
||||
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
|
||||
};
|
||||
|
||||
this.commands = {
|
||||
@@ -28,41 +29,45 @@ class PluginContext {
|
||||
}
|
||||
};
|
||||
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
this.statusBar = {
|
||||
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
|
||||
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
|
||||
};
|
||||
|
||||
this.settings = {
|
||||
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
||||
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
||||
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb)
|
||||
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb),
|
||||
};
|
||||
|
||||
this.editor = {
|
||||
getContent: () => editor.getContent(),
|
||||
getSelection: () => editor.getSelection(),
|
||||
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||
onContentChanged: (cb) => editor.onContentChanged(cb)
|
||||
onContentChanged: (cb) => editor.onContentChanged(cb),
|
||||
};
|
||||
|
||||
this.events = {
|
||||
on: (event, handler) => eventBus.on(event, handler),
|
||||
off: (event, handler) => eventBus.off(event, handler),
|
||||
emit: (event, payload) => eventBus.emit(event, payload),
|
||||
hasHandler: (event) => eventBus.hasHandler(event)
|
||||
hasHandler: (event) => eventBus.hasHandler(event),
|
||||
};
|
||||
|
||||
this.ipc = {
|
||||
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
||||
on: (channel, handler) => ipc.on(channel, handler)
|
||||
on: (channel, handler) => ipc.on(channel, handler),
|
||||
};
|
||||
|
||||
this.exports = {
|
||||
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
|
||||
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
|
||||
registerPreHook: (handler) => {
|
||||
if (exportHooks) exportHooks.preHooks.push(handler);
|
||||
},
|
||||
registerPostHook: (handler) => {
|
||||
if (exportHooks) exportHooks.postHooks.push(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ class PluginLoader {
|
||||
const loaded = require(indexPath);
|
||||
PluginClass = loaded.Plugin || loaded.default || null;
|
||||
} catch (err) {
|
||||
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
|
||||
console.error(
|
||||
`[PluginLoader] Failed to load index.js for "${manifest.id}":`,
|
||||
err.message
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +49,7 @@ class PluginLoader {
|
||||
description: manifest.description,
|
||||
manifest,
|
||||
PluginClass,
|
||||
dir: pluginDir
|
||||
dir: pluginDir,
|
||||
});
|
||||
this.loadedIds.add(manifest.id);
|
||||
} catch (err) {
|
||||
|
||||
@@ -32,7 +32,7 @@ class PluginRegistry {
|
||||
settings: this.deps.settings,
|
||||
editor: this.deps.editor,
|
||||
ipc: this.deps.ipc,
|
||||
exportHooks: this.exportHooks
|
||||
exportHooks: this.exportHooks,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ class SettingsStore {
|
||||
this.backend.set(key, value);
|
||||
}
|
||||
|
||||
onChanged(key, callback) {
|
||||
onChanged(_key, _callback) {
|
||||
// Deferred: plugins read settings on init/activate for MVP.
|
||||
// Full change notification requires IPC watcher in main process.
|
||||
}
|
||||
|
||||
+89
-17
@@ -98,12 +98,16 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'list-directory',
|
||||
'read-file',
|
||||
'write-file',
|
||||
'write-buffer',
|
||||
'read-buffer',
|
||||
'delete-file',
|
||||
'ensure-directory',
|
||||
'path-exists',
|
||||
'is-directory',
|
||||
'copy-path',
|
||||
'move-path',
|
||||
'pick-folder',
|
||||
'pick-file',
|
||||
|
||||
// Git
|
||||
'git-status',
|
||||
@@ -129,12 +133,28 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'menu-open',
|
||||
'export',
|
||||
|
||||
// App lifecycle
|
||||
'app:quit',
|
||||
'app:open-external',
|
||||
'app:show-save-dialog',
|
||||
'get-app-version',
|
||||
|
||||
// Updater
|
||||
'updater:check',
|
||||
'updater:install',
|
||||
'updater:get-state',
|
||||
|
||||
// Crash reporter
|
||||
'crash:read',
|
||||
'crash:open-dir',
|
||||
'crash:delete',
|
||||
|
||||
// Git diff
|
||||
'git-diff',
|
||||
|
||||
// Plugin settings
|
||||
'plugin-settings:get',
|
||||
'plugin-settings:set'
|
||||
'plugin-settings:set',
|
||||
];
|
||||
|
||||
const ALLOWED_RECEIVE_CHANNELS = [
|
||||
@@ -217,15 +237,25 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
// Batch converter
|
||||
'show-batch-converter',
|
||||
|
||||
// Document compare
|
||||
'show-document-compare',
|
||||
|
||||
// v4 menu-triggered events
|
||||
'load-template-menu',
|
||||
'toggle-sidebar-panel',
|
||||
'toggle-bottom-panel',
|
||||
'print-preview',
|
||||
'print-preview-styled',
|
||||
'clear-recent-files',
|
||||
|
||||
// Updater
|
||||
'updater:status',
|
||||
'show-analytics-dialog',
|
||||
|
||||
// File dialog / directory listing
|
||||
'list-directory',
|
||||
'pick-folder',
|
||||
'pick-file'
|
||||
'pick-file',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -327,6 +357,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
rendererReady: () => ipcRenderer.send('renderer-ready'),
|
||||
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
|
||||
readBuffer: (filePath) => ipcRenderer.invoke('read-buffer', { path: filePath }),
|
||||
writeBuffer: (filePath, buffer) => ipcRenderer.invoke('write-buffer', { path: filePath, buffer }),
|
||||
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
|
||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||
@@ -335,23 +367,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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')
|
||||
pickFile: () => ipcRenderer.invoke('pick-file'),
|
||||
},
|
||||
|
||||
// Theme Operations
|
||||
theme: {
|
||||
get: () => ipcRenderer.send('get-theme')
|
||||
get: () => ipcRenderer.send('get-theme'),
|
||||
},
|
||||
|
||||
// Print Operations
|
||||
print: {
|
||||
doPrint: (options) => ipcRenderer.send('do-print', options)
|
||||
doPrint: (options) => ipcRenderer.send('do-print', options),
|
||||
show: (payload) => ipcRenderer.send('do-print', payload),
|
||||
},
|
||||
|
||||
// Export Operations
|
||||
export: {
|
||||
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
|
||||
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format })
|
||||
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }),
|
||||
},
|
||||
|
||||
// Batch Conversion
|
||||
@@ -359,7 +392,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
convert: (inputFolder, outputFolder, format, options) => {
|
||||
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
|
||||
},
|
||||
selectFolder: (type) => ipcRenderer.send('select-folder', type)
|
||||
selectFolder: (type) => ipcRenderer.send('select-folder', type),
|
||||
},
|
||||
|
||||
// Universal Converter
|
||||
@@ -368,8 +401,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
|
||||
},
|
||||
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
|
||||
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder });
|
||||
}
|
||||
ipcRenderer.send('universal-convert-batch', {
|
||||
tool,
|
||||
fromFormat,
|
||||
toFormat,
|
||||
inputFolder,
|
||||
outputFolder,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// Header/Footer Operations
|
||||
@@ -377,22 +416,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
|
||||
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
|
||||
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
|
||||
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
||||
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position)
|
||||
saveLogo: (position, filePath) =>
|
||||
ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
||||
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
|
||||
},
|
||||
|
||||
// Page Settings
|
||||
page: {
|
||||
getSettings: () => ipcRenderer.send('get-page-settings'),
|
||||
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
|
||||
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber)
|
||||
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
|
||||
},
|
||||
|
||||
// PDF Operations
|
||||
pdf: {
|
||||
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
|
||||
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
|
||||
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId)
|
||||
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
|
||||
},
|
||||
|
||||
// Image Converter Operations
|
||||
@@ -401,7 +441,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
||||
resize: (data) => ipcRenderer.send('image-resize', data),
|
||||
compress: (data) => ipcRenderer.send('image-compress', data),
|
||||
rotate: (data) => ipcRenderer.send('image-rotate', data)
|
||||
rotate: (data) => ipcRenderer.send('image-rotate', data),
|
||||
},
|
||||
|
||||
// Audio Converter Operations
|
||||
@@ -410,7 +450,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
||||
extract: (data) => ipcRenderer.send('audio-extract', data),
|
||||
trim: (data) => ipcRenderer.send('audio-trim', data),
|
||||
merge: (data) => ipcRenderer.send('audio-merge', data)
|
||||
merge: (data) => ipcRenderer.send('audio-merge', data),
|
||||
},
|
||||
|
||||
// Video Converter Operations
|
||||
@@ -420,10 +460,42 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
compress: (data) => ipcRenderer.send('video-compress', data),
|
||||
trim: (data) => ipcRenderer.send('video-trim', data),
|
||||
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
||||
toGif: (data) => ipcRenderer.send('video-gif', data)
|
||||
toGif: (data) => ipcRenderer.send('video-gif', data),
|
||||
},
|
||||
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version')
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
|
||||
// Git Operations
|
||||
git: {
|
||||
status: (rootPath) => ipcRenderer.invoke('git-status', rootPath),
|
||||
stage: (args) => ipcRenderer.invoke('git-stage', args),
|
||||
commit: (args) => ipcRenderer.invoke('git-commit', args),
|
||||
log: (rootPath) => ipcRenderer.invoke('git-log', rootPath),
|
||||
diff: (filePath) => ipcRenderer.invoke('git-diff', filePath),
|
||||
},
|
||||
|
||||
app: {
|
||||
quit: () => ipcRenderer.send('app:quit'),
|
||||
openExternal: (url) => ipcRenderer.send('app:open-external', url),
|
||||
showSaveDialog: (args) => ipcRenderer.invoke('app:show-save-dialog', args),
|
||||
},
|
||||
|
||||
updater: {
|
||||
check: () => ipcRenderer.invoke('updater:check'),
|
||||
install: () => ipcRenderer.invoke('updater:install'),
|
||||
getState: () => ipcRenderer.invoke('updater:get-state'),
|
||||
onStatus: (cb) => {
|
||||
const subscription = (_event, payload) => cb(payload);
|
||||
ipcRenderer.on('updater:status', subscription);
|
||||
return () => ipcRenderer.removeListener('updater:status', subscription);
|
||||
},
|
||||
},
|
||||
|
||||
crash: {
|
||||
read: () => ipcRenderer.invoke('crash:read'),
|
||||
openDir: () => ipcRenderer.send('crash:open-dir'),
|
||||
delete: (filename) => ipcRenderer.invoke('crash:delete', filename),
|
||||
},
|
||||
});
|
||||
|
||||
// Log successful preload initialization
|
||||
|
||||
+74
-1
@@ -1,26 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { ModalLayer } from './components/modals/ModalLayer';
|
||||
import { CommandPalette } from './components/modals/CommandPalette';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { ReplPanel } from './components/tools/ReplPanel';
|
||||
import { PrintPreview } from './components/tools/PrintPreview';
|
||||
import { UpdateBanner } from './components/UpdateBanner';
|
||||
import { FirstRunWizard } from './components/FirstRunWizard';
|
||||
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
||||
import { useAutoUpdateCheck } from './hooks/useAutoUpdateCheck';
|
||||
|
||||
import { useSettingsStore } from './stores/settings-store';
|
||||
import { ipc } from './lib/ipc';
|
||||
import { toast } from './lib/toast';
|
||||
|
||||
function scopeCSS(cssText: string, scopeSelector: string) {
|
||||
if (!cssText) return '';
|
||||
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
|
||||
const trimmed = selector.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('@') ||
|
||||
trimmed.startsWith(':root') ||
|
||||
trimmed.startsWith('from') ||
|
||||
trimmed.startsWith('to') ||
|
||||
/^\d+%$/.test(trimmed)
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||
});
|
||||
}
|
||||
|
||||
function App() {
|
||||
useWelcomeTrigger();
|
||||
useAutoUpdateCheck();
|
||||
const [printOpen, setPrintOpen] = useState(false);
|
||||
const customCssPath = useSettingsStore((s) => s.customCssPath);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const styleId = 'custom-preview-style';
|
||||
|
||||
async function applyCSS() {
|
||||
if (!customCssPath) {
|
||||
const styleTag = document.getElementById(styleId);
|
||||
if (styleTag) styleTag.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await ipc.file.read(customCssPath);
|
||||
if (!active) return;
|
||||
if (r.ok && r.data) {
|
||||
let styleTag = document.getElementById(styleId);
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = styleId;
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
styleTag.textContent = scopeCSS(r.data, '.preview-content');
|
||||
} else {
|
||||
toast.error('Failed to load custom CSS file');
|
||||
}
|
||||
}
|
||||
|
||||
void applyCSS();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [customCssPath]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.file?.rendererReady?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setPrintOpen(true);
|
||||
window.addEventListener('mc:print', handler);
|
||||
return () => window.removeEventListener('mc:print', handler);
|
||||
window.addEventListener('mc:print-preview', handler);
|
||||
window.addEventListener('mc:print-preview-styled', handler);
|
||||
return () => {
|
||||
window.removeEventListener('mc:print', handler);
|
||||
window.removeEventListener('mc:print-preview', handler);
|
||||
window.removeEventListener('mc:print-preview-styled', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell />
|
||||
<ModalLayer />
|
||||
<CommandPalette />
|
||||
<Toaster />
|
||||
<UpdateBanner />
|
||||
<FirstRunWizard />
|
||||
<ReplPanel />
|
||||
{printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
const TEMPLATES = {
|
||||
blank: '',
|
||||
readme: '# Project\n\nDescription.\n\n## Usage\n\n```\nnpm install\n```\n',
|
||||
meeting:
|
||||
'# Meeting Notes — YYYY-MM-DD\n\n## Attendees\n\n- \n## Agenda\n\n1. \n\n## Action items\n\n- [ ] \n',
|
||||
blog: '# Title\n\n*Subtitle*\n\nLorem ipsum.\n\n---\n\n## Section 1\n',
|
||||
};
|
||||
|
||||
export function FirstRunWizard() {
|
||||
const firstRun = useAppStore((s) => s.firstRun);
|
||||
const setFirstRun = useAppStore((s) => s.setFirstRun);
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||
const updateChannel = useSettingsStore((s) => s.updateChannel);
|
||||
const [step, setStep] = useState(0);
|
||||
const [template, setTemplate] = useState<keyof typeof TEMPLATES>('blank');
|
||||
|
||||
if (!firstRun) return null;
|
||||
|
||||
const close = () => setFirstRun(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="first-run-wizard"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg p-6 w-[28rem] shadow-xl">
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Pick a theme</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['light', 'dark', 'system'] as const).map((t) => (
|
||||
<label key={t} className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
checked={theme === t}
|
||||
onChange={() => setSetting('theme', t)}
|
||||
/>
|
||||
{t}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Update channel</h2>
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="channel"
|
||||
checked={updateChannel === 'github'}
|
||||
onChange={() => setSetting('updateChannel', 'github')}
|
||||
/>
|
||||
GitHub Releases (public)
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="channel"
|
||||
checked={updateChannel === 'concreteinfo'}
|
||||
onChange={() => setSetting('updateChannel', 'concreteinfo')}
|
||||
/>
|
||||
ConcreteInfo self-hosted
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Starter template</h2>
|
||||
<select
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value as any)}
|
||||
className="border rounded px-2 py-1 w-full mb-4"
|
||||
>
|
||||
<option value="blank">Blank</option>
|
||||
<option value="readme">README</option>
|
||||
<option value="meeting">Meeting notes</option>
|
||||
<option value="blog">Blog post</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<button onClick={close} className="text-sm text-neutral-500">
|
||||
Skip
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{step > 0 && <button onClick={() => setStep(step - 1)}>Back</button>}
|
||||
{step < 2 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
className="px-3 py-1 rounded bg-brand text-white"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
useAppStore.getState().newBuffer(TEMPLATES[template]);
|
||||
close();
|
||||
}}
|
||||
className="px-3 py-1 rounded bg-brand text-white"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useUpdaterStore } from '@/lib/updater-store';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function UpdateBanner() {
|
||||
const { state, version, percent, install, check } = useUpdaterStore();
|
||||
|
||||
if (state === 'idle' || state === 'checking') return null;
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<div
|
||||
data-testid="update-banner"
|
||||
role="status"
|
||||
className="bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm"
|
||||
>
|
||||
Couldn't check for updates.{' '}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await check();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
className="underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'available') {
|
||||
return (
|
||||
<div
|
||||
data-testid="update-banner"
|
||||
className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm"
|
||||
>
|
||||
A new version (v{version}) is available.{' '}
|
||||
<button onClick={() => useUpdaterStore.getState().check()} className="underline">
|
||||
Download now
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'downloading') {
|
||||
return (
|
||||
<div
|
||||
data-testid="update-banner"
|
||||
className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm"
|
||||
>
|
||||
Downloading update… {Math.round(percent)}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'ready') {
|
||||
return (
|
||||
<div
|
||||
data-testid="update-banner"
|
||||
className="bg-green-50 border-b border-green-200 px-4 py-2 text-sm flex items-center gap-3"
|
||||
>
|
||||
<span>v{version} is ready.</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
ipc.app.openExternal(
|
||||
`https://github.com/amitwh/markdown-converter/releases/tag/v${version}`
|
||||
)
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
View release notes
|
||||
</button>
|
||||
<button onClick={install} className="px-3 py-1 rounded bg-brand text-white">
|
||||
Restart to update
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||
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';
|
||||
@@ -10,7 +16,10 @@ import { useTheme } from 'next-themes';
|
||||
import { lightTheme, lightHighlight } from './themes/light';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { setActiveView, insertSnippet } from '@/lib/editor-commands';
|
||||
import { Minimap } from './Minimap';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
bufferId: string;
|
||||
@@ -19,6 +28,63 @@ interface Props {
|
||||
onCursorChange?: (line: number, column: number) => void;
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/bmp',
|
||||
'image/avif',
|
||||
]);
|
||||
|
||||
function guessExt(mimeType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/bmp': 'bmp',
|
||||
'image/avif': 'avif',
|
||||
};
|
||||
return map[mimeType] ?? 'png';
|
||||
}
|
||||
|
||||
async function handleImageFile(file: File): Promise<void> {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
if (!base64) return;
|
||||
const ext = guessExt(file.type);
|
||||
const result = await window.electronAPI.invoke('save-pasted-image', { base64, ext });
|
||||
if (result) {
|
||||
insertSnippet(``);
|
||||
toast.success('Image pasted');
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function createPasteHandler() {
|
||||
return EditorView.domEventHandlers({
|
||||
paste(event: ClipboardEvent, _view: EditorView) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (IMAGE_TYPES.has(item.type)) {
|
||||
event.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) handleImageFile(file);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
@@ -27,6 +93,12 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
const updateContent = useEditorStore((s) => s.updateContent);
|
||||
const setCursor = useEditorStore((s) => s.setCursor);
|
||||
const minimap = useSettingsStore((s) => s.minimap);
|
||||
const editorFontSize = useSettingsStore((s) => s.editorFontSize);
|
||||
const buffer = useEditorStore((s) => s.buffers.get(bufferId));
|
||||
const content = buffer?.content ?? initialContent;
|
||||
const [scrollRatio, setScrollRatio] = useState(0);
|
||||
const [visibleRatio, setVisibleRatio] = useState(1);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -40,8 +112,16 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
drawSelection(),
|
||||
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||
autocompletion(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
|
||||
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
...completionKeymap,
|
||||
indentWithTab,
|
||||
]),
|
||||
themeCompartment.current.of(
|
||||
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
|
||||
),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((v) => {
|
||||
if (v.docChanged) {
|
||||
@@ -58,11 +138,28 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
onCursorChange?.(lineNo, col);
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': { fontSize: `${editorFontSize}px` },
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
scroll(_event, view) {
|
||||
const el = view.scrollDOM;
|
||||
const denom = el.scrollHeight - el.clientHeight;
|
||||
setScrollRatio(denom > 0 ? el.scrollTop / denom : 0);
|
||||
setVisibleRatio(
|
||||
el.clientHeight > 0 ? Math.min(1, el.clientHeight / el.scrollHeight) : 1
|
||||
);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
createPasteHandler(),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: ref.current });
|
||||
viewRef.current = view;
|
||||
setActiveView(view);
|
||||
return () => {
|
||||
setActiveView(null);
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
@@ -79,10 +176,43 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
});
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const imageFiles = files.filter((f) => IMAGE_TYPES.has(f.type));
|
||||
for (const file of imageFiles) {
|
||||
await handleImageFile(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<div ref={ref} className="h-full overflow-hidden" />
|
||||
{minimap && <Minimap content={initialContent} />}
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-full overflow-hidden transition-colors duration-150',
|
||||
isDragOver && 'ring-2 ring-primary bg-primary/5'
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
{minimap && (
|
||||
<Minimap content={content} scrollRatio={scrollRatio} visibleRatio={visibleRatio} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||
import { FindReplaceBar } from './FindReplaceBar';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
export function EditorPane() {
|
||||
const { buffers, activeId } = useEditorStore();
|
||||
const buf = activeId ? buffers.get(activeId) : null;
|
||||
const setPreviewSource = usePreviewStore((s) => s.setSource);
|
||||
const lastActiveId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (buf) setPreviewSource(buf.content);
|
||||
if (!buf) return;
|
||||
const isNewFile = lastActiveId.current !== buf.id;
|
||||
lastActiveId.current = buf.id;
|
||||
|
||||
const isLarge = buf.content.length > 1024 * 1024;
|
||||
|
||||
if (isLarge) {
|
||||
if (!usePreviewStore.getState().largeFileMode) {
|
||||
usePreviewStore.setState({ largeFileMode: true });
|
||||
useAppStore.setState({ previewVisible: false });
|
||||
toast.warning(
|
||||
'Large content detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
// Only render on initial load / tab switch, not on edits
|
||||
if (isNewFile) {
|
||||
usePreviewStore.getState().forceRender(buf.content);
|
||||
}
|
||||
} else {
|
||||
if (usePreviewStore.getState().largeFileMode) {
|
||||
usePreviewStore.setState({ largeFileMode: false });
|
||||
}
|
||||
setPreviewSource(buf.content);
|
||||
}
|
||||
}, [buf?.id, buf?.content, buf, setPreviewSource]);
|
||||
|
||||
if (!buf) {
|
||||
@@ -21,12 +49,11 @@ export function EditorPane() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<CodeMirrorEditor
|
||||
key={buf.id}
|
||||
bufferId={buf.id}
|
||||
initialContent={buf.content}
|
||||
/>
|
||||
<div className="flex h-full flex-col">
|
||||
<FindReplaceBar />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeMirrorEditor key={buf.id} bufferId={buf.id} initialContent={buf.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import {
|
||||
findNext,
|
||||
findPrevious,
|
||||
replaceNext,
|
||||
replaceAll,
|
||||
closeSearchPanel,
|
||||
getSearchQuery,
|
||||
setSearchQuery,
|
||||
} from '@codemirror/search';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { getActiveView } from '@/lib/editor-commands';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X, ChevronDown, ChevronUp, Replace, ReplaceAll, CaseSensitive, Regex } from 'lucide-react';
|
||||
|
||||
export function FindReplaceBar() {
|
||||
const findBarOpen = useAppStore((s) => s.findBarOpen);
|
||||
const toggleFindBar = useAppStore((s) => s.toggleFindBar);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const replaceRef = useRef<HTMLInputElement>(null);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [useRegex, setUseRegex] = useState(false);
|
||||
const [matchInfo, setMatchInfo] = useState<{ current: number; total: number } | null>(null);
|
||||
|
||||
const updateMatchCount = useCallback(() => {
|
||||
const view = getActiveView();
|
||||
if (!view) {
|
||||
setMatchInfo(null);
|
||||
return;
|
||||
}
|
||||
const query = getSearchQuery(view.state);
|
||||
if (!query || !query.search) {
|
||||
setMatchInfo(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const docText = view.state.doc.toString();
|
||||
const searchStr = query.regexp
|
||||
? query.search
|
||||
: query.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const flags = `g${query.caseSensitive ? '' : 'i'}`;
|
||||
const regex = new RegExp(searchStr, flags);
|
||||
const matches = docText.match(regex);
|
||||
if (!matches) {
|
||||
setMatchInfo({ current: 0, total: 0 });
|
||||
return;
|
||||
}
|
||||
const selectionHead = view.state.selection.main.head;
|
||||
let currentMatch = 0;
|
||||
const matchPositions: number[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(docText)) !== null) {
|
||||
matchPositions.push(match.index);
|
||||
if (match.index <= selectionHead && selectionHead <= match.index + match[0].length) {
|
||||
currentMatch = matchPositions.length;
|
||||
}
|
||||
if (matchPositions.length > 10000) break;
|
||||
}
|
||||
setMatchInfo({ current: currentMatch || 1, total: matchPositions.length });
|
||||
} catch {
|
||||
setMatchInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
(fn: (view: EditorView) => boolean | void) => {
|
||||
const view = getActiveView();
|
||||
if (!view) return false;
|
||||
const result = fn(view);
|
||||
updateMatchCount();
|
||||
view.focus();
|
||||
return result;
|
||||
},
|
||||
[updateMatchCount]
|
||||
);
|
||||
|
||||
const handleFindNext = useCallback(() => {
|
||||
executeCommand(findNext);
|
||||
}, [executeCommand]);
|
||||
|
||||
const handleFindPrev = useCallback(() => {
|
||||
executeCommand(findPrevious);
|
||||
}, [executeCommand]);
|
||||
|
||||
const handleReplace = useCallback(() => {
|
||||
executeCommand(replaceNext);
|
||||
}, [executeCommand]);
|
||||
|
||||
const handleReplaceAll = useCallback(() => {
|
||||
executeCommand(replaceAll);
|
||||
}, [executeCommand]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const view = getActiveView();
|
||||
if (view) closeSearchPanel(view);
|
||||
toggleFindBar();
|
||||
view?.focus();
|
||||
}, [toggleFindBar]);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const view = getActiveView();
|
||||
if (!view) return;
|
||||
setSearchQuery(view, {
|
||||
search: value,
|
||||
caseSensitive,
|
||||
regexp: useRegex,
|
||||
});
|
||||
},
|
||||
[caseSensitive, useRegex]
|
||||
);
|
||||
|
||||
const handleReplaceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const view = getActiveView();
|
||||
if (!view) return;
|
||||
const query = getSearchQuery(view.state);
|
||||
if (query) {
|
||||
setSearchQuery(view, {
|
||||
search: query.search,
|
||||
caseSensitive: query.caseSensitive ?? false,
|
||||
regexp: query.regexp ?? false,
|
||||
replace: e.target.value,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (findBarOpen) {
|
||||
setTimeout(() => searchRef.current?.focus(), 50);
|
||||
}
|
||||
}, [findBarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!useAppStore.getState().findBarOpen) {
|
||||
useAppStore.getState().toggleFindBar();
|
||||
}
|
||||
};
|
||||
window.addEventListener('mc:find-toggle', handler);
|
||||
return () => window.removeEventListener('mc:find-toggle', handler);
|
||||
}, []);
|
||||
|
||||
if (!findBarOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-border bg-background px-2 py-1">
|
||||
<Input
|
||||
ref={searchRef}
|
||||
placeholder="Find..."
|
||||
className="h-7 w-48 text-xs"
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.shiftKey ? handleFindPrev() : handleFindNext();
|
||||
}
|
||||
if (e.key === 'Escape') handleClose();
|
||||
if (e.key === 'Tab' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
replaceRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
data-testid="find-input"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded p-0.5 hover:bg-accent',
|
||||
caseSensitive && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
setCaseSensitive((v) => {
|
||||
const next = !v;
|
||||
const view = getActiveView();
|
||||
if (view) {
|
||||
const query = getSearchQuery(view.state);
|
||||
if (query) {
|
||||
setSearchQuery(view, {
|
||||
search: query.search,
|
||||
caseSensitive: next,
|
||||
regexp: useRegex,
|
||||
replace: query.replace,
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-label="Case sensitive"
|
||||
data-testid="find-case-sensitive"
|
||||
>
|
||||
<CaseSensitive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded p-0.5 hover:bg-accent',
|
||||
useRegex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
setUseRegex((v) => {
|
||||
const next = !v;
|
||||
const view = getActiveView();
|
||||
if (view) {
|
||||
const query = getSearchQuery(view.state);
|
||||
if (query) {
|
||||
setSearchQuery(view, {
|
||||
search: query.search,
|
||||
caseSensitive,
|
||||
regexp: next,
|
||||
replace: query.replace,
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-label="Use regex"
|
||||
data-testid="find-regex"
|
||||
>
|
||||
<Regex className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{matchInfo && matchInfo.total > 0 && (
|
||||
<span className="min-w-[4rem] text-center text-[10px] text-muted-foreground">
|
||||
{matchInfo.current}/{matchInfo.total}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Input
|
||||
ref={replaceRef}
|
||||
placeholder="Replace..."
|
||||
className="h-7 w-48 text-xs"
|
||||
onChange={handleReplaceChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleReplace();
|
||||
if (e.key === 'Escape') handleClose();
|
||||
if (e.key === 'Tab' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
data-testid="replace-input"
|
||||
/>
|
||||
|
||||
<Button size="sm" variant="ghost" onClick={handleFindPrev} aria-label="Find previous">
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleFindNext} aria-label="Find next">
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleReplace} aria-label="Replace">
|
||||
<Replace className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleReplaceAll} aria-label="Replace all">
|
||||
<ReplaceAll className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleClose} aria-label="Close">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
interface Props {
|
||||
content: string;
|
||||
scrollRatio?: number; // 0-1, where the viewport is
|
||||
visibleRatio?: number; // 0-1, what fraction of content is visible
|
||||
scrollRatio?: number; // 0-1, where the viewport is
|
||||
visibleRatio?: number; // 0-1, what fraction of content is visible
|
||||
}
|
||||
|
||||
export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
|
||||
@@ -30,4 +30,4 @@ export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ 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';
|
||||
import {
|
||||
useRegisterMenuCommands,
|
||||
useBridgeNativeMenu,
|
||||
} from '@/lib/commands/register-menu-commands';
|
||||
import { useZenMode } from '@/hooks/use-zen-mode';
|
||||
|
||||
export function AppShell() {
|
||||
@@ -27,7 +30,9 @@ export function AppShell() {
|
||||
<main className="h-screen w-screen overflow-hidden bg-background">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes) => setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })}
|
||||
onLayoutChange={(sizes) =>
|
||||
setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })
|
||||
}
|
||||
>
|
||||
<ResizablePanel defaultSize={previewVisible ? 50 : 100} minSize={20}>
|
||||
<section className="h-full bg-background">
|
||||
@@ -58,7 +63,9 @@ export function AppShell() {
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
|
||||
onLayoutChange={(sizes) =>
|
||||
setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })
|
||||
}
|
||||
>
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
@@ -86,4 +93,4 @@ export function AppShell() {
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function extractHeadings(content: string): { level: number; text: string }[] {
|
||||
const lines = content.split('\n');
|
||||
const headings: { level: number; text: string }[] = [];
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (m) headings.push({ level: m[1].length, text: m[2].trim() });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
import { useCommandStore } from '@/stores/command-store';
|
||||
import { extractHeadings } from '@/lib/headings';
|
||||
|
||||
export function Breadcrumb() {
|
||||
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||
const openTabs = useFileStore((s) => s.openTabs);
|
||||
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
|
||||
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
|
||||
const dispatch = useCommandStore((s) => s.dispatch);
|
||||
|
||||
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
|
||||
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
|
||||
@@ -26,13 +19,19 @@ export function Breadcrumb() {
|
||||
aria-label="File path"
|
||||
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>{tab ? tab.title : 'No file selected'}</span>
|
||||
<span className="truncate">{tab ? tab.title : 'No file selected'}</span>
|
||||
{headings.map((h, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => dispatch('editor.gotoHeading', h.line)}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<span aria-hidden="true">›</span>
|
||||
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span>
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{'#'.repeat(h.level)} {h.text}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ export function StatusBar() {
|
||||
<span>UTF-8</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
||||
<span>
|
||||
Ln {cursor.line}, Col {cursor.column}
|
||||
</span>
|
||||
<span>Markdown</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
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';
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon, PanelLeft, PanelRight, Save, FolderOpen, FileText } from 'lucide-react';
|
||||
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';
|
||||
@@ -66,12 +78,60 @@ export function Toolbar() {
|
||||
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Bold"
|
||||
data-testid="toolbar-bold"
|
||||
onClick={() => dispatch('editor.bold')}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Italic"
|
||||
data-testid="toolbar-italic"
|
||||
onClick={() => dispatch('editor.italic')}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Unordered list"
|
||||
data-testid="toolbar-list-unordered"
|
||||
onClick={() => dispatch('editor.list.unordered')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Ordered list"
|
||||
data-testid="toolbar-list-ordered"
|
||||
onClick={() => dispatch('editor.list.ordered')}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Inline code"
|
||||
data-testid="toolbar-code"
|
||||
onClick={() => dispatch('editor.code')}
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Insert link"
|
||||
data-testid="toolbar-link"
|
||||
onClick={() => dispatch('editor.link')}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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';
|
||||
@@ -17,10 +24,10 @@ export function AboutDialog() {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="about-desc">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>About MarkdownConverter</DialogTitle>
|
||||
<DialogDescription id="about-desc">
|
||||
<DialogDescription>
|
||||
Professional Markdown editor and universal file converter.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -46,4 +53,4 @@ export function AboutDialog() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ export function AboutSettings() {
|
||||
<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'); }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
ipc.app.openExternal('https://github.com/amitwh/markdown-converter');
|
||||
}}
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
@@ -27,7 +30,10 @@ export function AboutSettings() {
|
||||
<a
|
||||
href="https://concreteinfo.co.in"
|
||||
className="text-brand hover:underline"
|
||||
onClick={(e) => { e.preventDefault(); ipc.app.openExternal('https://concreteinfo.co.in'); }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
ipc.app.openExternal('https://concreteinfo.co.in');
|
||||
}}
|
||||
>
|
||||
ConcreteInfo
|
||||
</a>
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { toast } from '@/lib/toast';
|
||||
@@ -17,9 +30,15 @@ export function AsciiGeneratorDialog() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
figletText(text || ' ', font)
|
||||
.then((result) => { if (!cancelled) setOutput(result); })
|
||||
.catch(() => { if (!cancelled) setOutput('(render error)'); });
|
||||
return () => { cancelled = true; };
|
||||
.then((result) => {
|
||||
if (!cancelled) setOutput(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOutput('(render error)');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [text, font]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
@@ -33,10 +52,10 @@ export function AsciiGeneratorDialog() {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="ascii-desc" className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ASCII generator</DialogTitle>
|
||||
<DialogDescription id="ascii-desc">Type text, pick a font, see ASCII art</DialogDescription>
|
||||
<DialogDescription>Type text, pick a font, see ASCII art</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@@ -52,26 +71,35 @@ export function AsciiGeneratorDialog() {
|
||||
<div>
|
||||
<Label htmlFor="ascii-font">Font</Label>
|
||||
<Select value={font} onValueChange={(v) => setFont(v as FigletFont)}>
|
||||
<SelectTrigger id="ascii-font" aria-label="Font"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger id="ascii-font" aria-label="Font">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIGLET_FONTS.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
<SelectItem key={f} value={f}>
|
||||
{f}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Output</Label>
|
||||
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="ascii-output">
|
||||
<pre
|
||||
className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs"
|
||||
data-testid="ascii-output"
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={closeModal}>Close</Button>
|
||||
<Button variant="ghost" onClick={closeModal}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
type ToolKey = 'imagemagick' | 'ffmpeg';
|
||||
|
||||
interface FormatOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const converterFormats: Record<ToolKey, { input: FormatOption[]; output: FormatOption[] }> = {
|
||||
imagemagick: {
|
||||
input: [
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPEG' },
|
||||
{ value: 'webp', label: 'WebP' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
{ value: 'bmp', label: 'BMP' },
|
||||
{ value: 'tiff', label: 'TIFF' },
|
||||
{ value: 'svg', label: 'SVG' },
|
||||
{ value: 'ico', label: 'ICO' },
|
||||
],
|
||||
output: [
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPEG' },
|
||||
{ value: 'webp', label: 'WebP' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'bmp', label: 'BMP' },
|
||||
{ value: 'ico', label: 'ICO' },
|
||||
],
|
||||
},
|
||||
ffmpeg: {
|
||||
input: [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'wav', label: 'WAV' },
|
||||
{ value: 'avi', label: 'AVI' },
|
||||
{ value: 'mkv', label: 'MKV' },
|
||||
{ value: 'flac', label: 'FLAC' },
|
||||
{ value: 'ogg', label: 'OGG' },
|
||||
{ value: 'mov', label: 'MOV' },
|
||||
],
|
||||
output: [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'wav', label: 'WAV' },
|
||||
{ value: 'avi', label: 'AVI' },
|
||||
{ value: 'mkv', label: 'MKV' },
|
||||
{ value: 'flac', label: 'FLAC' },
|
||||
{ value: 'ogg', label: 'OGG' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
{ value: 'webm', label: 'WebM' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const toolLabels: Record<ToolKey, string> = {
|
||||
imagemagick: 'ImageMagick',
|
||||
ffmpeg: 'FFmpeg',
|
||||
};
|
||||
|
||||
export function BatchMediaConverterDialog() {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const [tool, setTool] = useState<ToolKey>('imagemagick');
|
||||
const [fromFormat, setFromFormat] = useState('');
|
||||
const [toFormat, setToFormat] = useState('');
|
||||
const [inputFolder, setInputFolder] = useState('');
|
||||
const [outputFolder, setOutputFolder] = useState('');
|
||||
const [includeSubfolders, setIncludeSubfolders] = useState(false);
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const inputFormats = converterFormats[tool].input;
|
||||
const outputFormats = converterFormats[tool].output;
|
||||
|
||||
useEffect(() => {
|
||||
setFromFormat('');
|
||||
setToFormat('');
|
||||
}, [tool]);
|
||||
|
||||
const handleBrowseInputFolder = useCallback(async () => {
|
||||
const result = await window.electronAPI?.file?.pickFolder?.();
|
||||
if (typeof result === 'string') {
|
||||
setInputFolder(result);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBrowseOutputFolder = useCallback(async () => {
|
||||
const result = await window.electronAPI?.file?.pickFolder?.();
|
||||
if (typeof result === 'string') {
|
||||
setOutputFolder(result);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handlers: Array<() => void> = [];
|
||||
|
||||
const batchProgressHandler = (_event: unknown, data: { current?: number; total?: number }) => {
|
||||
if (typeof data?.current === 'number' && typeof data?.total === 'number') {
|
||||
setProgress(Math.round((data.current / data.total) * 100));
|
||||
}
|
||||
};
|
||||
const completeHandler = (_event: unknown, data: { outputPath?: string; error?: string }) => {
|
||||
setConverting(false);
|
||||
setProgress(100);
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
toast.error(`Batch conversion failed: ${data.error}`);
|
||||
} else {
|
||||
toast.success('Batch conversion complete');
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const unsubBatch =
|
||||
window.electronAPI?.on?.('batch-progress', batchProgressHandler) ?? (() => {});
|
||||
const unsubComplete =
|
||||
window.electronAPI?.on?.('conversion-complete', completeHandler) ?? (() => {});
|
||||
handlers.push(unsubBatch, unsubComplete);
|
||||
|
||||
return () => handlers.forEach((h) => h());
|
||||
}, [closeModal]);
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!fromFormat || !toFormat) {
|
||||
setError('Select both source and target formats');
|
||||
return;
|
||||
}
|
||||
if (!inputFolder || !outputFolder) {
|
||||
setError('Select both input and output folders');
|
||||
return;
|
||||
}
|
||||
|
||||
setConverting(true);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await window.electronAPI?.converter?.convertBatch?.({
|
||||
tool,
|
||||
fromFormat,
|
||||
toFormat,
|
||||
inputFolder,
|
||||
outputFolder,
|
||||
includeSubfolders,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
toast.error(`Batch conversion failed: ${msg}`);
|
||||
setConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Batch Media Converter</DialogTitle>
|
||||
<DialogDescription>
|
||||
Convert multiple media files between formats using ImageMagick or FFmpeg
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<Tabs value={tool} onValueChange={(v) => setTool(v as ToolKey)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="imagemagick">ImageMagick</TabsTrigger>
|
||||
<TabsTrigger value="ffmpeg">FFmpeg</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value={tool} className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="batch-from">From</Label>
|
||||
<Select value={fromFormat} onValueChange={setFromFormat}>
|
||||
<SelectTrigger id="batch-from" aria-label="Source format">
|
||||
<SelectValue placeholder="Source format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{inputFormats.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="batch-to">To</Label>
|
||||
<Select value={toFormat} onValueChange={setToFormat}>
|
||||
<SelectTrigger id="batch-to" aria-label="Target format">
|
||||
<SelectValue placeholder="Target format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{outputFormats.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={inputFolder}
|
||||
onChange={(e) => setInputFolder(e.target.value)}
|
||||
placeholder="Input folder"
|
||||
className="flex-1"
|
||||
aria-label="Input folder"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleBrowseInputFolder}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={outputFolder}
|
||||
onChange={(e) => setOutputFolder(e.target.value)}
|
||||
placeholder="Output folder"
|
||||
className="flex-1"
|
||||
aria-label="Output folder"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleBrowseOutputFolder}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={includeSubfolders}
|
||||
onCheckedChange={setIncludeSubfolders}
|
||||
id="batch-subfolders"
|
||||
/>
|
||||
<Label htmlFor="batch-subfolders">Include subdirectories</Label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{converting && (
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.max(2, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-right">{progress}%</p>
|
||||
</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={handleConvert}
|
||||
submitting={converting}
|
||||
submitLabel="Convert"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useCommandStore } from '@/stores/command-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
shortcut: string | null;
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const handlers = useCommandStore.getState().handlers;
|
||||
return Object.keys(handlers).map((id) => ({
|
||||
id,
|
||||
label: id
|
||||
.split('.')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' → '),
|
||||
shortcut: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return commands.slice(0, MAX_RESULTS);
|
||||
const lower = query.toLowerCase();
|
||||
const scored = commands
|
||||
.map((cmd) => {
|
||||
const labelLower = cmd.label.toLowerCase();
|
||||
const idLower = cmd.id.toLowerCase();
|
||||
const labelIdx = labelLower.indexOf(lower);
|
||||
const idIdx = idLower.indexOf(lower);
|
||||
let score = 100;
|
||||
if (labelIdx === 0) score = 10;
|
||||
else if (labelIdx >= 0) score = 20;
|
||||
else if (idIdx === 0) score = 15;
|
||||
else if (idIdx >= 0) score = 25;
|
||||
else score = 100;
|
||||
return { cmd, score };
|
||||
})
|
||||
.filter((item) => item.score < 100)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.map((item) => item.cmd);
|
||||
return scored.slice(0, MAX_RESULTS);
|
||||
}, [commands, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedIndex(0);
|
||||
setQuery('');
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const execute = useCallback((id: string) => {
|
||||
useCommandStore.getState().dispatch(id);
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const cmd = filtered[selectedIndex];
|
||||
if (cmd) execute(cmd.id);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[filtered, selectedIndex, execute]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handle);
|
||||
return () => window.removeEventListener('keydown', handle);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)} />
|
||||
<div className="relative z-10 w-full max-w-lg rounded-lg border bg-background shadow-2xl">
|
||||
<div className="border-b px-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a command..."
|
||||
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
aria-label="Search commands"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-activedescendant={
|
||||
filtered[selectedIndex] ? `cmd-${filtered[selectedIndex].id}` : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-64 overflow-y-auto p-1" role="listbox">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No matching commands
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((cmd, idx) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
id={`cmd-${cmd.id}`}
|
||||
role="option"
|
||||
aria-selected={idx === selectedIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md px-3 py-2 text-sm',
|
||||
idx === selectedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => execute(cmd.id)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<span>{cmd.label}</span>
|
||||
{cmd.shortcut && (
|
||||
<kbd className="ml-4 rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
{cmd.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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 {
|
||||
title,
|
||||
body,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
} = props;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
@@ -17,10 +32,10 @@ export function ConfirmDialog(props: ConfirmProps) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && handleCancel()}>
|
||||
<DialogContent aria-describedby="confirm-body">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription id="confirm-body">{body}</DialogDescription>
|
||||
<DialogDescription>{body}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCancel}>
|
||||
@@ -33,4 +48,4 @@ export function ConfirmDialog(props: ConfirmProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
|
||||
interface Dump {
|
||||
filename: string;
|
||||
kind: string;
|
||||
message?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function CrashReportModal({ onClose }: { onClose: () => void }) {
|
||||
const [dumps, setDumps] = useState<Dump[]>([]);
|
||||
|
||||
const refresh = async () => setDumps(await ipc.crash.read());
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="crash-report-modal"
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg p-6 w-[36rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<header className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Crash reports</h2>
|
||||
<button onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<button
|
||||
onClick={() => ipc.crash.openDir()}
|
||||
className="mb-4 px-3 py-1 text-sm border rounded"
|
||||
>
|
||||
Open dump folder
|
||||
</button>
|
||||
{dumps.length === 0 ? (
|
||||
<p data-testid="empty-state" className="text-neutral-500">
|
||||
No crashes recorded — nice work!
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{dumps.map((d) => (
|
||||
<li
|
||||
key={d.filename}
|
||||
className="border rounded p-2 text-sm flex justify-between items-start gap-2"
|
||||
>
|
||||
<div>
|
||||
<div className="font-mono text-xs text-neutral-500">{d.timestamp}</div>
|
||||
<div>{d.message ?? '(no message)'}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await ipc.crash.delete(d.filename);
|
||||
refresh();
|
||||
}}
|
||||
className="text-red-600 text-xs"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export function EditorSettings() {
|
||||
@@ -11,12 +17,24 @@ export function EditorSettings() {
|
||||
<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)} />
|
||||
<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>
|
||||
<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>
|
||||
@@ -26,15 +44,27 @@ export function EditorSettings() {
|
||||
</div>
|
||||
<label className="flex items-center justify-between">
|
||||
<span>Line numbers</span>
|
||||
<Switch checked={lineNumbers} onCheckedChange={(c) => setSetting('lineNumbers', c)} aria-label="Line numbers" />
|
||||
<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" />
|
||||
<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" />
|
||||
<Switch
|
||||
checked={minimap}
|
||||
onCheckedChange={(c) => setSetting('minimap', c)}
|
||||
aria-label="Minimap"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from '@/lib/toast';
|
||||
@@ -37,16 +49,18 @@ export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="batch-desc">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Batch export</DialogTitle>
|
||||
<DialogDescription id="batch-desc">{sourcePaths.length} files</DialogDescription>
|
||||
<DialogDescription>{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>
|
||||
<SelectTrigger id="batch-format" aria-label="Format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="docx">DOCX</SelectItem>
|
||||
@@ -58,24 +72,40 @@ export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
|
||||
<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>
|
||||
<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>
|
||||
<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>)}
|
||||
{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">
|
||||
<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" />
|
||||
<ExportDialogFooter
|
||||
onCancel={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel="Export"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,13 @@ interface Props {
|
||||
submitDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExportDialogFooter({ onCancel, onSubmit, submitting, submitLabel, submitDisabled }: Props) {
|
||||
export function ExportDialogFooter({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitting,
|
||||
submitLabel,
|
||||
submitDisabled,
|
||||
}: Props) {
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
|
||||
@@ -20,4 +26,4 @@ export function ExportDialogFooter({ onCancel, onSubmit, submitting, submitLabel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useExportSource } from '@/hooks/use-export-source';
|
||||
import { generateDocx } from '@/lib/docx-export';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||
@@ -16,41 +31,73 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||
const source = useExportSource();
|
||||
const [template, setTemplate] = useState<'standard' | 'minimal' | 'modern'>(docxTemplate);
|
||||
const [ascii, setAscii] = useState(renderTablesAsAscii);
|
||||
const [referenceDoc, setReferenceDoc] = useState('');
|
||||
const [toc, setToc] = useState(false);
|
||||
const [tocDepth, setTocDepth] = useState(3);
|
||||
const [numberSections, setNumberSections] = useState(false);
|
||||
const [bibliography, setBibliography] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!source) { setError('No file open.'); return; }
|
||||
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) {
|
||||
toast.error(`Export failed: ${result.error.message}`);
|
||||
setError(result.error.message);
|
||||
setSubmitting(false);
|
||||
} else {
|
||||
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||
try {
|
||||
const blob = await generateDocx({ source: source.source, title: source.title });
|
||||
const saveResult = await ipc.app.showSaveDialog?.({
|
||||
title: 'Save as Word document',
|
||||
defaultPath: source.path.replace(/\.md$/, '.docx'),
|
||||
});
|
||||
if (!saveResult?.ok || !saveResult.data) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
referenceDoc: referenceDoc || undefined,
|
||||
toc,
|
||||
tocDepth,
|
||||
numberSections,
|
||||
bibliography: bibliography || undefined,
|
||||
};
|
||||
|
||||
await window.electronAPI?.export?.withOptions?.('docx', options);
|
||||
|
||||
const buffer = new Uint8Array(await blob.arrayBuffer());
|
||||
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
|
||||
if (!writeResult.ok) {
|
||||
setError(writeResult.error.message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(`Exported ${source.title} to ${saveResult.data}`);
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Export failed: ${msg}`);
|
||||
setError(msg);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="docx-desc">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to DOCX</DialogTitle>
|
||||
<DialogDescription id="docx-desc">{sourcePath}</DialogDescription>
|
||||
<DialogDescription>{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>
|
||||
<SelectTrigger id="docx-template" aria-label="Template">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem value="minimal">Minimal</SelectItem>
|
||||
@@ -58,21 +105,81 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||
</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.
|
||||
The renderer-side export produces the same document for all three template choices;
|
||||
the option is preserved for future stylesheets.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" />
|
||||
<Checkbox
|
||||
checked={ascii}
|
||||
onCheckedChange={(c) => setAscii(!!c)}
|
||||
aria-label="ASCII tables"
|
||||
/>
|
||||
Render tables as ASCII
|
||||
</label>
|
||||
<div>
|
||||
<Label htmlFor="docx-reference">Reference doc / template</Label>
|
||||
<Input
|
||||
id="docx-reference"
|
||||
value={referenceDoc}
|
||||
onChange={(e) => setReferenceDoc(e.target.value)}
|
||||
placeholder="/path/to/reference.docx"
|
||||
aria-label="Reference document path"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={toc} onCheckedChange={setToc} id="docx-toc" />
|
||||
<Label htmlFor="docx-toc">Table of Contents</Label>
|
||||
</div>
|
||||
{toc && (
|
||||
<div className="pl-9">
|
||||
<Label htmlFor="docx-toc-depth">TOC Depth</Label>
|
||||
<Input
|
||||
id="docx-toc-depth"
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
value={tocDepth}
|
||||
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
|
||||
className="w-20"
|
||||
aria-label="TOC depth"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={numberSections}
|
||||
onCheckedChange={setNumberSections}
|
||||
id="docx-number-sections"
|
||||
/>
|
||||
<Label htmlFor="docx-number-sections">Number sections</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="docx-bibliography">Bibliography file</Label>
|
||||
<Input
|
||||
id="docx-bibliography"
|
||||
value={bibliography}
|
||||
onChange={(e) => setBibliography(e.target.value)}
|
||||
placeholder="/path/to/references.bib"
|
||||
aria-label="Bibliography file path"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<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" />
|
||||
<ExportDialogFooter
|
||||
onCancel={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel="Export"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useExportSource } from '@/hooks/use-export-source';
|
||||
import { generateHtml } from '@/lib/html-export';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||
@@ -17,46 +32,89 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||
const [standalone, setStandalone] = useState(true);
|
||||
const [highlight, setHighlight] = useState(htmlHighlightStyle);
|
||||
const [ascii, setAscii] = useState(renderTablesAsAscii);
|
||||
const [selfContained, setSelfContained] = useState(false);
|
||||
const [toc, setToc] = useState(false);
|
||||
const [tocDepth, setTocDepth] = useState(3);
|
||||
const [numberSections, setNumberSections] = useState(false);
|
||||
const [cssPath, setCssPath] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!source) { setError('No file open.'); return; }
|
||||
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) {
|
||||
toast.error(`Export failed: ${result.error.message}`);
|
||||
setError(result.error.message);
|
||||
setSubmitting(false);
|
||||
} else {
|
||||
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||
try {
|
||||
const html = generateHtml({
|
||||
source: source.source,
|
||||
title: source.title,
|
||||
standalone,
|
||||
highlightStyle: highlight,
|
||||
renderTablesAsAscii: ascii,
|
||||
});
|
||||
|
||||
const options = {
|
||||
standalone,
|
||||
highlightStyle: highlight,
|
||||
selfContained,
|
||||
toc,
|
||||
tocDepth,
|
||||
numberSections,
|
||||
css: cssPath || undefined,
|
||||
};
|
||||
|
||||
await window.electronAPI?.export?.withOptions?.('html', options);
|
||||
|
||||
const saveResult = await ipc.app.showSaveDialog?.({
|
||||
title: 'Save as HTML',
|
||||
defaultPath: source.path.replace(/\.md$/, '.html'),
|
||||
});
|
||||
if (!saveResult?.ok || !saveResult.data) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const buffer = new TextEncoder().encode(html);
|
||||
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
|
||||
if (!writeResult.ok) {
|
||||
setError(writeResult.error.message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(`Exported ${source.title} to ${saveResult.data}`);
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Export failed: ${msg}`);
|
||||
setError(msg);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="html-desc">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to HTML</DialogTitle>
|
||||
<DialogDescription id="html-desc">{sourcePath}</DialogDescription>
|
||||
<DialogDescription>{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" />
|
||||
<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>
|
||||
<SelectTrigger id="html-highlight" aria-label="Highlight style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="github">GitHub</SelectItem>
|
||||
<SelectItem value="monokai">Monokai</SelectItem>
|
||||
@@ -65,18 +123,75 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={selfContained}
|
||||
onCheckedChange={setSelfContained}
|
||||
id="html-self-contained"
|
||||
/>
|
||||
<Label htmlFor="html-self-contained">Self-contained (embed all CSS inline)</Label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" />
|
||||
<Checkbox
|
||||
checked={ascii}
|
||||
onCheckedChange={(c) => setAscii(!!c)}
|
||||
aria-label="ASCII tables"
|
||||
/>
|
||||
Render tables as ASCII
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={toc} onCheckedChange={setToc} id="html-toc" />
|
||||
<Label htmlFor="html-toc">Table of Contents</Label>
|
||||
</div>
|
||||
{toc && (
|
||||
<div className="pl-9">
|
||||
<Label htmlFor="html-toc-depth">TOC Depth</Label>
|
||||
<Input
|
||||
id="html-toc-depth"
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
value={tocDepth}
|
||||
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
|
||||
className="w-20"
|
||||
aria-label="TOC depth"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={numberSections}
|
||||
onCheckedChange={setNumberSections}
|
||||
id="html-number-sections"
|
||||
/>
|
||||
<Label htmlFor="html-number-sections">Number sections</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="html-css">Custom CSS file</Label>
|
||||
<Input
|
||||
id="html-css"
|
||||
value={cssPath}
|
||||
onChange={(e) => setCssPath(e.target.value)}
|
||||
placeholder="/path/to/custom.css"
|
||||
aria-label="Custom CSS file path"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<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" />
|
||||
<ExportDialogFooter
|
||||
onCancel={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel="Export"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
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 { toast } from '@/lib/toast';
|
||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { generateHtml } from '@/lib/html-export';
|
||||
|
||||
const MARGIN_MAP = {
|
||||
normal: { top: 25, right: 25, bottom: 25, left: 25 },
|
||||
@@ -18,14 +33,23 @@ const MARGIN_MAP = {
|
||||
|
||||
export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const { fontSize, pdfFormat, pdfMargins, pdfEmbedFonts, renderTablesAsAscii } = useSettingsStore();
|
||||
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 [engine, setEngine] = useState<'pdflatex' | 'xelatex' | 'lualatex'>('pdflatex');
|
||||
const [toc, setToc] = useState(false);
|
||||
const [tocDepth, setTocDepth] = useState(3);
|
||||
const [numberSections, setNumberSections] = useState(false);
|
||||
const [pageGeometry, setPageGeometry] = useState<'margin' | 'crop' | 'bleed'>('margin');
|
||||
const [bibliography, setBibliography] = useState('');
|
||||
const [mainFont, setMainFont] = useState('');
|
||||
const [cjkFont, setCjkFont] = useState('');
|
||||
const [highlightStyle, setHighlightStyle] = useState('tango');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const source = useExportSource();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -35,37 +59,72 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
}
|
||||
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.ok) {
|
||||
toast.error(`Export failed: ${result.error?.message ?? 'Export failed'}`);
|
||||
setError(result.error?.message ?? 'Export failed');
|
||||
setSubmitting(false);
|
||||
} else {
|
||||
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`);
|
||||
try {
|
||||
const html = generateHtml({
|
||||
source: source.source,
|
||||
title: source.title,
|
||||
standalone: true,
|
||||
highlightStyle: 'github',
|
||||
renderTablesAsAscii: ascii,
|
||||
});
|
||||
const fmt =
|
||||
format === 'a4'
|
||||
? { width: '210mm', height: '297mm' }
|
||||
: format === 'legal'
|
||||
? { width: '8.5in', height: '14in' }
|
||||
: { width: '8.5in', height: '11in' };
|
||||
const m = MARGIN_MAP[margins];
|
||||
const pageCss = `@page { size: ${fmt.width} ${fmt.height}; margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm; }`;
|
||||
const finalHtml = html.replace('</style>', `${pageCss}</style>`);
|
||||
|
||||
const options = {
|
||||
html: finalHtml,
|
||||
withStyles: embed,
|
||||
engine,
|
||||
toc,
|
||||
tocDepth,
|
||||
numberSections,
|
||||
pageGeometry,
|
||||
bibliography: bibliography || undefined,
|
||||
mainFont: mainFont || undefined,
|
||||
cjkFont: cjkFont || undefined,
|
||||
highlightStyle,
|
||||
};
|
||||
|
||||
const result = await (window.electronAPI?.export?.withOptions?.('pdf', options) ??
|
||||
ipc.print.show({ html: finalHtml, withStyles: embed }));
|
||||
|
||||
if (!result.ok) {
|
||||
const msg = result.error?.message ?? 'PDF export failed';
|
||||
toast.error(`Export failed: ${msg}`);
|
||||
setError(msg);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(`Sent ${source.title} to printer`);
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Export failed: ${msg}`);
|
||||
setError(msg);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="pdf-desc">
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to PDF</DialogTitle>
|
||||
<DialogDescription id="pdf-desc">{sourcePath}</DialogDescription>
|
||||
<DialogDescription>{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>
|
||||
<SelectTrigger id="pdf-format" aria-label="Format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="letter">Letter</SelectItem>
|
||||
<SelectItem value="a4">A4</SelectItem>
|
||||
@@ -76,7 +135,9 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
<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>
|
||||
<SelectTrigger id="pdf-margins" aria-label="Margins">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="narrow">Narrow</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
@@ -85,21 +146,137 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={embed} onCheckedChange={(c) => setEmbed(!!c)} aria-label="Embed fonts" />
|
||||
<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" />
|
||||
<Checkbox
|
||||
checked={ascii}
|
||||
onCheckedChange={(c) => setAscii(!!c)}
|
||||
aria-label="ASCII tables"
|
||||
/>
|
||||
Render tables as ASCII
|
||||
</label>
|
||||
<div>
|
||||
<Label htmlFor="pdf-engine">PDF Engine</Label>
|
||||
<Select value={engine} onValueChange={(v) => setEngine(v as typeof engine)}>
|
||||
<SelectTrigger id="pdf-engine" aria-label="PDF Engine">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdflatex">pdflatex</SelectItem>
|
||||
<SelectItem value="xelatex">xelatex</SelectItem>
|
||||
<SelectItem value="lualatex">lualatex</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={toc} onCheckedChange={setToc} id="pdf-toc" />
|
||||
<Label htmlFor="pdf-toc">Table of Contents</Label>
|
||||
</div>
|
||||
{toc && (
|
||||
<div className="pl-9">
|
||||
<Label htmlFor="pdf-toc-depth">TOC Depth</Label>
|
||||
<Input
|
||||
id="pdf-toc-depth"
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
value={tocDepth}
|
||||
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
|
||||
className="w-20"
|
||||
aria-label="TOC depth"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={numberSections}
|
||||
onCheckedChange={setNumberSections}
|
||||
id="pdf-number-sections"
|
||||
/>
|
||||
<Label htmlFor="pdf-number-sections">Number sections</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pdf-page-geometry">Page Geometry</Label>
|
||||
<Select
|
||||
value={pageGeometry}
|
||||
onValueChange={(v) => setPageGeometry(v as typeof pageGeometry)}
|
||||
>
|
||||
<SelectTrigger id="pdf-page-geometry" aria-label="Page geometry">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="margin">Margin</SelectItem>
|
||||
<SelectItem value="crop">Crop</SelectItem>
|
||||
<SelectItem value="bleed">Bleed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pdf-bibliography">Bibliography file</Label>
|
||||
<Input
|
||||
id="pdf-bibliography"
|
||||
value={bibliography}
|
||||
onChange={(e) => setBibliography(e.target.value)}
|
||||
placeholder="/path/to/references.bib"
|
||||
aria-label="Bibliography file path"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pdf-main-font">Main font</Label>
|
||||
<Input
|
||||
id="pdf-main-font"
|
||||
value={mainFont}
|
||||
onChange={(e) => setMainFont(e.target.value)}
|
||||
placeholder="e.g. Latin Modern"
|
||||
aria-label="Main font"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pdf-cjk-font">CJK font</Label>
|
||||
<Input
|
||||
id="pdf-cjk-font"
|
||||
value={cjkFont}
|
||||
onChange={(e) => setCjkFont(e.target.value)}
|
||||
placeholder="e.g. Noto Sans CJK SC"
|
||||
aria-label="CJK font"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pdf-highlight">Highlight style</Label>
|
||||
<Select value={highlightStyle} onValueChange={setHighlightStyle}>
|
||||
<SelectTrigger id="pdf-highlight" aria-label="Highlight style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tango">Tango</SelectItem>
|
||||
<SelectItem value="pygments">Pygments</SelectItem>
|
||||
<SelectItem value="kateks">Kate (Kateks)</SelectItem>
|
||||
<SelectItem value="monochrome">Monochrome</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<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" />
|
||||
<ExportDialogFooter
|
||||
onCancel={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel="Export"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useExportSource } from '@/hooks/use-export-source';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||
|
||||
export function ExportRevealjsDialog({ sourcePath }: { sourcePath: string }) {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const source = useExportSource();
|
||||
|
||||
const [revealTheme, setRevealTheme] = useState('black');
|
||||
const [revealTransition, setRevealTransition] = useState('slide');
|
||||
const [revealTransitionSpeed, setRevealTransitionSpeed] = useState('default');
|
||||
const [revealSlideNumber, setRevealSlideNumber] = useState(false);
|
||||
const [revealControls, setRevealControls] = useState(true);
|
||||
const [revealProgress, setRevealProgress] = useState(true);
|
||||
const [revealHistory, setRevealHistory] = useState(true);
|
||||
const [revealCenter, setRevealCenter] = useState(true);
|
||||
|
||||
const [template, setTemplate] = useState('default');
|
||||
const [title, setTitle] = useState(source?.title || '');
|
||||
const [author, setAuthor] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [bibliography, setBibliography] = useState('');
|
||||
const [csl, setCsl] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleBrowseFile = async (setter: (v: string) => void) => {
|
||||
const result = await ipc.file.pickFile();
|
||||
if (result.ok && result.data) {
|
||||
setter(result.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!source) {
|
||||
setError('No file open.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const options = {
|
||||
revealTheme,
|
||||
revealTransition,
|
||||
revealTransitionSpeed,
|
||||
revealSlideNumber,
|
||||
revealControls,
|
||||
revealProgress,
|
||||
revealHistory,
|
||||
revealCenter,
|
||||
template: template || undefined,
|
||||
metadata: {
|
||||
title: title || undefined,
|
||||
author: author || undefined,
|
||||
date: date || undefined,
|
||||
},
|
||||
bibliography: bibliography || undefined,
|
||||
csl: csl || undefined,
|
||||
};
|
||||
|
||||
await window.electronAPI?.export?.withOptions?.('revealjs', options);
|
||||
toast.success('Slide export process completed');
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Export failed: ${msg}`);
|
||||
setError(msg);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Reveal.js Slides</DialogTitle>
|
||||
<DialogDescription>{sourcePath}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Theme & Transitions */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="reveal-theme">Theme</Label>
|
||||
<Select value={revealTheme} onValueChange={setRevealTheme}>
|
||||
<SelectTrigger id="reveal-theme">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['black', 'white', 'league', 'beige', 'sky', 'night', 'serif', 'simple', 'solarized', 'blood', 'moon'].map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="reveal-transition">Transition</Label>
|
||||
<Select value={revealTransition} onValueChange={setRevealTransition}>
|
||||
<SelectTrigger id="reveal-transition">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['slide', 'none', 'fade', 'convex', 'concave', 'zoom'].map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="reveal-speed">Transition Speed</Label>
|
||||
<Select value={revealTransitionSpeed} onValueChange={setRevealTransitionSpeed}>
|
||||
<SelectTrigger id="reveal-speed">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['default', 'fast', 'slow'].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Toggle Switches */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={revealSlideNumber} onCheckedChange={setRevealSlideNumber} id="reveal-slide-number" />
|
||||
<Label htmlFor="reveal-slide-number">Slide Numbers</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={revealControls} onCheckedChange={setRevealControls} id="reveal-controls" />
|
||||
<Label htmlFor="reveal-controls">Controls</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={revealProgress} onCheckedChange={setRevealProgress} id="reveal-progress" />
|
||||
<Label htmlFor="reveal-progress">Progress Bar</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={revealHistory} onCheckedChange={setRevealHistory} id="reveal-history" />
|
||||
<Label htmlFor="reveal-history">Slide History</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 col-span-2">
|
||||
<Switch checked={revealCenter} onCheckedChange={setRevealCenter} id="reveal-center" />
|
||||
<Label htmlFor="reveal-center">Center Content Vertically</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template & Metadata */}
|
||||
<div className="border-t pt-3 space-y-3">
|
||||
<Label className="font-semibold text-xs text-muted-foreground uppercase tracking-wider block">Metadata & Template</Label>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="reveal-template">Template File</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="reveal-template"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="default"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setTemplate)}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="reveal-title">Title</Label>
|
||||
<Input id="reveal-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reveal-author">Author</Label>
|
||||
<Input id="reveal-author" value={author} onChange={(e) => setAuthor(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reveal-date">Date</Label>
|
||||
<Input id="reveal-date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bibliography & CSL */}
|
||||
<div className="border-t pt-3 space-y-3">
|
||||
<Label className="font-semibold text-xs text-muted-foreground uppercase tracking-wider block">Bibliography (Pandoc)</Label>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="reveal-bibliography">Bibliography (.bib, .json)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="reveal-bibliography"
|
||||
value={bibliography}
|
||||
onChange={(e) => setBibliography(e.target.value)}
|
||||
placeholder="/path/to/citations.bib"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setBibliography)}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="reveal-csl">CSL Style</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="reveal-csl"
|
||||
value={csl}
|
||||
onChange={(e) => setCsl(e.target.value)}
|
||||
placeholder="/path/to/style.csl"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setCsl)}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExportDialogFooter
|
||||
onCancel={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
submitLabel="Export"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,33 @@
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export function ExportSettings() {
|
||||
const { pdfFormat, pdfMargins, pdfEmbedFonts, docxTemplate, htmlHighlightStyle, renderTablesAsAscii, setSetting } = useSettingsStore();
|
||||
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>
|
||||
<SelectTrigger id="export-pdf-format" aria-label="Default PDF format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="letter">Letter</SelectItem>
|
||||
<SelectItem value="a4">A4</SelectItem>
|
||||
@@ -22,7 +38,9 @@ export function ExportSettings() {
|
||||
<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>
|
||||
<SelectTrigger id="export-pdf-margins" aria-label="Default PDF margins">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="narrow">Narrow</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
@@ -32,12 +50,18 @@ export function ExportSettings() {
|
||||
</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" />
|
||||
<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>
|
||||
<SelectTrigger id="export-docx-template" aria-label="Default DOCX template">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem value="minimal">Minimal</SelectItem>
|
||||
@@ -47,8 +71,13 @@ export function ExportSettings() {
|
||||
</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>
|
||||
<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>
|
||||
@@ -59,8 +88,12 @@ export function ExportSettings() {
|
||||
</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" />
|
||||
<Switch
|
||||
checked={renderTablesAsAscii}
|
||||
onCheckedChange={(c) => setSetting('renderTablesAsAscii', c)}
|
||||
aria-label="ASCII tables by default"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -47,10 +54,10 @@ export function FindInFilesDialog() {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="find-desc" className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Find in files</DialogTitle>
|
||||
<DialogDescription id="find-desc">
|
||||
<DialogDescription>
|
||||
{rootPath ? `Search in ${rootPath}` : 'No folder open'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -67,22 +74,35 @@ export function FindInFilesDialog() {
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={isRegex} onCheckedChange={(c) => setIsRegex(!!c)} aria-label="Regex" />
|
||||
<Checkbox
|
||||
checked={isRegex}
|
||||
onCheckedChange={(c) => setIsRegex(!!c)}
|
||||
aria-label="Regex"
|
||||
/>
|
||||
Regex
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={caseSensitive} onCheckedChange={(c) => setCaseSensitive(!!c)} aria-label="Case sensitive" />
|
||||
<Checkbox
|
||||
checked={caseSensitive}
|
||||
onCheckedChange={(c) => setCaseSensitive(!!c)}
|
||||
aria-label="Case sensitive"
|
||||
/>
|
||||
Case sensitive
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<Label>{results.length} result{results.length === 1 ? '' : 's'}</Label>
|
||||
<Label>
|
||||
{results.length} result{results.length === 1 ? '' : 's'}
|
||||
</Label>
|
||||
<div className="max-h-64 overflow-auto rounded border border-border bg-card/20 text-xs">
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
@@ -91,7 +111,9 @@ export function FindInFilesDialog() {
|
||||
className="block w-full truncate border-b border-border/30 px-2 py-1 text-left hover:bg-card/50"
|
||||
data-testid="find-result"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground">{r.filePath}:{r.line}</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{r.filePath}:{r.line}
|
||||
</span>
|
||||
<span className="ml-2">{r.content}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -100,7 +122,9 @@ export function FindInFilesDialog() {
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Close</Button>
|
||||
<Button variant="ghost" onClick={closeModal} disabled={submitting}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleSearch} disabled={submitting || !query}>
|
||||
{submitting ? 'Searching…' : 'Search'}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { toast } from '@/lib/toast';
|
||||
|
||||
interface HeaderFooterSettings {
|
||||
headerEnabled: boolean;
|
||||
footerEnabled: boolean;
|
||||
headerLeft: string;
|
||||
headerCenter: string;
|
||||
headerRight: string;
|
||||
footerLeft: string;
|
||||
footerCenter: string;
|
||||
footerRight: string;
|
||||
logoPosition: 'none' | 'left' | 'right';
|
||||
logoPath: string | null;
|
||||
}
|
||||
|
||||
const DYNAMIC_FIELDS = [
|
||||
{ token: '$PAGE$', label: 'Page' },
|
||||
{ token: '$TOTAL$', label: 'Total Pages' },
|
||||
{ token: '$DATE$', label: 'Date' },
|
||||
{ token: '$TIME$', label: 'Time' },
|
||||
{ token: '$TITLE$', label: 'Title' },
|
||||
{ token: '$AUTHOR$', label: 'Author' },
|
||||
{ token: '$FILENAME$', label: 'Filename' },
|
||||
] as const;
|
||||
|
||||
const DEFAULTS: HeaderFooterSettings = {
|
||||
headerEnabled: false,
|
||||
footerEnabled: false,
|
||||
headerLeft: '',
|
||||
headerCenter: '',
|
||||
headerRight: '',
|
||||
footerLeft: '',
|
||||
footerCenter: '',
|
||||
footerRight: '',
|
||||
logoPosition: 'none',
|
||||
logoPath: null,
|
||||
};
|
||||
|
||||
type FieldKey = keyof Pick<
|
||||
HeaderFooterSettings,
|
||||
'headerLeft' | 'headerCenter' | 'headerRight' | 'footerLeft' | 'footerCenter' | 'footerRight'
|
||||
>;
|
||||
|
||||
export function HeaderFooterDialog() {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const [settings, setSettings] = useState<HeaderFooterSettings>(DEFAULTS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function load() {
|
||||
try {
|
||||
window.electronAPI?.send('get-header-footer-settings');
|
||||
const unsub = window.electronAPI?.on('header-footer-settings-data', (data: unknown) => {
|
||||
if (mounted && data && typeof data === 'object') {
|
||||
setSettings({ ...DEFAULTS, ...(data as Partial<HeaderFooterSettings>) });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsub?.();
|
||||
};
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateField = useCallback((key: FieldKey, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const insertToken = useCallback((key: FieldKey, token: string) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[key]: prev[key] + token,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleBrowseLogo = useCallback(async () => {
|
||||
window.electronAPI?.send('browse-header-footer-logo');
|
||||
const unsub = window.electronAPI?.on('header-footer-logo-selected', (data: unknown) => {
|
||||
if (typeof data === 'string') {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
logoPath: data,
|
||||
logoPosition: prev.logoPosition === 'none' ? 'left' : prev.logoPosition,
|
||||
}));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsub?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClearLogo = useCallback(() => {
|
||||
setSettings((prev) => ({ ...prev, logoPath: null, logoPosition: 'none' }));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
window.electronAPI?.send('save-header-footer-settings', settings);
|
||||
const unsub = window.electronAPI?.on('header-footer-logo-saved', () => {
|
||||
toast.success('Header & footer settings saved');
|
||||
closeModal();
|
||||
setSaving(false);
|
||||
unsub?.();
|
||||
});
|
||||
setTimeout(() => {
|
||||
setSaving(false);
|
||||
toast.success('Header & footer settings saved');
|
||||
closeModal();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const fieldRows: Array<{ key: FieldKey; label: string; enabled: boolean }> = [
|
||||
{ key: 'headerLeft', label: 'Left', enabled: settings.headerEnabled },
|
||||
{ key: 'headerCenter', label: 'Center', enabled: settings.headerEnabled },
|
||||
{ key: 'headerRight', label: 'Right', enabled: settings.headerEnabled },
|
||||
{ key: 'footerLeft', label: 'Left', enabled: settings.footerEnabled },
|
||||
{ key: 'footerCenter', label: 'Center', enabled: settings.footerEnabled },
|
||||
{ key: 'footerRight', label: 'Right', enabled: settings.footerEnabled },
|
||||
];
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Header & Footer</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure headers and footers for exported documents
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={settings.headerEnabled}
|
||||
onCheckedChange={(c) => setSettings((p) => ({ ...p, headerEnabled: !!c }))}
|
||||
aria-label="Enable header"
|
||||
/>
|
||||
Enable header
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Header</p>
|
||||
{fieldRows
|
||||
.filter((r) => r.key.startsWith('header'))
|
||||
.map((row) => (
|
||||
<div key={row.key} className="flex items-center gap-2">
|
||||
<span className="w-12 text-xs text-muted-foreground">{row.label}</span>
|
||||
<Input
|
||||
value={settings[row.key]}
|
||||
onChange={(e) => updateField(row.key, e.target.value)}
|
||||
placeholder={`Header ${row.label.toLowerCase()}`}
|
||||
className="flex-1"
|
||||
disabled={!settings.headerEnabled}
|
||||
aria-label={`Header ${row.label.toLowerCase()}`}
|
||||
/>
|
||||
<div className="flex gap-0.5">
|
||||
{DYNAMIC_FIELDS.slice(0, 4).map((field) => (
|
||||
<Button
|
||||
key={field.token}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-1.5 text-[10px]"
|
||||
disabled={!settings.headerEnabled}
|
||||
onClick={() => insertToken(row.key, field.token)}
|
||||
title={field.label}
|
||||
>
|
||||
{field.token}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={settings.footerEnabled}
|
||||
onCheckedChange={(c) => setSettings((p) => ({ ...p, footerEnabled: !!c }))}
|
||||
aria-label="Enable footer"
|
||||
/>
|
||||
Enable footer
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Footer</p>
|
||||
{fieldRows
|
||||
.filter((r) => r.key.startsWith('footer'))
|
||||
.map((row) => (
|
||||
<div key={row.key} className="flex items-center gap-2">
|
||||
<span className="w-12 text-xs text-muted-foreground">{row.label}</span>
|
||||
<Input
|
||||
value={settings[row.key]}
|
||||
onChange={(e) => updateField(row.key, e.target.value)}
|
||||
placeholder={`Footer ${row.label.toLowerCase()}`}
|
||||
className="flex-1"
|
||||
disabled={!settings.footerEnabled}
|
||||
aria-label={`Footer ${row.label.toLowerCase()}`}
|
||||
/>
|
||||
<div className="flex gap-0.5">
|
||||
{DYNAMIC_FIELDS.slice(0, 4).map((field) => (
|
||||
<Button
|
||||
key={field.token}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-1.5 text-[10px]"
|
||||
disabled={!settings.footerEnabled}
|
||||
onClick={() => insertToken(row.key, field.token)}
|
||||
title={field.label}
|
||||
>
|
||||
{field.token}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Logo</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={settings.logoPosition}
|
||||
onValueChange={(v) =>
|
||||
setSettings((p) => ({
|
||||
...p,
|
||||
logoPosition: v as HeaderFooterSettings['logoPosition'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32" aria-label="Logo position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.logoPosition !== 'none' && (
|
||||
<>
|
||||
<Input
|
||||
value={settings.logoPath ?? ''}
|
||||
readOnly
|
||||
placeholder="No logo selected"
|
||||
className="flex-1"
|
||||
aria-label="Logo path"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleBrowseLogo}>
|
||||
Browse
|
||||
</Button>
|
||||
{settings.logoPath && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearLogo}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{settings.logoPath && (
|
||||
<img
|
||||
src={`file://${settings.logoPath}`}
|
||||
alt="Logo preview"
|
||||
className="h-8 rounded border object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={closeModal} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { AboutDialog } from './AboutDialog';
|
||||
import { BatchMediaConverterDialog } from './BatchMediaConverterDialog';
|
||||
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { CrashReportModal } from './CrashReportModal';
|
||||
import { ExportBatchDialog } from './ExportBatchDialog';
|
||||
import { ExportDocxDialog } from './ExportDocxDialog';
|
||||
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
||||
import { ExportPdfDialog } from './ExportPdfDialog';
|
||||
import { ExportRevealjsDialog } from './ExportRevealjsDialog';
|
||||
import { FindInFilesDialog } from './FindInFilesDialog';
|
||||
import { HeaderFooterDialog } from './HeaderFooterDialog';
|
||||
import { SettingsSheet } from './SettingsSheet';
|
||||
import { TableGeneratorDialog } from './TableGeneratorDialog';
|
||||
import { UniversalConverterDialog } from './UniversalConverterDialog';
|
||||
import { WelcomeDialog } from './WelcomeDialog';
|
||||
import { WordExportDialog } from './WordExportDialog';
|
||||
import { WritingAnalyticsDialog } from './WritingAnalyticsDialog';
|
||||
import { PdfEditorDialog } from './PdfEditorDialog';
|
||||
|
||||
export function ModalLayer() {
|
||||
const modal = useAppStore((s) => s.modal);
|
||||
@@ -23,6 +30,8 @@ export function ModalLayer() {
|
||||
return <ExportDocxDialog sourcePath={modal.props.sourcePath} />;
|
||||
case 'export-html':
|
||||
return <ExportHtmlDialog sourcePath={modal.props.sourcePath} />;
|
||||
case 'export-revealjs':
|
||||
return <ExportRevealjsDialog sourcePath={modal.props.sourcePath} />;
|
||||
case 'export-batch':
|
||||
return <ExportBatchDialog sourcePaths={modal.props.sourcePaths} />;
|
||||
case 'settings':
|
||||
@@ -41,5 +50,22 @@ export function ModalLayer() {
|
||||
return <TableGeneratorDialog />;
|
||||
case 'find-in-files':
|
||||
return <FindInFilesDialog />;
|
||||
case 'crashReports':
|
||||
return <CrashReportModal onClose={useAppStore.getState().closeModal} />;
|
||||
case 'writing-analytics':
|
||||
return <WritingAnalyticsDialog />;
|
||||
case 'pdf-editor':
|
||||
return (
|
||||
<PdfEditorDialog
|
||||
onClose={useAppStore.getState().closeModal}
|
||||
initialFilePath={modal.props?.filePath}
|
||||
/>
|
||||
);
|
||||
case 'universal-converter':
|
||||
return <UniversalConverterDialog />;
|
||||
case 'header-footer':
|
||||
return <HeaderFooterDialog />;
|
||||
case 'batch-media-converter':
|
||||
return <BatchMediaConverterDialog />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,10 @@
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
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';
|
||||
@@ -8,6 +14,7 @@ import { ThemeSettings } from './ThemeSettings';
|
||||
import { ExportSettings } from './ExportSettings';
|
||||
import { PluginsSettings } from './PluginsSettings';
|
||||
import { AboutSettings } from './AboutSettings';
|
||||
import { UpdatesSettings } from './UpdatesSettings';
|
||||
|
||||
export function SettingsSheet() {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
@@ -15,31 +22,47 @@ export function SettingsSheet() {
|
||||
|
||||
return (
|
||||
<Sheet open onOpenChange={(o) => !o && closeModal()}>
|
||||
<SheetContent aria-describedby="settings-desc" side="right" className="w-full sm:max-w-[480px]">
|
||||
<SheetContent side="right" className="w-full sm:max-w-[480px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Settings</SheetTitle>
|
||||
<SheetDescription id="settings-desc">Editor, theme, and export preferences</SheetDescription>
|
||||
<SheetDescription>Editor, theme, and export preferences</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="editor" className="mt-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="editor">Editor</TabsTrigger>
|
||||
<TabsTrigger value="theme">Theme</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
<TabsTrigger value="plugins">Plugins</TabsTrigger>
|
||||
<TabsTrigger value="updates">Updates</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>
|
||||
<TabsContent value="editor">
|
||||
<EditorSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="theme">
|
||||
<ThemeSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="export">
|
||||
<ExportSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="plugins">
|
||||
<PluginsSettings />
|
||||
</TabsContent>
|
||||
<TabsContent value="updates">
|
||||
<UpdatesSettings />
|
||||
</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>
|
||||
<Button variant="ghost" onClick={resetToDefaults}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -11,7 +18,13 @@ function pad(s: string, w: number) {
|
||||
return s.padEnd(w);
|
||||
}
|
||||
|
||||
function generateTable(rows: number, cols: number, hasHeader: boolean, headers: string[], data: string[][]): string {
|
||||
function generateTable(
|
||||
rows: number,
|
||||
cols: number,
|
||||
hasHeader: boolean,
|
||||
headers: string[],
|
||||
data: string[][]
|
||||
): string {
|
||||
const width = cols;
|
||||
const colWidths: number[] = Array.from({ length: width }, (_, c) => {
|
||||
const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')];
|
||||
@@ -19,11 +32,21 @@ function generateTable(rows: number, cols: number, hasHeader: boolean, headers:
|
||||
});
|
||||
const lines: string[] = [];
|
||||
if (hasHeader) {
|
||||
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])).join(' | ') + ' |');
|
||||
lines.push(
|
||||
'| ' +
|
||||
Array.from({ length: width }, (_, c) =>
|
||||
pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])
|
||||
).join(' | ') +
|
||||
' |'
|
||||
);
|
||||
lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
|
||||
}
|
||||
for (const row of data) {
|
||||
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') + ' |');
|
||||
lines.push(
|
||||
'| ' +
|
||||
Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') +
|
||||
' |'
|
||||
);
|
||||
}
|
||||
// Add empty rows if data has fewer
|
||||
for (let i = data.length; i < rows; i++) {
|
||||
@@ -53,17 +76,23 @@ export function TableGeneratorDialog() {
|
||||
const updateHeadersCount = (newCols: number) => {
|
||||
setCols(newCols);
|
||||
setHeaders((prev) => {
|
||||
if (prev.length < newCols) return [...prev, ...Array(newCols - prev.length).fill('').map((_, i) => `Col ${prev.length + i + 1}`)];
|
||||
if (prev.length < newCols)
|
||||
return [
|
||||
...prev,
|
||||
...Array(newCols - prev.length)
|
||||
.fill('')
|
||||
.map((_, i) => `Col ${prev.length + i + 1}`),
|
||||
];
|
||||
return prev.slice(0, newCols);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="table-desc" className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Table generator</DialogTitle>
|
||||
<DialogDescription id="table-desc">Specify rows × columns to generate a markdown table</DialogDescription>
|
||||
<DialogDescription>Specify rows × columns to generate a markdown table</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
@@ -88,18 +117,27 @@ export function TableGeneratorDialog() {
|
||||
min={1}
|
||||
max={20}
|
||||
value={cols}
|
||||
onChange={(e) => updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||
onChange={(e) =>
|
||||
updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={hasHeader} onCheckedChange={(c) => setHasHeader(!!c)} aria-label="Header" />
|
||||
<Checkbox
|
||||
checked={hasHeader}
|
||||
onCheckedChange={(c) => setHasHeader(!!c)}
|
||||
aria-label="Header"
|
||||
/>
|
||||
Include header row
|
||||
</label>
|
||||
{hasHeader && (
|
||||
<div>
|
||||
<Label>Header names</Label>
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{Array.from({ length: cols }, (_, i) => (
|
||||
<Input
|
||||
key={i}
|
||||
@@ -117,16 +155,21 @@ export function TableGeneratorDialog() {
|
||||
)}
|
||||
<div>
|
||||
<Label>Output</Label>
|
||||
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="table-output">
|
||||
<pre
|
||||
className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs"
|
||||
data-testid="table-output"
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={closeModal}>Close</Button>
|
||||
<Button variant="ghost" onClick={closeModal}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export function ThemeSettings() {
|
||||
const { theme, accentColor, fontFamily, setSetting } = useSettingsStore();
|
||||
@@ -10,16 +16,30 @@ export function ThemeSettings() {
|
||||
<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
|
||||
value={theme}
|
||||
onValueChange={(v) => setSetting('theme', v as 'light' | 'dark' | 'system')}
|
||||
>
|
||||
<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="system" id="theme-system" />
|
||||
<Label htmlFor="theme-system">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>
|
||||
<SelectTrigger id="theme-accent" aria-label="Accent color">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="brand">Brand (orange)</SelectItem>
|
||||
<SelectItem value="blue">Blue</SelectItem>
|
||||
@@ -32,7 +52,9 @@ export function ThemeSettings() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -42,4 +64,4 @@ export function ThemeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { ExportDialogFooter } from './ExportDialogFooter';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
type ToolKey = 'imagemagick' | 'ffmpeg' | 'libreoffice' | 'pandoc';
|
||||
|
||||
interface FormatOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const converterFormats: Record<ToolKey, { input: FormatOption[]; output: FormatOption[] }> = {
|
||||
imagemagick: {
|
||||
input: [
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPEG' },
|
||||
{ value: 'webp', label: 'WebP' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
{ value: 'bmp', label: 'BMP' },
|
||||
{ value: 'tiff', label: 'TIFF' },
|
||||
{ value: 'svg', label: 'SVG' },
|
||||
{ value: 'ico', label: 'ICO' },
|
||||
],
|
||||
output: [
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPEG' },
|
||||
{ value: 'webp', label: 'WebP' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'bmp', label: 'BMP' },
|
||||
{ value: 'ico', label: 'ICO' },
|
||||
],
|
||||
},
|
||||
ffmpeg: {
|
||||
input: [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'wav', label: 'WAV' },
|
||||
{ value: 'avi', label: 'AVI' },
|
||||
{ value: 'mkv', label: 'MKV' },
|
||||
{ value: 'flac', label: 'FLAC' },
|
||||
{ value: 'ogg', label: 'OGG' },
|
||||
{ value: 'mov', label: 'MOV' },
|
||||
],
|
||||
output: [
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'wav', label: 'WAV' },
|
||||
{ value: 'avi', label: 'AVI' },
|
||||
{ value: 'mkv', label: 'MKV' },
|
||||
{ value: 'flac', label: 'FLAC' },
|
||||
{ value: 'ogg', label: 'OGG' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
{ value: 'webm', label: 'WebM' },
|
||||
],
|
||||
},
|
||||
libreoffice: {
|
||||
input: [
|
||||
{ value: 'docx', label: 'DOCX' },
|
||||
{ value: 'xlsx', label: 'XLSX' },
|
||||
{ value: 'pptx', label: 'PPTX' },
|
||||
{ value: 'odt', label: 'ODT' },
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
],
|
||||
output: [
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'docx', label: 'DOCX' },
|
||||
{ value: 'xlsx', label: 'XLSX' },
|
||||
{ value: 'pptx', label: 'PPTX' },
|
||||
{ value: 'odt', label: 'ODT' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'txt', label: 'Text' },
|
||||
],
|
||||
},
|
||||
pandoc: {
|
||||
input: [
|
||||
{ value: 'md', label: 'Markdown' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'docx', label: 'DOCX' },
|
||||
{ value: 'latex', label: 'LaTeX' },
|
||||
{ value: 'rst', label: 'reStructuredText' },
|
||||
{ value: 'epub', label: 'EPUB' },
|
||||
{ value: 'org', label: 'Org Mode' },
|
||||
{ value: 'wiki', label: 'MediaWiki' },
|
||||
],
|
||||
output: [
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'docx', label: 'DOCX' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'latex', label: 'LaTeX' },
|
||||
{ value: 'epub', label: 'EPUB' },
|
||||
{ value: 'rst', label: 'reStructuredText' },
|
||||
{ value: 'org', label: 'Org Mode' },
|
||||
{ value: 'plain', label: 'Plain Text' },
|
||||
{ value: 'rtf', label: 'RTF' },
|
||||
{ value: 'odt', label: 'ODT' },
|
||||
{ value: 'wiki', label: 'MediaWiki' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const toolLabels: Record<ToolKey, string> = {
|
||||
imagemagick: 'ImageMagick',
|
||||
ffmpeg: 'FFmpeg',
|
||||
libreoffice: 'LibreOffice',
|
||||
pandoc: 'Pandoc',
|
||||
};
|
||||
|
||||
export function UniversalConverterDialog() {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const [tool, setTool] = useState<ToolKey>('pandoc');
|
||||
const [fromFormat, setFromFormat] = useState('');
|
||||
const [toFormat, setToFormat] = useState('');
|
||||
const [filePath, setFilePath] = useState('');
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [inputFolder, setInputFolder] = useState('');
|
||||
const [outputFolder, setOutputFolder] = useState('');
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const inputFormats = converterFormats[tool].input;
|
||||
const outputFormats = converterFormats[tool].output;
|
||||
|
||||
useEffect(() => {
|
||||
setFromFormat('');
|
||||
setToFormat('');
|
||||
}, [tool]);
|
||||
|
||||
const handleBrowseFile = useCallback(async () => {
|
||||
const result = await window.electronAPI?.file?.pickFile?.();
|
||||
if (typeof result === 'string') {
|
||||
setFilePath(result);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBrowseInputFolder = useCallback(async () => {
|
||||
const result = await window.electronAPI?.file?.pickFolder?.();
|
||||
if (typeof result === 'string') {
|
||||
setInputFolder(result);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBrowseOutputFolder = useCallback(async () => {
|
||||
const result = await window.electronAPI?.file?.pickFolder?.();
|
||||
if (typeof result === 'string') {
|
||||
setOutputFolder(result);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handlers: Array<() => void> = [];
|
||||
|
||||
const statusHandler = (_event: unknown, data: { percent?: number; message?: string }) => {
|
||||
if (typeof data?.percent === 'number') setProgress(data.percent);
|
||||
};
|
||||
const completeHandler = (_event: unknown, data: { outputPath?: string; error?: string }) => {
|
||||
setConverting(false);
|
||||
setProgress(100);
|
||||
if (data?.error) {
|
||||
setError(data.error);
|
||||
toast.error(`Conversion failed: ${data.error}`);
|
||||
} else {
|
||||
toast.success('Conversion complete');
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
const batchProgressHandler = (_event: unknown, data: { current?: number; total?: number }) => {
|
||||
if (typeof data?.current === 'number' && typeof data?.total === 'number') {
|
||||
setProgress(Math.round((data.current / data.total) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
const unsubStatus = window.electronAPI?.on?.('conversion-status', statusHandler) ?? (() => {});
|
||||
const unsubComplete =
|
||||
window.electronAPI?.on?.('conversion-complete', completeHandler) ?? (() => {});
|
||||
const unsubBatch =
|
||||
window.electronAPI?.on?.('batch-progress', batchProgressHandler) ?? (() => {});
|
||||
handlers.push(unsubStatus, unsubComplete, unsubBatch);
|
||||
|
||||
return () => handlers.forEach((h) => h());
|
||||
}, [closeModal]);
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!fromFormat || !toFormat) {
|
||||
setError('Select both source and target formats');
|
||||
return;
|
||||
}
|
||||
if (!batchMode && !filePath) {
|
||||
setError('Select a file to convert');
|
||||
return;
|
||||
}
|
||||
if (batchMode && (!inputFolder || !outputFolder)) {
|
||||
setError('Select both input and output folders');
|
||||
return;
|
||||
}
|
||||
|
||||
setConverting(true);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (batchMode) {
|
||||
await window.electronAPI?.converter?.convertBatch?.({
|
||||
tool,
|
||||
fromFormat,
|
||||
toFormat,
|
||||
inputFolder,
|
||||
outputFolder,
|
||||
});
|
||||
} else {
|
||||
await window.electronAPI?.converter?.convert?.({
|
||||
tool,
|
||||
inputPath: filePath,
|
||||
fromFormat,
|
||||
toFormat,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
toast.error(`Conversion failed: ${msg}`);
|
||||
setConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Universal Converter</DialogTitle>
|
||||
<DialogDescription>
|
||||
Convert between image, audio, video, document, and markup formats
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{!batchMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={filePath}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="Select a file..."
|
||||
className="flex-1"
|
||||
aria-label="File path"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleBrowseFile}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="converter-tool">Tool</Label>
|
||||
<Select value={tool} onValueChange={(v) => setTool(v as ToolKey)}>
|
||||
<SelectTrigger id="converter-tool" aria-label="Conversion tool">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(converterFormats) as ToolKey[]).map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{toolLabels[key]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="converter-from">From</Label>
|
||||
<Select value={fromFormat} onValueChange={setFromFormat}>
|
||||
<SelectTrigger id="converter-from" aria-label="Source format">
|
||||
<SelectValue placeholder="Source format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{inputFormats.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="converter-to">To</Label>
|
||||
<Select value={toFormat} onValueChange={setToFormat}>
|
||||
<SelectTrigger id="converter-to" aria-label="Target format">
|
||||
<SelectValue placeholder="Target format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{outputFormats.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={batchMode} onCheckedChange={setBatchMode} id="batch-mode" />
|
||||
<Label htmlFor="batch-mode">Batch mode</Label>
|
||||
</div>
|
||||
|
||||
{batchMode && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={inputFolder}
|
||||
onChange={(e) => setInputFolder(e.target.value)}
|
||||
placeholder="Input folder"
|
||||
className="flex-1"
|
||||
aria-label="Input folder"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleBrowseInputFolder}>
|
||||
...
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={outputFolder}
|
||||
onChange={(e) => setOutputFolder(e.target.value)}
|
||||
placeholder="Output folder"
|
||||
className="flex-1"
|
||||
aria-label="Output folder"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleBrowseOutputFolder}>
|
||||
...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{converting && (
|
||||
<div className="space-y-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.max(2, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-right">{progress}%</p>
|
||||
</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={handleConvert}
|
||||
submitting={converting}
|
||||
submitLabel="Convert"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useUpdaterStore } from '@/lib/updater-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function UpdatesSettings() {
|
||||
const updateChannel = useSettingsStore((s) => s.updateChannel);
|
||||
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||
const autoCheck = useSettingsStore((s) => s.autoCheckUpdates);
|
||||
const { check, state } = useUpdaterStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="updates-settings">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Update channel</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="update-channel"
|
||||
checked={updateChannel === 'github'}
|
||||
onChange={() => setSetting('updateChannel', 'github')}
|
||||
/>
|
||||
<span>GitHub Releases (public)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="update-channel"
|
||||
checked={updateChannel === 'concreteinfo'}
|
||||
onChange={() => setSetting('updateChannel', 'concreteinfo')}
|
||||
/>
|
||||
<span>ConcreteInfo self-hosted</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Auto-check on launch</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoCheck}
|
||||
onChange={(e) => setSetting('autoCheckUpdates', e.target.checked)}
|
||||
/>
|
||||
<span>Check for updates 5s after launch</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Manual check</h3>
|
||||
<Button
|
||||
onClick={() => check()}
|
||||
disabled={state === 'checking' || state === 'downloading'}
|
||||
data-testid="check-now"
|
||||
>
|
||||
{state === 'checking' ? 'Checking…' : 'Check now'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +1,317 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useState, useEffect } 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';
|
||||
import { useCommandStore } from '@/stores/command-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuickStartItem {
|
||||
label: string;
|
||||
shortcut: string;
|
||||
commandId: string;
|
||||
}
|
||||
|
||||
interface FeatureItem {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const quickStartItems: QuickStartItem[] = [
|
||||
{ label: 'New File', shortcut: 'Ctrl+N', commandId: 'file.new' },
|
||||
{ label: 'Open File', shortcut: 'Ctrl+O', commandId: 'file.open' },
|
||||
{ label: 'Open Folder', shortcut: 'Ctrl+Shift+O', commandId: 'file.openFolder' },
|
||||
{ label: 'Command Palette', shortcut: 'Ctrl+Shift+P', commandId: 'shortcuts.show' },
|
||||
];
|
||||
|
||||
const features: FeatureItem[] = [
|
||||
{ label: 'CodeMirror 6', description: 'Fast, extensible editor', icon: 'code' },
|
||||
{ label: 'Live Preview', description: 'Mermaid, KaTeX support', icon: 'eye' },
|
||||
{ label: 'PDF Editing', description: 'Print-ready export', icon: 'file-text' },
|
||||
{ label: '25+ Themes', description: 'Light, dark, custom', icon: 'palette' },
|
||||
{ label: 'Batch Conversion', description: 'ImageMagick, FFmpeg, more', icon: 'layers' },
|
||||
{ label: 'Plugin System', description: 'Extend with plugins', icon: 'puzzle' },
|
||||
];
|
||||
|
||||
const shortcutEntries = [
|
||||
{ keys: 'Ctrl+S', action: 'Save' },
|
||||
{ keys: 'Ctrl+B', action: 'Toggle sidebar' },
|
||||
{ keys: 'Ctrl+\\', action: 'Toggle preview' },
|
||||
{ keys: 'Ctrl+W', action: 'Close tab' },
|
||||
{ keys: 'Ctrl+F', action: 'Find' },
|
||||
{ keys: 'Ctrl+Shift+P', action: 'Command palette' },
|
||||
{ keys: 'Ctrl+K Z', action: 'Zen mode' },
|
||||
{ keys: 'Ctrl+Tab', action: 'Next tab' },
|
||||
];
|
||||
|
||||
export function WelcomeDialog() {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||
const [dontShow, setDontShow] = useState(false);
|
||||
const [version, setVersion] = useState('');
|
||||
const [recentFiles, setRecentFiles] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadVersion() {
|
||||
const result = await window.electronAPI?.app?.getVersion?.();
|
||||
if (result && typeof result === 'object' && 'data' in result) {
|
||||
setVersion((result as { data: string }).data);
|
||||
} else if (typeof result === 'string') {
|
||||
setVersion(result);
|
||||
}
|
||||
}
|
||||
void loadVersion();
|
||||
const stored = localStorage.getItem('mc-recent-files');
|
||||
if (stored) {
|
||||
try {
|
||||
setRecentFiles(JSON.parse(stored).slice(0, 5));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShow) setSetting('welcomeDismissed', true);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleQuickStart = (commandId: string) => {
|
||||
useCommandStore.getState().dispatch(commandId);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent aria-describedby="welcome-desc" className="max-w-xl">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Welcome to MarkdownConverter</DialogTitle>
|
||||
<DialogDescription id="welcome-desc">
|
||||
A polished editor for Markdown, with PDF, DOCX, and HTML export.
|
||||
<DialogTitle className="text-xl">
|
||||
Welcome to MarkdownConverter{version ? ` v${version}` : ''}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
A powerful Markdown editor with PDF, DOCX, HTML export and universal file conversion.
|
||||
</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 className="grid gap-4 text-sm">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Quick Start
|
||||
</h3>
|
||||
{quickStartItems.map((item) => (
|
||||
<button
|
||||
key={item.commandId}
|
||||
onClick={() => handleQuickStart(item.commandId)}
|
||||
className="flex w-full items-center justify-between rounded-md border border-border bg-card/30 p-2.5 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Features
|
||||
</h3>
|
||||
{features.map((feat) => (
|
||||
<div
|
||||
key={feat.label}
|
||||
className="flex items-start gap-2 rounded-md border border-border bg-card/30 p-2.5"
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-primary/10 text-primary">
|
||||
<FeatureIcon name={feat.icon} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium leading-tight">{feat.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{feat.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Recent Files
|
||||
</h3>
|
||||
{recentFiles.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-card/30 p-4 text-center text-muted-foreground">
|
||||
No recent files
|
||||
</div>
|
||||
) : (
|
||||
recentFiles.map((path, idx) => (
|
||||
<button
|
||||
key={`${path}-${idx}`}
|
||||
onClick={() => {
|
||||
useCommandStore.getState().dispatch('file.opened', { path });
|
||||
handleClose();
|
||||
}}
|
||||
className="flex w-full items-center rounded-md border border-border bg-card/30 px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span className="flex-1 truncate text-xs" title={path}>
|
||||
{path.split('/').pop()}
|
||||
</span>
|
||||
<span
|
||||
className="ml-2 truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={path}
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
<h3 className="pt-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Shortcuts
|
||||
</h3>
|
||||
<div className="rounded-md border border-border bg-card/30">
|
||||
{shortcutEntries.map((s) => (
|
||||
<div
|
||||
key={s.keys}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-2.5 py-1.5 text-xs',
|
||||
shortcutEntries.indexOf(s) < shortcutEntries.length - 1 &&
|
||||
'border-b border-border'
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{s.action}</span>
|
||||
<kbd className="font-mono text-[10px]">{s.keys}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
<Checkbox
|
||||
checked={dontShow}
|
||||
onCheckedChange={(c) => setDontShow(!!c)}
|
||||
aria-label="Don't show again"
|
||||
/>
|
||||
Don't show on startup
|
||||
</label>
|
||||
<Button onClick={handleClose}>Get started</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function FeatureIcon({ name }: { name: string }) {
|
||||
const icons: Record<string, JSX.Element> = {
|
||||
code: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
),
|
||||
eye: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
),
|
||||
'file-text': (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 9H8" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
</svg>
|
||||
),
|
||||
palette: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
|
||||
</svg>
|
||||
),
|
||||
layers: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z" />
|
||||
<path d="m22 12.5-8.58 3.91a2 2 0 0 1-1.66 0L2 12.08" />
|
||||
</svg>
|
||||
),
|
||||
puzzle: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.618 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
return icons[name] ?? null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
@@ -16,7 +23,9 @@ export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||
const docxCustomTemplatePath = useSettingsStore((s) => s.docxCustomTemplatePath);
|
||||
const source = useExportSource();
|
||||
|
||||
const [templateMode, setTemplateMode] = useState<'standard' | 'custom'>(docxCustomTemplatePath ? 'custom' : 'standard');
|
||||
const [templateMode, setTemplateMode] = useState<'standard' | 'custom'>(
|
||||
docxCustomTemplatePath ? 'custom' : 'standard'
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -32,7 +41,10 @@ export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!source) { setError('No file open.'); return; }
|
||||
if (!source) {
|
||||
setError('No file open.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -41,7 +53,10 @@ export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||
title: source.title,
|
||||
customTemplatePath: templateMode === 'custom' ? docxCustomTemplatePath : null,
|
||||
});
|
||||
const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') });
|
||||
const saveResult = await ipc.app.showSaveDialog?.({
|
||||
title: 'Save as Word document',
|
||||
defaultPath: source.path.replace(/\.md$/, '.docx'),
|
||||
});
|
||||
if (!saveResult?.ok || !saveResult.data) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
@@ -65,15 +80,18 @@ export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent aria-describedby="word-desc">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to Word (.docx)</DialogTitle>
|
||||
<DialogDescription id="word-desc">{sourcePath}</DialogDescription>
|
||||
<DialogDescription>{sourcePath}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<Label>Template</Label>
|
||||
<RadioGroup value={templateMode} onValueChange={(v) => setTemplateMode(v as 'standard' | 'custom')}>
|
||||
<RadioGroup
|
||||
value={templateMode}
|
||||
onValueChange={(v) => setTemplateMode(v as 'standard' | 'custom')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="standard" id="template-standard" />
|
||||
<Label htmlFor="template-standard">Standard (bundled)</Label>
|
||||
@@ -87,9 +105,13 @@ export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||
{templateMode === 'custom' && (
|
||||
<div className="rounded border border-border bg-card/20 p-2 text-xs">
|
||||
{docxCustomTemplatePath ? (
|
||||
<span>Template path: <code>{docxCustomTemplatePath}</code></span>
|
||||
<span>
|
||||
Template path: <code>{docxCustomTemplatePath}</code>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No template selected. Click "Choose template..." to pick a .dotx file.</span>
|
||||
<span className="text-muted-foreground">
|
||||
No template selected. Click "Choose template..." to pick a .dotx file.
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleChooseTemplate} className="ml-2">
|
||||
Choose template...
|
||||
@@ -97,13 +119,18 @@ export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={closeModal} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? 'Exporting…' : 'Export'}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { analyzeText } from '@/lib/writing-analytics';
|
||||
import { BookOpen, Clock, FileText, BarChart2, Target, Award, Sparkles, Mic } from 'lucide-react';
|
||||
|
||||
export function WritingAnalyticsDialog() {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const isOpen = useAppStore((s) => s.modal.kind) === 'writing-analytics';
|
||||
const { dailyGoal, setSetting } = useSettingsStore();
|
||||
const activeId = useEditorStore((s) => s.activeId);
|
||||
const buffer = activeId ? useEditorStore((s) => s.buffers.get(activeId)) : null;
|
||||
const content = buffer?.content || '';
|
||||
|
||||
const metrics = analyzeText(content);
|
||||
const percentage = dailyGoal > 0 ? Math.round((metrics.wordCount / dailyGoal) * 100) : 0;
|
||||
const isGoalReached = metrics.wordCount >= dailyGoal && dailyGoal > 0;
|
||||
|
||||
// Track celebration state to trigger an animation on goal completion
|
||||
const [celebrated, setCelebrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGoalReached && !celebrated) {
|
||||
setCelebrated(true);
|
||||
} else if (!isGoalReached && celebrated) {
|
||||
setCelebrated(false);
|
||||
}
|
||||
}, [isGoalReached, celebrated]);
|
||||
|
||||
// Find max count for word cloud scaling
|
||||
const maxWordCount =
|
||||
metrics.topWords.length > 0 ? Math.max(...metrics.topWords.map((w) => w.count)) : 1;
|
||||
|
||||
// Helper to determine color coding for Flesch Readability Ease
|
||||
const getReadabilityColor = (score: number) => {
|
||||
if (score >= 70) return 'text-[#1a7a56]'; // Easy / Very Easy
|
||||
if (score >= 50) return 'text-amber-500'; // Standard
|
||||
return 'text-rose-500'; // Difficult / Very Difficult
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent className="max-w-3xl overflow-y-auto max-h-[90vh] p-6">
|
||||
<DialogHeader className="border-b border-border pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="h-6 w-6 text-brand" />
|
||||
<DialogTitle className="text-xl font-bold font-display">Writing Analytics</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Real-time readability metrics, vocabulary analysis, and progress towards your daily word
|
||||
goal.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left Column: Readability, Structure, Timing */}
|
||||
<div className="space-y-6">
|
||||
{/* Readability Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/50 pb-2 mb-3">
|
||||
<BookOpen className="h-5 w-5 text-brand" />
|
||||
<h3 className="font-semibold text-sm">Readability</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Flesch Reading Ease</p>
|
||||
<p className={`text-2xl font-bold ${getReadabilityColor(metrics.fleschEase)}`}>
|
||||
{metrics.fleschEase}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{metrics.readabilityLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Grade Level</p>
|
||||
<p className="text-2xl font-bold text-foreground">{metrics.fleschGrade}</p>
|
||||
<p className="text-xs text-muted-foreground">US school grade</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Structure Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/50 pb-2 mb-3">
|
||||
<FileText className="h-5 w-5 text-brand" />
|
||||
<h3 className="font-semibold text-sm">Structure</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center mb-4">
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Words</p>
|
||||
<p className="text-lg font-bold text-foreground">{metrics.wordCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Sentences</p>
|
||||
<p className="text-lg font-bold text-foreground">{metrics.sentenceCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Paragraphs</p>
|
||||
<p className="text-lg font-bold text-foreground">{metrics.paragraphCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Average Sentence Length:</span>
|
||||
<span className="font-semibold">{metrics.avgSentenceLength} words</span>
|
||||
</div>
|
||||
{metrics.longestSentence && (
|
||||
<div className="pt-2 border-t border-border/30">
|
||||
<p className="text-muted-foreground mb-1">
|
||||
Longest Sentence ({metrics.longestSentenceLength} words):
|
||||
</p>
|
||||
<p className="italic bg-secondary/35 p-2 rounded text-muted-foreground leading-relaxed">
|
||||
"{metrics.longestSentence}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timing Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/50 pb-2 mb-3">
|
||||
<Clock className="h-5 w-5 text-brand" />
|
||||
<h3 className="font-semibold text-sm">Timing</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand/10 rounded-lg text-brand">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Reading Time</p>
|
||||
<p className="text-base font-semibold">{metrics.readingTime} min</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#1a7a56]/10 rounded-lg text-[#1a7a56]">
|
||||
<Mic className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Speaking Time</p>
|
||||
<p className="text-base font-semibold">{metrics.speakingTime} min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Word Goal, Vocabulary */}
|
||||
<div className="space-y-6">
|
||||
{/* Word Goal Card */}
|
||||
<div
|
||||
className={`rounded-xl border p-4 shadow-sm transition-all duration-500 ${
|
||||
isGoalReached
|
||||
? 'border-[#1a7a56] bg-[#1a7a56]/5 animate-pulse-slow'
|
||||
: 'border-border bg-card/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isGoalReached ? (
|
||||
<Award className="h-5 w-5 text-[#1a7a56]" />
|
||||
) : (
|
||||
<Target className="h-5 w-5 text-brand" />
|
||||
)}
|
||||
<h3 className="font-semibold text-sm">Daily Word Goal</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Target:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={dailyGoal}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val > 0) {
|
||||
setSetting('dailyGoal', val);
|
||||
}
|
||||
}}
|
||||
className="h-7 w-20 text-xs px-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-xs font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{metrics.wordCount} / {dailyGoal} words
|
||||
</span>
|
||||
<span className={isGoalReached ? 'text-[#1a7a56]' : 'text-brand'}>
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 rounded-full ${
|
||||
isGoalReached ? 'bg-[#1a7a56]' : 'bg-brand'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isGoalReached && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[#1a7a56]/10 p-2 text-xs text-[#1a7a56] font-medium border border-[#1a7a56]/20">
|
||||
<Sparkles className="h-4 w-4 animate-spin-slow" />
|
||||
<span>Goal accomplished! Keep writing to set a new record.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vocabulary & Word Cloud Card */}
|
||||
<div className="rounded-xl border border-border bg-card/30 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-3">
|
||||
<h3 className="font-semibold text-sm">Vocabulary & Top Words</h3>
|
||||
<div className="text-right text-xs">
|
||||
<span className="text-muted-foreground">Lexical Diversity: </span>
|
||||
<span className="font-bold text-foreground">{metrics.lexicalDiversity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground mb-4">
|
||||
Unique words:{' '}
|
||||
<span className="font-semibold text-foreground">{metrics.uniqueWordCount}</span> /{' '}
|
||||
{metrics.wordCount} (
|
||||
{metrics.wordCount > 0
|
||||
? Math.round((metrics.uniqueWordCount / metrics.wordCount) * 100)
|
||||
: 0}
|
||||
%)
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">
|
||||
Word Frequency Cloud
|
||||
</p>
|
||||
{metrics.topWords.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-card/10 text-xs text-muted-foreground">
|
||||
Add more words to see vocabulary statistics
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-3 bg-secondary/20 rounded-lg min-h-32 border border-border/30">
|
||||
{metrics.topWords.map(({ word, count }) => {
|
||||
// Normalize count relative to maximum frequency for font scaling
|
||||
const scale = maxWordCount > 0 ? count / maxWordCount : 0.5;
|
||||
const size = Math.round((0.8 + scale * 0.8) * 10) / 10; // 0.8rem to 1.6rem
|
||||
const opacity = Math.round((0.5 + scale * 0.5) * 10) / 10; // 0.5 to 1.0
|
||||
|
||||
return (
|
||||
<span
|
||||
key={word}
|
||||
style={{
|
||||
fontSize: `${size}rem`,
|
||||
opacity: opacity,
|
||||
}}
|
||||
className="font-medium inline-block text-brand hover:text-brand-light transition-colors duration-200 cursor-default"
|
||||
title={`Occurred ${count} time${count === 1 ? '' : 's'}`}
|
||||
>
|
||||
{word}
|
||||
<sub className="text-[9px] text-muted-foreground font-normal ml-0.5">
|
||||
({count})
|
||||
</sub>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end border-t border-border pt-4">
|
||||
<Button onClick={closeModal}>Close</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function MarkdownRenderer({ source }: Props) {
|
||||
}, [source]);
|
||||
|
||||
return (
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none p-6 preview-content" ref={containerRef}>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{mermaidCodes.map((code, i) => (
|
||||
<div key={i} className="my-4">
|
||||
|
||||
@@ -39,7 +39,12 @@ export function MermaidLazy({ code }: Props) {
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
|
||||
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 }} />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ 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 }) {
|
||||
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;
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw, FileX, FilePlus, FileEdit, FileQuestion } from 'lucide-react';
|
||||
import {
|
||||
RefreshCw,
|
||||
FileX,
|
||||
FilePlus,
|
||||
FileEdit,
|
||||
FileQuestion,
|
||||
GitCommitHorizontal,
|
||||
CheckSquare,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface GitStatus {
|
||||
filePath: string;
|
||||
@@ -29,6 +41,10 @@ export function GitStatusPanel() {
|
||||
const [status, setStatus] = useState<GitStatus[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [commitMsg, setCommitMsg] = useState('');
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [staging, setStaging] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!rootPath) {
|
||||
@@ -50,12 +66,57 @@ export function GitStatusPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Listen for git.refresh command via custom event
|
||||
const handler = () => load();
|
||||
window.addEventListener('mc:git-refresh', handler);
|
||||
return () => window.removeEventListener('mc:git-refresh', handler);
|
||||
}, [rootPath]);
|
||||
|
||||
const toggleSelect = (filePath: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) next.delete(filePath);
|
||||
else next.add(filePath);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selected.size === status.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(status.map((s) => s.filePath)));
|
||||
}
|
||||
};
|
||||
|
||||
const stageFiles = async (files: string[]) => {
|
||||
if (!rootPath || files.length === 0) return;
|
||||
setStaging(true);
|
||||
const result = await ipc.file.gitStage({ rootPath, files });
|
||||
setStaging(false);
|
||||
if (result.ok) {
|
||||
toast.success(`Staged ${files.length} file${files.length === 1 ? '' : 's'}`);
|
||||
setSelected(new Set());
|
||||
window.dispatchEvent(new CustomEvent('mc:git-refresh'));
|
||||
} else {
|
||||
toast.error(`Failed to stage: ${result.error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
if (!rootPath || !commitMsg.trim()) return;
|
||||
setCommitting(true);
|
||||
const result = await ipc.file.gitCommit({ rootPath, message: commitMsg.trim() });
|
||||
setCommitting(false);
|
||||
if (result.ok) {
|
||||
toast.success('Changes committed');
|
||||
setCommitMsg('');
|
||||
setSelected(new Set());
|
||||
window.dispatchEvent(new CustomEvent('mc:git-refresh'));
|
||||
} else {
|
||||
toast.error(`Failed to commit: ${result.error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!rootPath) {
|
||||
return <div className="p-3 text-xs text-muted-foreground">No folder open</div>;
|
||||
}
|
||||
@@ -65,7 +126,9 @@ export function GitStatusPanel() {
|
||||
<div className="p-3 text-xs">
|
||||
<div className="text-destructive">Error: {error}</div>
|
||||
<p className="mt-1 text-muted-foreground">Not a git repository, or git not installed.</p>
|
||||
<Button size="sm" variant="ghost" onClick={load} className="mt-2">Retry</Button>
|
||||
<Button size="sm" variant="ghost" onClick={load} className="mt-2">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,27 +138,106 @@ export function GitStatusPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2 text-xs">
|
||||
<div className="flex flex-col p-2 text-xs">
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="font-semibold">{status.length} changed file{status.length === 1 ? '' : 's'}</span>
|
||||
<span className="font-semibold">
|
||||
{status.length} changed file{status.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={load} aria-label="Refresh">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{status.map((s) => (
|
||||
<button
|
||||
key={s.filePath}
|
||||
onClick={() => openFile(s.filePath)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50"
|
||||
data-testid="git-status-row"
|
||||
>
|
||||
{STATUS_ICON[s.status]}
|
||||
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
|
||||
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mb-1 flex gap-1 px-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={toggleSelectAll}
|
||||
data-testid="git-select-all"
|
||||
>
|
||||
{selected.size === status.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={selected.size === 0 || staging}
|
||||
onClick={() => stageFiles(Array.from(selected))}
|
||||
data-testid="git-stage-selected"
|
||||
>
|
||||
Stage Selected
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={staging}
|
||||
onClick={() => stageFiles(status.map((s) => s.filePath))}
|
||||
data-testid="git-stage-all"
|
||||
>
|
||||
Stage All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-0.5 overflow-y-auto">
|
||||
{status.map((s) => {
|
||||
const isSelected = selected.has(s.filePath);
|
||||
return (
|
||||
<button
|
||||
key={s.filePath}
|
||||
onClick={() => openFile(s.filePath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50',
|
||||
isSelected && 'bg-accent/40'
|
||||
)}
|
||||
data-testid="git-status-row"
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(s.filePath);
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
aria-label={isSelected ? 'Deselect' : 'Select'}
|
||||
data-testid="git-status-checkbox"
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="h-3 w-3 text-primary" />
|
||||
) : (
|
||||
<Square className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{STATUS_ICON[s.status]}
|
||||
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
|
||||
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
||||
<Input
|
||||
placeholder="Commit message..."
|
||||
className="h-7 text-xs"
|
||||
value={commitMsg}
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
}}
|
||||
data-testid="git-commit-input"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!commitMsg.trim() || committing}
|
||||
onClick={commit}
|
||||
data-testid="git-commit-button"
|
||||
>
|
||||
<GitCommitHorizontal className="h-3 w-3" />
|
||||
Commit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
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;
|
||||
}
|
||||
import { useCommandStore } from '@/stores/command-store';
|
||||
import { extractHeadings } from '@/lib/headings';
|
||||
|
||||
export function Outline() {
|
||||
const activeId = useEditorStore((s) => s.activeId);
|
||||
const buffers = useEditorStore((s) => s.buffers);
|
||||
const dispatch = useCommandStore((s) => s.dispatch);
|
||||
|
||||
if (!activeId) {
|
||||
return (
|
||||
@@ -64,7 +39,7 @@ export function Outline() {
|
||||
{headings.map((h, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {}}
|
||||
onClick={() => dispatch('editor.gotoHeading', h.line)}
|
||||
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` }}
|
||||
>
|
||||
|
||||
@@ -5,66 +5,135 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { FileTree } from './FileTree';
|
||||
import { Outline } from './Outline';
|
||||
import { Snippets } from './Snippets';
|
||||
import { Templates } from './Templates';
|
||||
import { GitStatusPanel } from './GitStatusPanel';
|
||||
|
||||
export function Sidebar() {
|
||||
const tree = useFileStore((s) => s.tree);
|
||||
|
||||
function scrollToSection(label: string) {
|
||||
const el = document.querySelector(`[data-sidebar-section="${label}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
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)]">
|
||||
<ScrollArea className="h-full pr-3">
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-files"
|
||||
data-sidebar-section="Files"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Files
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
{!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()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => useFileStore.getState().openFolderDialog()}
|
||||
>
|
||||
<FolderOpen className="mr-1" /> Open Folder
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FileTree />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</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)]">
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-outline"
|
||||
data-sidebar-section="Outline"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Outline
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<Outline />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Git section */}
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Git
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-snippets"
|
||||
data-sidebar-section="Snippets"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Snippets
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<Snippets />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-templates"
|
||||
data-sidebar-section="Templates"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Templates
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<Templates />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
data-testid="sidebar-section-git"
|
||||
data-sidebar-section="Git"
|
||||
className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-semibold hover:bg-accent rounded text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} className="rotate-90" />
|
||||
Git
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1">
|
||||
<GitStatusPanel />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Hidden bridge: when `view.sidebarPanel` is dispatched, scroll the
|
||||
matching section into view. The hidden elements expose a hook for
|
||||
Playwright tests and the menu handler. */}
|
||||
<div className="sr-only" aria-hidden="true">
|
||||
<button data-testid="sidebar-jump-explorer" onClick={() => scrollToSection('Files')}>
|
||||
jump-explorer
|
||||
</button>
|
||||
<button data-testid="sidebar-jump-outline" onClick={() => scrollToSection('Outline')}>
|
||||
jump-outline
|
||||
</button>
|
||||
<button data-testid="sidebar-jump-snippets" onClick={() => scrollToSection('Snippets')}>
|
||||
jump-snippets
|
||||
</button>
|
||||
<button data-testid="sidebar-jump-templates" onClick={() => scrollToSection('Templates')}>
|
||||
jump-templates
|
||||
</button>
|
||||
<button data-testid="sidebar-jump-git" onClick={() => scrollToSection('Git')}>
|
||||
jump-git
|
||||
</button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user