mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69afd5fc54 | ||
|
|
9e315af1b9 | ||
|
|
78914a4d65 | ||
|
|
3c4cc985fb | ||
|
|
09d9f6bdd1 | ||
|
|
f36d918871 | ||
|
|
ab16998922 | ||
|
|
58ca3014d8 | ||
|
|
7c1a79c724 | ||
|
|
8027d0b9b5 | ||
|
|
c0988a0108 | ||
|
|
18fc2317ae | ||
|
|
94dd62946a | ||
|
|
054ce2351a | ||
|
|
6651dccfdf | ||
|
|
848102905e | ||
|
|
6c7a86c99b | ||
|
|
b46fa9abaa | ||
|
|
2a32e702f3 | ||
|
|
356cfc5cc8 | ||
|
|
a03144df5b | ||
|
|
7fc11aed3b | ||
|
|
095f77f5f1 | ||
|
|
80afbb136b | ||
|
|
95bbe171b5 | ||
|
|
2d77fa5456 | ||
|
|
fbaff37505 | ||
|
|
1ccde4baa3 | ||
|
|
8822c9a2f9 | ||
|
|
324e5676b8 | ||
|
|
a62511e7ed | ||
|
|
a6c6e3696e | ||
|
|
1b2ab437d1 | ||
|
|
efc7709f66 | ||
|
|
a8f7547c7e | ||
|
|
094b52a2b3 |
@@ -94,8 +94,42 @@ jobs:
|
|||||||
dist/*.zip
|
dist/*.zip
|
||||||
retention-days: 5
|
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=never
|
||||||
|
|
||||||
|
- name: Upload macOS artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos-artifacts
|
||||||
|
path: |
|
||||||
|
dist/*.dmg
|
||||||
|
dist/*.zip
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-linux, build-windows]
|
needs: [build-linux, build-windows, build-macos]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -115,6 +149,13 @@ jobs:
|
|||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: dist
|
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
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to markdown-converter will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [5.0.0] - 2026-06-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Complete React 19 + Vite + TypeScript renderer** replacing the legacy vanilla-JS UI (Phases 1-9 of the React UI redesign)
|
||||||
|
- Native macOS/Windows/Linux menus with command palette and keyboard shortcuts
|
||||||
|
- Settings sheet with 5 tabs (editor, theme, keybindings, advanced, about) and 17+ persisted fields
|
||||||
|
- Modal layer with 13 modal kinds (export PDF/DOCX/HTML/Word, batch, settings, about, welcome, confirm, ASCII gen, table gen, find in files)
|
||||||
|
- 10 advanced tools: ASCII generator (figlet), table generator, Word export (.docx via `docx` lib), find-in-files (recursive regex), REPL (markdown snippet preview), print preview, zen mode (Esc exits), minimap, breadcrumbs-with-symbols, git status (porcelain parser)
|
||||||
|
- Sonner toast notifications at 4 wire points (file save, open file/folder, 4 export dialogs)
|
||||||
|
- 3 mount strategies: ModalLayer dialogs, App.tsx global overlays, editor/sidebar integrations
|
||||||
|
- `ipc.file.writeBuffer` for renderer-side binary file output (used by Word .docx export)
|
||||||
|
- `ipc.file.search` (recursive regex), `ipc.file.gitStatus`, `ipc.print.show`, `ipc.app.showSaveDialog`
|
||||||
|
- 305 unit + integration tests (vitest + React Testing Library)
|
||||||
|
- Per-package @radix-ui primitives (checkbox, dialog, select, switch, tabs, radio-group, scroll-area, slider, collapsible, label, context-menu)
|
||||||
|
- shadcn/ui (new-york style) primitives, manually pasted (CLI broken on Node 24)
|
||||||
|
- Feature-first main process decomposition: `src/main/{files,menu,window,word-template,utils}/` + glue files
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **BREAKING**: Renderer is now React-only. The legacy `src/renderer.js` (5319 lines) and all vanilla-JS UI scripts/styles are removed.
|
||||||
|
- Main process decomposed from a 4311-line `src/main.js` into feature-first modules under `src/main/`
|
||||||
|
- New entrypoint: `src/main/index.js` (was `src/main.js`)
|
||||||
|
- `src/index.html` (1667-line legacy orphan) removed; renderer served by `src/renderer/index.html` (Vite root)
|
||||||
|
- IPC contract: handlers throw on error, `safeCall` catches → returns `{ ok: false, error }`. `result.ok` is at top level, NOT nested in `result.data.ok`
|
||||||
|
- Settings store: `useSettingsStore` (zustand persist with zod validation), 17+ persisted fields
|
||||||
|
- Modal state: `useAppStore.modal: ModalState` discriminated union with 13 kinds; `openModal` uses TS conditional types to enforce prop requirements
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `src/renderer.js` (legacy vanilla-JS renderer, 5319 lines)
|
||||||
|
- 8 legacy stylesheets: `src/styles.css`, `src/styles-modern.css`, `src/styles-concreteinfo.css`, `src/styles-sidebar.css`, `src/styles-zen.css`, `src/styles-welcome.css`, `src/fonts.css`
|
||||||
|
- 5 legacy scripts: `src/command-palette.js`, `src/print-preview.js`, `src/welcome.js`, `src/zen-mode.js`, `src/wordTemplateExporter.js`
|
||||||
|
- 2 legacy HTMLs: `src/ascii-generator.html`, `src/table-generator.html`
|
||||||
|
- `src/main.js` (4311-line god file, replaced by `src/main/index.js`)
|
||||||
|
- `src/index.html` (1667-line legacy orphan at project root, replaced by `src/renderer/index.html`)
|
||||||
|
- 9 dead IPC channels from `src/preload.js`: `toggle-command-palette`, `print-preview`, `print-preview-styled`, `open-ascii-generator`, `open-table-generator`, `show-ascii-generator`, `show-ascii-generator-window`, `show-table-generator`, `show-table-generator-window`
|
||||||
|
|
||||||
|
[5.0.0]: https://github.com/amitwh/markdown-converter/releases/tag/v5.0.0
|
||||||
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,303 @@
|
|||||||
|
# Phase 9 — Advanced Tools Design
|
||||||
|
|
||||||
|
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phase 9 is sketched at high level; this spec locks architecture, file map, and contracts so it can be planned task-by-task.)
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Phase:** 9 of 10 (React + shadcn/ui UI redesign)
|
||||||
|
**Tag (on completion):** `phase-9-advanced-tools`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal & Non-Goals
|
||||||
|
|
||||||
|
**Goal:** Add 10 advanced tools to the React renderer. Group 1: Standalone dialogs (ASCII generator, Table generator, Word export, Find-in-files). Group 2: Global overlays (Zen mode, REPL, Print preview). Group 3: Editor/sidebar integrations (Minimap, Breadcrumbs-with-symbols, Git status). All triggered via the Phase 6 command store.
|
||||||
|
|
||||||
|
**Non-goals (Phase 9):**
|
||||||
|
- True undo/redo stack (still not in scope)
|
||||||
|
- Snippet/template library
|
||||||
|
- Custom REPL with full JS eval (we chose markdown snippet preview for safety)
|
||||||
|
- Custom diff/merge UI (just shows git status, no in-app diffing)
|
||||||
|
- Plug-in extensions
|
||||||
|
- Multi-cursor editing
|
||||||
|
- LSP / language server integration
|
||||||
|
|
||||||
|
**Decision summary (from brainstorming):**
|
||||||
|
- REPL = **markdown snippet preview** (no JS eval — safe in renderer)
|
||||||
|
- Word export = **`.docx` via `docx` lib in renderer**, with both Standard and Custom .dotx template modes
|
||||||
|
- Find-in-files = **recursive search with result navigation** (new IPC, regex support)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
### 2.1 Three mount strategies
|
||||||
|
|
||||||
|
| Category | Mount | Examples |
|
||||||
|
|---|---|---|
|
||||||
|
| **Dialogs** | `<ModalLayer />` (Phase 7 pattern) | ASCII gen, Table gen, Word export, Find-in-files |
|
||||||
|
| **Global overlays** | Top-level in `App.tsx` (like `<Toaster />`) | Zen mode, REPL panel, Print preview |
|
||||||
|
| **Editor/sidebar integrations** | Extend existing components | Minimap, Breadcrumbs, Git status |
|
||||||
|
|
||||||
|
The ModalLayer is the dispatcher for dialogs. The global overlays are mounted directly in `App.tsx` because they need to participate in the top-level layout (full-window, or pinned to the bottom).
|
||||||
|
|
||||||
|
### 2.2 ModalState union extension
|
||||||
|
|
||||||
|
The Phase 7 `ModalState` discriminated union (9 kinds) gets 4 new kinds for the new dialogs:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ModalState =
|
||||||
|
| { kind: null }
|
||||||
|
| { kind: 'export-pdf'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'export-docx'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'export-html'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'export-batch'; props: { sourcePaths: string[] } }
|
||||||
|
| { kind: 'export-word'; props: { sourcePath: string } } // NEW
|
||||||
|
| { kind: 'ascii-generator' } // NEW
|
||||||
|
| { kind: 'table-generator' } // NEW
|
||||||
|
| { kind: 'find-in-files' } // NEW
|
||||||
|
| { kind: 'settings' }
|
||||||
|
| { kind: 'about' }
|
||||||
|
| { kind: 'welcome' }
|
||||||
|
| { kind: 'confirm'; props: ConfirmProps };
|
||||||
|
```
|
||||||
|
|
||||||
|
Three of the four new kinds (ascii, table, find-in-files) take no props — they read from the active buffer via `useExportSource` (for ascii/table) or from `useFileStore.rootPath` (for find-in-files). `export-word` takes `sourcePath` like the other export dialogs.
|
||||||
|
|
||||||
|
### 2.3 New commands in command store
|
||||||
|
|
||||||
|
| Command ID | Trigger | Opens |
|
||||||
|
|---|---|---|
|
||||||
|
| `tools.ascii` | new | `AsciiGeneratorDialog` |
|
||||||
|
| `tools.table` | new | `TableGeneratorDialog` |
|
||||||
|
| `tools.exportWord` | new | `WordExportDialog` |
|
||||||
|
| `tools.findInFiles` | new | `FindInFilesDialog` |
|
||||||
|
| `tools.repl` | new | toggles REPL panel |
|
||||||
|
| `view.zenMode` | existing (Phase 6) | toggles Zen mode overlay |
|
||||||
|
| `file.print` | new | opens `PrintPreview` overlay |
|
||||||
|
| `git.refresh` | new | re-fetches git status |
|
||||||
|
|
||||||
|
All registered in `src/renderer/lib/commands/register-menu-commands.ts`.
|
||||||
|
|
||||||
|
### 2.4 New IPC surface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/renderer/lib/ipc.ts (additions)
|
||||||
|
ipc.file.search({ rootPath, query, isRegex, caseSensitive }: {
|
||||||
|
rootPath: string;
|
||||||
|
query: string;
|
||||||
|
isRegex: boolean;
|
||||||
|
caseSensitive: boolean;
|
||||||
|
}): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }>>>;
|
||||||
|
|
||||||
|
ipc.file.gitStatus({ rootPath }: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }>>>;
|
||||||
|
|
||||||
|
ipc.file.print({ html }: { html: string }): Promise<IpcResult<void>>;
|
||||||
|
|
||||||
|
ipc.file.writeBuffer({ path, buffer }: { path: string; buffer: Uint8Array }): Promise<IpcResult<void>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The main process counterparts are added to `src/main.js` (or a new `src/main/files-search.js`, etc.). For Phase 9, the spec covers the renderer-side design; main-process IPC handlers are assumed to follow the same `ipcMain.handle` pattern as the existing handlers.
|
||||||
|
|
||||||
|
### 2.5 Settings additions (`useSettingsStore`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// zod schema additions:
|
||||||
|
docxCustomTemplatePath: z.string().nullable().default(null), // path to user .dotx file
|
||||||
|
replOpen: z.boolean().default(false), // REPL panel visibility
|
||||||
|
breadcrumbSymbols: z.boolean().default(true), // breadcrumbs show code symbols
|
||||||
|
// minimap already exists from Phase 7 (useSettingsStore.minimap: z.boolean().default(true))
|
||||||
|
```
|
||||||
|
|
||||||
|
The `minimap` setting from Phase 7 is now wired (not just stored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. File Map
|
||||||
|
|
||||||
|
### 3.1 Created files
|
||||||
|
|
||||||
|
**Dialogs (in `src/renderer/components/modals/`):**
|
||||||
|
- `AsciiGeneratorDialog.tsx` — input textarea, font select, output preview with copy button
|
||||||
|
- `TableGeneratorDialog.tsx` — rows × cols inputs, header checkbox, output preview
|
||||||
|
- `WordExportDialog.tsx` — template select (Standard / Custom .dotx), options, preview, export
|
||||||
|
- `FindInFilesDialog.tsx` — query input, regex/case toggles, results list with click-to-navigate
|
||||||
|
|
||||||
|
**Global overlays (in `src/renderer/components/tools/`):**
|
||||||
|
- `ReplPanel.tsx` — bottom-pinned, textarea + rendered preview
|
||||||
|
- `PrintPreview.tsx` — full-window print preview
|
||||||
|
|
||||||
|
**Editor/sidebar integrations:**
|
||||||
|
- `src/renderer/components/layout/ZenMode.tsx` — wraps/controls the editor in zen mode (or modify `AppShell.tsx` to hide chrome)
|
||||||
|
- `src/renderer/components/sidebar/GitStatusPanel.tsx` — file list with status badges
|
||||||
|
|
||||||
|
**Lib:**
|
||||||
|
- `src/renderer/lib/docx-export.ts` — renderer-side `docx` lib integration (markdown → Blob)
|
||||||
|
- `src/renderer/hooks/use-zen-mode.ts` — small hook that reads `useSettingsStore.zenMode`
|
||||||
|
|
||||||
|
**Modifications:**
|
||||||
|
- `src/renderer/App.tsx` — mount `<ReplPanel />` and `<PrintPreview />` alongside `<ModalLayer />` and `<Toaster />`
|
||||||
|
- `src/renderer/components/layout/AppShell.tsx` — when `zenMode === true`, hide all chrome except the editor
|
||||||
|
- `src/renderer/components/editor/CodeMirrorEditor.tsx` — add `@codemirror/minimap` (or `@replit/codemirror-minimap`) when `useSettingsStore.minimap` is true
|
||||||
|
- `src/renderer/components/layout/Breadcrumb.tsx` — extend to show symbols (headings, code blocks) using `@codemirror/langs-data` or a simple markdown AST walk
|
||||||
|
- `src/renderer/components/sidebar/Sidebar.tsx` — add a "Git" tab to the existing tab list
|
||||||
|
- `src/renderer/stores/settings-store.ts` — add 3 new fields
|
||||||
|
- `src/renderer/lib/validators.ts` — add 3 new fields to `settingsSchema`
|
||||||
|
- `src/renderer/lib/ipc.ts` — add 4 new IPC methods
|
||||||
|
- `src/renderer/lib/commands/register-menu-commands.ts` — add 8 new commands
|
||||||
|
- `src/main.js` (or split files) — add 4 main-process IPC handlers
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
- `tests/component/tools/ReplPanel.test.tsx` — smoke test
|
||||||
|
- `tests/component/tools/PrintPreview.test.tsx` — smoke test
|
||||||
|
- `tests/component/modals/AsciiGeneratorDialog.test.tsx` — 3 tests
|
||||||
|
- `tests/component/modals/TableGeneratorDialog.test.tsx` — 3 tests
|
||||||
|
- `tests/component/modals/WordExportDialog.test.tsx` — 4 tests
|
||||||
|
- `tests/component/modals/FindInFilesDialog.test.tsx` — 4 tests
|
||||||
|
- `tests/component/sidebar/GitStatusPanel.test.tsx` — 3 tests
|
||||||
|
- `tests/component/layout/Breadcrumb.test.tsx` — extend with symbols test
|
||||||
|
- `tests/integration/phase9-tools-smoke.test.tsx` — 6 tests (one per command-triggered tool)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Flow
|
||||||
|
|
||||||
|
### 4.1 Word export (most complex)
|
||||||
|
|
||||||
|
1. User triggers `tools.exportWord` → `registerMenuCommands` opens the export-word modal via `useAppStore.openModal('export-word', { sourcePath: activeTabId })`
|
||||||
|
2. **Wait** — we need a new modal kind `export-word` in the `ModalState` union. Add it.
|
||||||
|
3. `WordExportDialog` (mounted by ModalLayer) shows:
|
||||||
|
- Template select: "Standard (bundled)" or "Custom .dotx (your file)" — pre-populated with `useSettingsStore.docxCustomTemplatePath`
|
||||||
|
- "Choose custom template..." button (file picker, saves to settings)
|
||||||
|
- Options: embed images (checkbox), include front matter (checkbox)
|
||||||
|
- Preview area: shows the generated docx structure (sizes, table of contents)
|
||||||
|
4. On submit:
|
||||||
|
- Renderer calls `lib/docx-export.ts#generateDocx(source, templatePath, options)` which uses the `docx` lib
|
||||||
|
- The lib takes a `Document` AST and produces a Blob
|
||||||
|
- ASCII tables are converted to monospace `<w:r>` runs (using `applyAsciiTransform` from Phase 7)
|
||||||
|
- Images are extracted from markdown and embedded as base64
|
||||||
|
- If a custom .dotx path is set, the renderer reads it via `ipc.file.read` and applies its styles (via `docx` lib's style support)
|
||||||
|
- User picks output path via `ipc.app.showSaveDialog`
|
||||||
|
- Writes Blob via `ipc.file.writeBuffer` (new)
|
||||||
|
- Toast success/failure
|
||||||
|
|
||||||
|
### 4.2 Find-in-files
|
||||||
|
|
||||||
|
1. User triggers `tools.findInFiles` → ModalLayer opens `FindInFilesDialog`
|
||||||
|
2. User types query, picks regex/literal, case-sensitive toggle
|
||||||
|
3. On submit: `ipc.file.search({ rootPath: useFileStore.rootPath, query, isRegex, caseSensitive })`
|
||||||
|
4. Backend walks the disk recursively, applies regex/literal match, returns `Array<{ filePath, line, content }>`
|
||||||
|
5. Dialog shows result list (file:line:content, clickable)
|
||||||
|
6. Click a result:
|
||||||
|
- `useFileStore.openFile(filePath)` if not already open
|
||||||
|
- Set the cursor in the editor to the matched line
|
||||||
|
7. Close the dialog
|
||||||
|
|
||||||
|
### 4.3 REPL (markdown snippet preview)
|
||||||
|
|
||||||
|
1. User triggers `tools.repl` → toggles `useSettingsStore.replOpen` (or a new dedicated `useReplStore`)
|
||||||
|
2. `ReplPanel` (mounted in App.tsx) is visible when `replOpen === true`
|
||||||
|
3. The panel has:
|
||||||
|
- Top half: textarea (user types/pastes markdown)
|
||||||
|
- Bottom half: rendered preview (uses `lib/markdown.ts` from Phase 1 + DOMPurify)
|
||||||
|
4. Updates are debounced (300ms) for the preview
|
||||||
|
5. Pure renderer-side, no IPC
|
||||||
|
|
||||||
|
### 4.4 Zen mode
|
||||||
|
|
||||||
|
1. User triggers `view.zenMode` → toggles `useSettingsStore.zenMode`
|
||||||
|
2. `AppShell` reads `zenMode` from store; when true, hides all chrome (header, tabs, toolbar, breadcrumb, status bar, sidebar)
|
||||||
|
3. Editor goes fullscreen
|
||||||
|
4. Pressing Esc exits zen mode (keydown listener in `use-zen-mode` hook)
|
||||||
|
|
||||||
|
### 4.5 Print preview
|
||||||
|
|
||||||
|
1. User triggers `file.print` → opens `PrintPreview` (full-window overlay)
|
||||||
|
2. The preview renders the current buffer's content as it would appear in print
|
||||||
|
3. Two buttons: "Print" (calls `ipc.file.print({ html })` which opens native print dialog) and "Close"
|
||||||
|
|
||||||
|
### 4.6 Minimap
|
||||||
|
|
||||||
|
1. `useSettingsStore.minimap` (existing setting) is read by `CodeMirrorEditor`
|
||||||
|
2. When true, the editor's extension includes `@replit/codemirror-minimap` (or a simpler custom implementation)
|
||||||
|
3. The minimap shows a shrunk version of the document on the right side of the editor
|
||||||
|
4. Toggling the setting adds/removes the extension dynamically
|
||||||
|
|
||||||
|
### 4.7 Breadcrumbs-with-symbols
|
||||||
|
|
||||||
|
1. The current `Breadcrumb` shows the file path. Extend it to also show markdown symbols (headings, code blocks)
|
||||||
|
2. Use a simple AST walk of the current buffer to extract heading levels
|
||||||
|
3. Show as a path-like navigation: `file.md > H1: Title > H2: Section > #line`
|
||||||
|
4. The `useSettingsStore.breadcrumbSymbols` toggle (default true) controls whether symbols are shown
|
||||||
|
|
||||||
|
### 4.8 Git status
|
||||||
|
|
||||||
|
1. On sidebar Git tab open, fetch `ipc.file.gitStatus({ rootPath: useFileStore.rootPath })`
|
||||||
|
2. The main process runs `git status --porcelain` (or equivalent) and returns the list
|
||||||
|
3. Show file list with status badges (M, A, D, ?)
|
||||||
|
4. Click a file opens it via `useFileStore.openFile`
|
||||||
|
5. `git.refresh` re-fetches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Error Handling
|
||||||
|
|
||||||
|
- **Word export failures:** `lib/docx-export.ts` catches `docx` lib errors → toast.error
|
||||||
|
- **Word export missing .dotx:** if custom template path is set but file doesn't exist, show inline banner in the dialog
|
||||||
|
- **Find-in-files failures:** if `ipc.file.search` throws (regex syntax error, IO error), show inline banner in the dialog
|
||||||
|
- **REPL:** no IPC, no error handling needed
|
||||||
|
- **Git status:** if folder isn't a git repo, return empty array. Panel shows "Not a git repository" message
|
||||||
|
- **Print:** if `ipc.file.print` fails (no printer, user cancels), toast.error or silent close
|
||||||
|
- **Minimap, Breadcrumbs:** renderer-side, no errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing Strategy
|
||||||
|
|
||||||
|
- **Unit tests:** `lib/docx-export.ts` (markdown → docx conversion, ASCII handling), `lib/ascii-table.ts` (already tested)
|
||||||
|
- **Component tests:** 1-2 smoke tests per new component
|
||||||
|
- **Integration test:** `phase9-tools-smoke.test.tsx` covering the 6 command-triggered tools (dispatch opens correct modal/overlay)
|
||||||
|
- **Editor integrations:** minimap toggle, breadcrumbs render with symbols, git status panel render
|
||||||
|
- **Target: +30-40 new tests**, total ~290
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & Open Questions
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- **`docx` lib bundle size (~500KB).** Acceptable for a desktop app but worth noting. Lazy-load if it becomes a concern.
|
||||||
|
- **Word export custom template parsing** — the `.dotx` format is a zip with XML inside. The `docx` lib may not fully support reading existing templates. v1 may need a simpler "styles only" extraction.
|
||||||
|
- **Find-in-files regex compilation** — invalid regex would throw. Validate on the renderer before calling IPC.
|
||||||
|
- **Minimap performance** — for large files, the minimap can slow down scrolling. CodeMirror's built-in minimap has performance options to configure.
|
||||||
|
|
||||||
|
**Open questions (deferrable):**
|
||||||
|
- **REPL persistence** — should the textarea content survive across modal close/reopen? → Decision: NO for v1. Keep simple.
|
||||||
|
- **Git status auto-refresh** — should it auto-refresh every N seconds? → Decision: NO for v1. Manual via `git.refresh` command.
|
||||||
|
- **Find-in-files result count limit** — what if there are 10,000 matches? → Decision: limit to 500 results for v1, show "X more matches" message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of Scope (deferred to Phase 10 or later)
|
||||||
|
|
||||||
|
- Custom undo/redo (still not in scope)
|
||||||
|
- Snippet/template library
|
||||||
|
- Multi-cursor editing
|
||||||
|
- LSP / language server
|
||||||
|
- Phase 10 will delete legacy files including the old `src/print-preview.js`, `src/wordTemplateExporter.js`, `src/welcome.js`, `src/zen-mode.js`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Success Criteria
|
||||||
|
|
||||||
|
Phase 9 is complete when:
|
||||||
|
- All 10 features implemented and accessible via the command store
|
||||||
|
- `lib/docx-export.ts` generates valid `.docx` files
|
||||||
|
- Find-in-files returns results from a recursive disk walk
|
||||||
|
- REPL renders markdown snippet previews
|
||||||
|
- Zen mode hides all chrome and Esc exits
|
||||||
|
- Print preview opens native print dialog
|
||||||
|
- Minimap appears when `useSettingsStore.minimap` is true
|
||||||
|
- Breadcrumbs show symbols by default
|
||||||
|
- Git status panel shows modified/added/deleted/untracked files
|
||||||
|
- ~+30-40 new tests, total ~290
|
||||||
|
- `npx vite build` succeeds
|
||||||
|
- Branch tagged `phase-9-advanced-tools` and pushed to origin
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# Phase 10 — Polish + Delete Legacy Design
|
||||||
|
|
||||||
|
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phase 10 is sketched at high level; this spec locks architecture, file map, deletion targets, and version strategy.)
|
||||||
|
|
||||||
|
**Date:** 2026-06-06
|
||||||
|
**Phase:** 10 of 10 (React + shadcn/ui UI redesign) — **FINAL**
|
||||||
|
**Tag (on completion):** `v5.0.0` (the first release tag, all prior phases were `phase-N-*` working tags)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal & Non-Goals
|
||||||
|
|
||||||
|
**Goal:** Finalize the React UI redesign. Decompose the legacy 146KB `src/main.js` into a feature-first modular structure under `src/main/`, remove the legacy vanilla-JS renderer and all dead IPC bridges, then ship **v5.0.0** with a CHANGELOG.
|
||||||
|
|
||||||
|
**Non-goals (Phase 10):**
|
||||||
|
- Adding new features (Phases 1-9 shipped all of them)
|
||||||
|
- A full main-process test suite (the existing main process is largely untested; adding tests is Phase 11+)
|
||||||
|
- Backwards compat shims for the legacy renderer
|
||||||
|
- Renderer-side refactor (already done)
|
||||||
|
- Migrating the renderer build pipeline further (vite configs are in place)
|
||||||
|
|
||||||
|
**Success criteria:**
|
||||||
|
- `src/main.js` is gone; `src/main/index.js` is the new entrypoint
|
||||||
|
- `package.json#main` points to `src/main/index.js`
|
||||||
|
- All 12 legacy renderer files are deleted
|
||||||
|
- All 9 dead IPC channels are removed from `preload.js` and `main.js`
|
||||||
|
- `src/index.html` is reduced to ~50 lines (just the Vite bootstrap)
|
||||||
|
- `package.json#version` is `5.0.0`
|
||||||
|
- `CHANGELOG.md` exists in Keep a Changelog 1.1.0 format
|
||||||
|
- `git grep -E "renderer\.js|command-palette|print-preview|welcome\.js|zen-mode|wordTemplate|ascii-generator|table-generator|styles\.css"` returns zero results
|
||||||
|
- All 305 tests still pass
|
||||||
|
- `npx vite build --config vite.renderer.config.ts` succeeds
|
||||||
|
- `npx electron .` launches and main window renders
|
||||||
|
- Tag `v5.0.0` pushed to origin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Target Architecture
|
||||||
|
|
||||||
|
### 2.1 `src/main/` (feature-first decomposition)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/
|
||||||
|
├── index.js # entrypoint: bootstraps store, app, window, ipc
|
||||||
|
├── store.js # electron-store wrapper (preferences + wordTemplatePath)
|
||||||
|
├── ipc.js # ipcMain.handle registration (composes from modules)
|
||||||
|
├── files/
|
||||||
|
│ ├── index.js # file ops facade (read/write/list/pickFolder/pickFile)
|
||||||
|
│ ├── search.js # recursive regex search (used by find-in-files)
|
||||||
|
│ ├── git.js # git status porcelain parser
|
||||||
|
│ └── binary.js # writeBuffer helper (Uint8Array → file)
|
||||||
|
├── menu/
|
||||||
|
│ ├── index.js # buildMenu() — composes the app menu
|
||||||
|
│ └── items.js # individual menu items (File, Edit, View, etc.)
|
||||||
|
├── window/
|
||||||
|
│ ├── index.js # createMainWindow ONLY (createAsciiWindow/createTableWindow are DEAD)
|
||||||
|
│ └── state.js # window state persistence
|
||||||
|
├── word-template/
|
||||||
|
│ ├── index.js # WordTemplateExporter facade
|
||||||
|
│ ├── parser.js # .dotx parsing
|
||||||
|
│ ├── converter.js # markdown → docx
|
||||||
|
│ └── apply.js # apply template styles to converted docx
|
||||||
|
└── utils/
|
||||||
|
├── paths.js # path helpers
|
||||||
|
├── logger.js # structured logging
|
||||||
|
└── download.js # tool downloader
|
||||||
|
```
|
||||||
|
|
||||||
|
**Module rules:**
|
||||||
|
- Each file has one clear responsibility (no god files; CLAUDE.md says >300 lines is the cap)
|
||||||
|
- `index.js` is the public face of a folder; deeper files are private
|
||||||
|
- `src/main/index.js` (top-level) wires everything together
|
||||||
|
- Cross-folder imports go through `index.js`, not directly to internals
|
||||||
|
|
||||||
|
### 2.2 Entry point change
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```json
|
||||||
|
// package.json
|
||||||
|
"main": "src/main.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```json
|
||||||
|
// package.json
|
||||||
|
"main": "src/main/index.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
The new `src/main/index.js` runs the same `app.whenReady().then(...)` flow that the current `src/main.js` does, but composes the decomposed modules.
|
||||||
|
|
||||||
|
### 2.3 What becomes dead code (removed during decomposition)
|
||||||
|
|
||||||
|
When the legacy renderer files are deleted, these main-process functions become orphaned and are removed too:
|
||||||
|
|
||||||
|
| Function | File | Why dead |
|
||||||
|
|---|---|---|
|
||||||
|
| `createAsciiWindow()` | `src/main/window/index.js` | Loads deleted `src/ascii-generator.html` |
|
||||||
|
| `createTableWindow()` | `src/main/window/index.js` | Loads deleted `src/table-generator.html` |
|
||||||
|
| `ipcMain.on('open-ascii-generator', ...)` | `src/main/index.js` (after decomposition) | Replaced by React `<AsciiGeneratorDialog>` |
|
||||||
|
| `ipcMain.on('open-table-generator', ...)` | `src/main/index.js` (after decomposition) | Replaced by React `<TableGeneratorDialog>` |
|
||||||
|
| `webContents.send('print-preview')` | `src/main/menu/items.js` | Replaced by React `<PrintPreview>` |
|
||||||
|
| `webContents.send('print-preview-styled')` | `src/main/menu/items.js` | Replaced by React `<PrintPreview>` |
|
||||||
|
| `webContents.send('toggle-command-palette')` | `src/main/menu/items.js` | Replaced by `useCommandStore` |
|
||||||
|
| `require('./wordTemplateExporter')` | `src/main/index.js` (after decomposition) | Replaced by `src/main/word-template/` + renderer-side `lib/docx-export.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Legacy Deletion Map
|
||||||
|
|
||||||
|
### 3.1 Files to delete from `src/` (12 files, ~14,426 lines)
|
||||||
|
|
||||||
|
| File | Size | Lines | Replaced by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/renderer.js` | 213KB | 5319 | `src/renderer/` (Phases 1-9) |
|
||||||
|
| `src/styles.css` | 74KB | 3723 | `src/renderer/index.css` + Tailwind |
|
||||||
|
| `src/styles-modern.css` | 71KB | 2625 | Tailwind + shadcn/ui |
|
||||||
|
| `src/styles-concreteinfo.css` | 21KB | 969 | Tailwind theme tokens |
|
||||||
|
| `src/styles-sidebar.css` | 9.7KB | 304 | `<Sidebar>` component |
|
||||||
|
| `src/styles-zen.css` | 2.1KB | 102 | Zen mode in AppShell |
|
||||||
|
| `src/styles-welcome.css` | 2.2KB | 24 | Welcome dialog |
|
||||||
|
| `src/fonts.css` | 1.8KB | (imported elsewhere) | Tailwind font config |
|
||||||
|
| `src/command-palette.js` | (small) | 109 | `useCommandStore` |
|
||||||
|
| `src/print-preview.js` | (small) | 138 | `<PrintPreview>` |
|
||||||
|
| `src/welcome.js` | (small) | 78 | `<Welcome>` modal |
|
||||||
|
| `src/zen-mode.js` | (small) | 292 | `use-zen-mode` hook + AppShell |
|
||||||
|
| `src/wordTemplateExporter.js` | (small) | 743 | `src/main/word-template/` + renderer `lib/docx-export.ts` |
|
||||||
|
| `src/ascii-generator.html` | 34KB | (HTML) | `<AsciiGeneratorDialog>` |
|
||||||
|
| `src/table-generator.html` | 18KB | (HTML) | `<TableGeneratorDialog>` |
|
||||||
|
| `src/index.html` | 103KB | 1667 | `src/renderer/index.html` (already exists, Vite root) |
|
||||||
|
|
||||||
|
**Total: 13 files = ~16,093 lines / ~548KB**
|
||||||
|
|
||||||
|
`src/renderer/index.html` (the live Vite template) is NOT deleted.
|
||||||
|
|
||||||
|
### 3.2 Dead IPC channels to remove from `src/preload.js`
|
||||||
|
|
||||||
|
- `toggle-command-palette` (line 238)
|
||||||
|
- `open-ascii-generator` (line 89)
|
||||||
|
- `open-table-generator` (line 92)
|
||||||
|
- `print-preview` (line 173)
|
||||||
|
- `print-preview-styled` (line 174)
|
||||||
|
- `show-table-generator` (line 180)
|
||||||
|
- `show-ascii-generator-window` (line 217)
|
||||||
|
- `show-ascii-generator` (line 218)
|
||||||
|
- `show-table-generator-window` (line 221)
|
||||||
|
|
||||||
|
And from the exposed API surface:
|
||||||
|
- `openAscii: () => ipcRenderer.send('open-ascii-generator')` (line 445)
|
||||||
|
- `openTable: () => ipcRenderer.send('open-table-generator')` (line 446)
|
||||||
|
|
||||||
|
**9 channel names + 2 API entries to remove.**
|
||||||
|
|
||||||
|
### 3.3 Legacy references in `src/index.html` (16 places) — **the LEGACY file at project root, 1667 lines**
|
||||||
|
|
||||||
|
- `<link rel="stylesheet" href="styles.css">` (line ~6)
|
||||||
|
- `<link rel="stylesheet" href="styles-welcome.css">` (line ~7)
|
||||||
|
- `<div id="print-preview-overlay" class="modal hidden" ...>` block (~10 lines)
|
||||||
|
- `<div class="command-palette-overlay hidden" id="command-palette-overlay">` block (~5 lines)
|
||||||
|
- `<script src="renderer.js"></script>` (final script tag)
|
||||||
|
|
||||||
|
**All of these are removed by deleting the file `src/index.html` entirely.**
|
||||||
|
|
||||||
|
The CURRENT live renderer template is **`src/renderer/index.html`** (already correct: minimal, CSP, Plus Jakarta Sans font, `<script type="module" src="./main.tsx">`). This file is **NOT touched** in Phase 10. Vite's `root` is set to `src/renderer/` in `vite.renderer.config.ts`, so `src/renderer/index.html` is the HTML template Vite uses. The `src/index.html` at the project root is an orphan from the pre-Vite era.
|
||||||
|
|
||||||
|
**Verification:** after deleting `src/index.html`, the renderer build still works because Vite doesn't look at the project root — it uses `src/renderer/index.html`.
|
||||||
|
|
||||||
|
### 3.4 Main process rewiring
|
||||||
|
|
||||||
|
- Remove `require('./wordTemplateExporter')` from main entrypoint
|
||||||
|
- Remove `webContents.send('print-preview*')` and `webContents.send('toggle-command-palette')` from menu items
|
||||||
|
- Remove `ipcMain.on('open-ascii-generator', ...)` and `ipcMain.on('open-table-generator', ...)` handlers
|
||||||
|
- Remove `asciiGeneratorWindow` and `tableGeneratorWindow` global state (and any references to `loadFile(...ascii-generator.html)` / `loadFile(...table-generator.html)`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `src/index.html` — DELETE (not rewrite)
|
||||||
|
|
||||||
|
The current `src/index.html` is 1667 lines of legacy markup and is **DELETED** (not rewritten). The renderer is already correctly served by `src/renderer/index.html` (the Vite root). Deleting the legacy root-level `src/index.html` is the action — no replacement file is needed.
|
||||||
|
|
||||||
|
`src/renderer/index.html` (already correct) is NOT modified:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; ...">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MarkdownConverter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:..." rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="font-sans antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Version Bump + CHANGELOG
|
||||||
|
|
||||||
|
### 5.1 `package.json`
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- "version": "4.4.2",
|
||||||
|
+ "version": "5.0.0",
|
||||||
|
```
|
||||||
|
|
||||||
|
Major version bump because the legacy renderer removal is a breaking change for anyone who had plugins/integrations pointing at `src/renderer.js`.
|
||||||
|
|
||||||
|
### 5.2 `CHANGELOG.md` (new file, Keep a Changelog 1.1.0 format)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to markdown-converter will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [5.0.0] - 2026-06-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Complete React 19 + Vite + TypeScript renderer** replacing the legacy vanilla-JS UI
|
||||||
|
- Native macOS/Windows/Linux menus with command palette and keyboard shortcuts
|
||||||
|
- Settings sheet (5 tabs: editor, theme, keybindings, advanced, about)
|
||||||
|
- Modal layer with 13 modal kinds (export PDF/DOCX/HTML/Word, batch, settings, about, welcome, confirm, ASCII gen, table gen, find in files)
|
||||||
|
- 10 advanced tools: ASCII generator, table generator, Word export, find-in-files, REPL, print preview, zen mode, minimap, breadcrumbs-with-symbols, git status
|
||||||
|
- Sonner toast notifications at 4 wire points
|
||||||
|
- 3 mount strategies: ModalLayer dialogs, App.tsx overlays, editor/sidebar integrations
|
||||||
|
- `ipc.file.writeBuffer` for renderer-side binary output
|
||||||
|
- `ipc.file.search` (recursive regex), `ipc.file.gitStatus`, `ipc.print.show`, `ipc.app.showSaveDialog`
|
||||||
|
- 305 unit + integration tests (vitest + React Testing Library)
|
||||||
|
- Per-package @radix-ui primitives
|
||||||
|
- shadcn/ui (new-york style) primitives
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **BREAKING**: Renderer is now React-only.
|
||||||
|
- Main process decomposed from 146KB `src/main.js` into feature-first modules under `src/main/`
|
||||||
|
- `src/index.html` reduced from 1667 to ~50 lines
|
||||||
|
- IPC contract: handlers throw, `safeCall` catches → `{ ok, error }`
|
||||||
|
- Settings store: `useSettingsStore` (zustand persist with zod)
|
||||||
|
- Modal state: `useAppStore.modal: ModalState` discriminated union
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `src/renderer.js` (legacy vanilla-JS renderer, 5319 lines) — and `src/index.html` (1667 lines of legacy markup, replaced by `src/renderer/index.html` which is already the live Vite template)
|
||||||
|
- 7 legacy stylesheets (styles.css, styles-modern.css, styles-concreteinfo.css, styles-sidebar.css, styles-zen.css, styles-welcome.css, fonts.css)
|
||||||
|
- 5 legacy scripts (command-palette.js, print-preview.js, welcome.js, zen-mode.js, wordTemplateExporter.js)
|
||||||
|
- 2 legacy HTMLs (ascii-generator.html, table-generator.html)
|
||||||
|
- 9 dead IPC channels (toggle-command-palette, open-*/show-* ascii/table, print-preview*)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Task Decomposition
|
||||||
|
|
||||||
|
**10 tasks** (each independently verifiable, one feature per commit, no bundling):
|
||||||
|
|
||||||
|
1. **Decompose main.js — `src/main/files/`** (file ops facade, search, git, binary)
|
||||||
|
2. **Decompose main.js — `src/main/menu/`** (buildMenu + items, with dead sends removed)
|
||||||
|
3. **Decompose main.js — `src/main/window/`** (createMainWindow ONLY — ascii/table windows removed)
|
||||||
|
4. **Decompose main.js — `src/main/word-template/`** (WordTemplateExporter split into parser/converter/apply)
|
||||||
|
5. **Decompose main.js — `src/main/utils/` + `store.js` + `ipc.js` + `index.js`** (glue + new entrypoint)
|
||||||
|
6. **Trim preload.js** — remove 9 dead IPC channels + 2 exposed API entries
|
||||||
|
7. **Delete legacy renderer files** (13 files: renderer.js, 7 styles, fonts.css, 4 scripts, 3 htmls)
|
||||||
|
8. **Verify src/renderer/index.html is the live template** (no action needed if it's already correct)
|
||||||
|
9. **Bump package.json version to 5.0.0 + write CHANGELOG.md**
|
||||||
|
10. **Final verification + tag v5.0.0** (build, tests, grep, electron smoke)
|
||||||
|
|
||||||
|
Tasks 1-5 are subagent-driven (decomposition is mechanical given a clear target structure). Tasks 6-9 are direct edits. Task 10 is a verification gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Verification Strategy
|
||||||
|
|
||||||
|
**Per task:**
|
||||||
|
- After each decomposition step: `npx vitest run` (305 still green)
|
||||||
|
- After main.js decomposition: `npx electron .` smoke — app starts, opens a file
|
||||||
|
- After deletions: `git grep -E "renderer\.js|command-palette|print-preview|welcome\.js|zen-mode|wordTemplate|ascii-generator|table-generator|styles\.css"` returns ZERO results
|
||||||
|
|
||||||
|
**Final (Task 10):**
|
||||||
|
- `npx vitest run` — 305 passing
|
||||||
|
- `npx vite build --config vite.renderer.config.ts` — succeeds
|
||||||
|
- `npx vite build --config vite.preload.config.ts` — succeeds
|
||||||
|
- `npx electron .` — app launches, main window renders
|
||||||
|
- Manual smoke: open a `.md` file, edit it, export to PDF/DOCX, use the command palette
|
||||||
|
- `git tag -a v5.0.0 -m "..."` and `git push origin v5.0.0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks & Open Questions
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- **Decomposition regressions.** Splitting a 146KB main.js into 7+ files has surface area for missed imports / circular deps. Mitigation: tests stay green; electron smoke after each step.
|
||||||
|
- **Hidden legacy references.** `git grep` won't catch references inside minified bundles or in node_modules. Mitigation: vite build will fail if there's a dangling import.
|
||||||
|
- **`src/main/index.js` entrypoint change.** Anything in the build pipeline (electron-builder, scripts) that hardcodes `src/main.js` needs to be updated. Mitigation: grep all of `package.json`, `scripts/`, `vite.*.config.ts` for `main.js`.
|
||||||
|
|
||||||
|
**Open questions (resolved during brainstorming):**
|
||||||
|
- ~~Main process scope~~ → Full rewrite
|
||||||
|
- ~~Folder shape~~ → Feature-first
|
||||||
|
- ~~IPC migration~~ → Delete dead channels
|
||||||
|
- ~~Version strategy~~ → v5.0.0 with Keep a Changelog format
|
||||||
|
- ~~Order of work~~ → Decompose first, delete last
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Out of Scope (deferred to Phase 11+)
|
||||||
|
|
||||||
|
- Main process test suite
|
||||||
|
- Renderer-side further refactor
|
||||||
|
- New features
|
||||||
|
- Migration to tauri (separate plan in `docs/plans/2026-03-15-react-tauri-pwa.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Success Criteria
|
||||||
|
|
||||||
|
Phase 10 is complete when:
|
||||||
|
- All 10 tasks are committed, one per commit, no bundling
|
||||||
|
- `src/main/index.js` is the new entrypoint
|
||||||
|
- All 12 legacy files are deleted
|
||||||
|
- All 9 dead IPC channels are removed
|
||||||
|
- `package.json#version` is `5.0.0`, `package.json#main` is `src/main/index.js`
|
||||||
|
- `CHANGELOG.md` exists with a complete v5.0.0 entry
|
||||||
|
- 305 tests still pass
|
||||||
|
- `npx vite build` succeeds for both renderer and preload configs
|
||||||
|
- `npx electron .` launches and the app works end-to-end
|
||||||
|
- `git grep` for legacy references returns zero results
|
||||||
|
- Tag `v5.0.0` is created and pushed to origin
|
||||||
|
- Phase 10 is the LAST phase of the React UI redesign
|
||||||
+1
-2
@@ -19,8 +19,7 @@ module.exports = {
|
|||||||
// Coverage configuration
|
// Coverage configuration
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.js',
|
'src/**/*.js',
|
||||||
'!src/main.js', // Main process needs electron-mock
|
'!src/main/**', // Main process needs electron-mock
|
||||||
'!src/renderer.js', // Large renderer file with duplicate declarations
|
|
||||||
'!src/preload.js', // Electron preload requires contextBridge
|
'!src/preload.js', // Electron preload requires contextBridge
|
||||||
'!**/node_modules/**'
|
'!**/node_modules/**'
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+431
-7
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.4.2",
|
"version": "5.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.4.2",
|
"version": "5.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -42,11 +42,12 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.7.1",
|
||||||
"docx4js": "^2.0.1",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"figlet": "^1.11.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"immer": "^11.1.8",
|
"immer": "^11.1.8",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/figlet": "^1.7.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"concurrently": "^10.0.3",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
@@ -102,7 +105,8 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8",
|
||||||
|
"wait-on": "^9.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -1900,6 +1904,60 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||||
@@ -5737,6 +5795,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/figlet": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/figlet/-/figlet-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-KwrT7p/8Eo3Op/HBSIwGXOsTZKYiM9NpWRBJ5sVjWP/SmlS+oxxRvJht/FNAtliJvja44N3ul1yATgohnVBV0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/fs-extra": {
|
"node_modules/@types/fs-extra": {
|
||||||
"version": "9.0.13",
|
"version": "9.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
|
||||||
@@ -6973,6 +7038,46 @@
|
|||||||
"postcss": "^8.1.0"
|
"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": {
|
"node_modules/babel-jest": {
|
||||||
"version": "30.3.0",
|
"version": "30.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
|
||||||
@@ -7880,6 +7985,185 @@
|
|||||||
"typedarray": "^0.0.6"
|
"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": {
|
"node_modules/conf": {
|
||||||
"version": "14.0.0",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz",
|
||||||
@@ -9161,9 +9445,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/docx": {
|
"node_modules/docx": {
|
||||||
"version": "9.6.1",
|
"version": "9.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||||
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==",
|
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
@@ -10241,6 +10525,30 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/figlet": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/figlet/-/figlet-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-EEx3OS/l2bFqcUNN2NM9FPJp8vAMrgbCxsbl2hbcJNNxOEwVe3mEzrhan7TbJQViZa8mMqhihlbCaqD+LyYKTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^14.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"figlet": "bin/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/figlet/node_modules/commander": {
|
||||||
|
"version": "14.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||||
|
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -10345,6 +10653,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/fontkit": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
@@ -10530,6 +10859,19 @@
|
|||||||
"node": "6.* || 8.* || >= 10.*"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -12663,6 +13005,25 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/jpeg-exif": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||||
@@ -15150,6 +15511,16 @@
|
|||||||
"signal-exit": "^3.0.2"
|
"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": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
@@ -15725,6 +16096,16 @@
|
|||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -15925,6 +16306,19 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -16845,6 +17239,16 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/truncate-utf8-bytes": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||||
@@ -17408,6 +17812,26 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/walker": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||||
|
|||||||
+40
-10
@@ -1,10 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.4.2",
|
"version": "5.0.1",
|
||||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
|
"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-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": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
@@ -27,7 +32,8 @@
|
|||||||
"dist": "electron-builder --publish=never",
|
"dist": "electron-builder --publish=never",
|
||||||
"dist:all": "electron-builder -mwl",
|
"dist:all": "electron-builder -mwl",
|
||||||
"download-tools": "node scripts/download-tools.js",
|
"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"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"markdown",
|
"markdown",
|
||||||
@@ -51,6 +57,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/figlet": "^1.7.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -59,6 +66,7 @@
|
|||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"concurrently": "^10.0.3",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
@@ -77,7 +85,8 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8",
|
||||||
|
"wait-on": "^9.0.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -113,11 +122,12 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.7.1",
|
||||||
"docx4js": "^2.0.1",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"figlet": "^1.11.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"immer": "^11.1.8",
|
"immer": "^11.1.8",
|
||||||
@@ -157,15 +167,20 @@
|
|||||||
},
|
},
|
||||||
"icon": "assets/icon",
|
"icon": "assets/icon",
|
||||||
"files": [
|
"files": [
|
||||||
"src/**/*",
|
"src/main/**/*",
|
||||||
"assets/**/*",
|
"src/preload.js",
|
||||||
"scripts/**/*",
|
"src/plugins/**/*",
|
||||||
"node_modules/**/*",
|
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/ffmpeg-static/**"
|
"node_modules/ffmpeg-static/**"
|
||||||
],
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "dist/renderer",
|
||||||
|
"to": "renderer"
|
||||||
|
}
|
||||||
|
],
|
||||||
"extraFiles": [],
|
"extraFiles": [],
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
{
|
{
|
||||||
@@ -192,7 +207,16 @@
|
|||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.productivity",
|
"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": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -265,6 +289,12 @@
|
|||||||
],
|
],
|
||||||
"description": "Professional Markdown editor and universal file converter",
|
"description": "Professional Markdown editor and universal file converter",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
"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';
|
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 = {
|
const PANDOC_CONFIG = {
|
||||||
linux: {
|
linux: {
|
||||||
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`,
|
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) {
|
extract(archivePath, destDir) {
|
||||||
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||||
fs.mkdirSync(tmpDir, { recursive: true });
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}" pandoc-${PANDOC_VERSION}/bin/pandoc`);
|
execSync(`tar -xzf "${archivePath}" -C "${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.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||||
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
@@ -39,7 +64,8 @@ const PANDOC_CONFIG = {
|
|||||||
execSync(
|
execSync(
|
||||||
`powershell -Command "Expand-Archive -Force '${archivePath}' '${tmpDir}'"`,
|
`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.copyFileSync(src, path.join(destDir, 'pandoc.exe'));
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
@@ -52,7 +78,8 @@ const PANDOC_CONFIG = {
|
|||||||
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||||
fs.mkdirSync(tmpDir, { recursive: true });
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`);
|
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.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||||
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -1,601 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>ASCII Art Generator - MarkdownConverter</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ci-dark-gray: #464646;
|
|
||||||
--ci-medium-gray: #9a9696;
|
|
||||||
--ci-accent: #e5461f;
|
|
||||||
--ci-light-gray: #e3e3e3;
|
|
||||||
--ci-black: #0d0b09;
|
|
||||||
--ci-white: #ffffff;
|
|
||||||
--ci-bg: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
background: var(--ci-bg);
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 3px solid var(--ci-accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
color: var(--ci-white);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-tab {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: 2px solid var(--ci-light-gray);
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-tab:hover {
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-tab.active {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input, .form-select, .form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 2px solid var(--ci-light-gray);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-textarea {
|
|
||||||
min-height: 80px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-container {
|
|
||||||
background: var(--ci-black);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
min-height: 200px;
|
|
||||||
max-height: 350px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: #00ff00;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-btn {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 2px solid var(--ci-light-gray);
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-btn:hover {
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
background: rgba(229, 70, 31, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-btn.active {
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
background: rgba(229, 70, 31, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-top: 1px solid var(--ci-light-gray);
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #c93a18;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #d0d0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-section {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-section.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-category {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-category-title {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--ci-medium-gray);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>ASCII Art Generator</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="section">
|
|
||||||
<div class="mode-tabs">
|
|
||||||
<button class="mode-tab active" data-mode="text">Text Banner</button>
|
|
||||||
<button class="mode-tab" data-mode="box">Box/Frame</button>
|
|
||||||
<button class="mode-tab" data-mode="templates">Templates</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Text Banner Mode -->
|
|
||||||
<div id="text-mode" class="mode-section active">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Text to Convert</label>
|
|
||||||
<input type="text" id="text-input" class="form-input" placeholder="Enter your text..." maxlength="30">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Style</label>
|
|
||||||
<select id="font-style" class="form-select">
|
|
||||||
<option value="standard">Standard</option>
|
|
||||||
<option value="banner">Banner</option>
|
|
||||||
<option value="block">Block</option>
|
|
||||||
<option value="bubble">Bubble</option>
|
|
||||||
<option value="digital">Digital</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Box/Frame Mode -->
|
|
||||||
<div id="box-mode" class="mode-section">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Text Content</label>
|
|
||||||
<textarea id="box-text" class="form-textarea" placeholder="Enter text for the box..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Box Style</label>
|
|
||||||
<select id="box-style" class="form-select">
|
|
||||||
<option value="single">Single Line</option>
|
|
||||||
<option value="double">Double Line</option>
|
|
||||||
<option value="rounded">Rounded</option>
|
|
||||||
<option value="bold">Bold</option>
|
|
||||||
<option value="ascii">ASCII (+|-)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Padding</label>
|
|
||||||
<input type="number" id="box-padding" class="form-input" min="0" max="10" value="2" style="width: 100px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Templates Mode -->
|
|
||||||
<div id="templates-mode" class="mode-section">
|
|
||||||
<div class="template-category">
|
|
||||||
<div class="template-category-title">Arrows & Flow</div>
|
|
||||||
<div class="template-grid">
|
|
||||||
<button class="template-btn" data-template="arrow-right">Arrow Right</button>
|
|
||||||
<button class="template-btn" data-template="arrow-down">Arrow Down</button>
|
|
||||||
<button class="template-btn" data-template="decision">Decision</button>
|
|
||||||
<button class="template-btn" data-template="process">Process Flow</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="template-category">
|
|
||||||
<div class="template-category-title">Diagrams</div>
|
|
||||||
<div class="template-grid">
|
|
||||||
<button class="template-btn" data-template="flowchart">Flowchart</button>
|
|
||||||
<button class="template-btn" data-template="sequence">Sequence</button>
|
|
||||||
<button class="template-btn" data-template="network">Network</button>
|
|
||||||
<button class="template-btn" data-template="hierarchy">Hierarchy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="template-category">
|
|
||||||
<div class="template-category-title">Boxes & Containers</div>
|
|
||||||
<div class="template-grid">
|
|
||||||
<button class="template-btn" data-template="header">Header</button>
|
|
||||||
<button class="template-btn" data-template="note">Note Box</button>
|
|
||||||
<button class="template-btn" data-template="warning">Warning</button>
|
|
||||||
<button class="template-btn" data-template="info">Info Box</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="template-category">
|
|
||||||
<div class="template-category-title">Decorative</div>
|
|
||||||
<div class="template-grid">
|
|
||||||
<button class="template-btn" data-template="divider">Divider</button>
|
|
||||||
<button class="template-btn" data-template="separator">Separator</button>
|
|
||||||
<button class="template-btn" data-template="banner">Banner</button>
|
|
||||||
<button class="template-btn" data-template="checklist">Checklist</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">Preview</div>
|
|
||||||
<div class="preview-container">
|
|
||||||
<div id="preview" class="preview-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<button class="btn btn-secondary" id="btn-generate">Generate Preview</button>
|
|
||||||
<button class="btn btn-primary" id="btn-insert">Insert to Editor</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ASCII Art Generator Logic
|
|
||||||
const FONTS = {
|
|
||||||
standard: {
|
|
||||||
height: 5,
|
|
||||||
chars: {
|
|
||||||
'A': [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
|
|
||||||
'B': ['|----\\', '| |', '|----/', '| \\', '|----/'],
|
|
||||||
'C': ['/----\\', '| ', '| ', '| ', '\\----/'],
|
|
||||||
'D': ['|----\\', '| |', '| |', '| |', '|----/'],
|
|
||||||
'E': ['|----', '| ', '|--- ', '| ', '|----'],
|
|
||||||
'F': ['|----', '| ', '|--- ', '| ', '| '],
|
|
||||||
'G': ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
|
|
||||||
'H': ['| |', '| |', '|----/', '| |', '| |'],
|
|
||||||
'I': ['|---|', ' | ', ' | ', ' | ', '|---|'],
|
|
||||||
'J': [' |', ' |', ' |', '| |', '\\---/'],
|
|
||||||
'K': ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
|
|
||||||
'L': ['| ', '| ', '| ', '| ', '|----'],
|
|
||||||
'M': ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
|
|
||||||
'N': ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
|
|
||||||
'O': ['/----\\', '| |', '| |', '| |', '\\----/'],
|
|
||||||
'P': ['|----\\', '| |', '|----/', '| ', '| '],
|
|
||||||
'Q': ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
|
|
||||||
'R': ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
|
|
||||||
'S': ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
|
|
||||||
'T': ['-----', ' | ', ' | ', ' | ', ' | '],
|
|
||||||
'U': ['| |', '| |', '| |', '| |', '\\----/'],
|
|
||||||
'V': ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
|
|
||||||
'W': ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
|
|
||||||
'X': ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
|
|
||||||
'Y': ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
|
|
||||||
'Z': ['-----', ' / ', ' / ', ' / ', '-----'],
|
|
||||||
' ': [' ', ' ', ' ', ' ', ' '],
|
|
||||||
'0': ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
|
|
||||||
'1': [' /| ', ' / | ', ' | ', ' | ', ' ----'],
|
|
||||||
'2': ['/---\\', ' |', ' ---/', '/ ', '-----'],
|
|
||||||
'3': ['----\\', ' |', ' ---/', ' |', '----/'],
|
|
||||||
'4': ['| |', '| |', '-----', ' |', ' |'],
|
|
||||||
'5': ['-----', '| ', '----\\', ' |', '----/'],
|
|
||||||
'6': ['/----', '| ', '|---\\', '| |', '\\---/'],
|
|
||||||
'7': ['-----', ' / ', ' / ', ' / ', '/ '],
|
|
||||||
'8': ['/---\\', '| |', ' --- ', '| |', '\\---/'],
|
|
||||||
'9': ['/---\\', '| |', '\\----', ' |', '----/']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
banner: {
|
|
||||||
height: 7,
|
|
||||||
chars: {
|
|
||||||
'A': [' ##### ', ' ## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
|
|
||||||
'B': ['######## ', '## ##', '## ##', '######## ', '## ##', '## ##', '######## '],
|
|
||||||
'C': [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
|
|
||||||
'D': ['######## ', '## ##', '## ##', '## ##', '## ##', '## ##', '######## '],
|
|
||||||
'E': ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
|
|
||||||
'F': ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
|
|
||||||
'G': [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
|
|
||||||
'H': ['## ##', '## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
|
|
||||||
'I': ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
|
|
||||||
'J': [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
|
|
||||||
'K': ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
|
|
||||||
'L': ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
|
|
||||||
'M': ['## ##', '### ###', '#### ####', '## ### ##', '## ##', '## ##', '## ##'],
|
|
||||||
'N': ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
|
|
||||||
'O': [' ####### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
|
|
||||||
'P': ['######## ', '## ##', '## ##', '######## ', '## ', '## ', '## '],
|
|
||||||
'Q': [' ####### ', '## ##', '## ##', '## ##', '## ## ##', '## ## ', ' ##### ##'],
|
|
||||||
'R': ['######## ', '## ##', '## ##', '######## ', '## ## ', '## ## ', '## ##'],
|
|
||||||
'S': [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
|
|
||||||
'T': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
|
||||||
'U': ['## ##', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
|
|
||||||
'V': ['## ##', '## ##', '## ##', '## ##', ' ## ## ', ' ## ## ', ' ### '],
|
|
||||||
'W': ['## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', ' ### ### '],
|
|
||||||
'X': ['## ##', ' ## ## ', ' ## ## ', ' ### ', ' ## ## ', ' ## ## ', '## ##'],
|
|
||||||
'Y': ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
|
|
||||||
'Z': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
|
||||||
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
|
||||||
'0': [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
|
|
||||||
'1': [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
|
|
||||||
'2': [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
|
||||||
'3': [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
|
|
||||||
'4': [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
|
|
||||||
'5': ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
|
|
||||||
'6': [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
|
|
||||||
'7': ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
|
||||||
'8': [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
|
|
||||||
'9': [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### ']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
block: {
|
|
||||||
height: 6,
|
|
||||||
chars: {
|
|
||||||
'A': ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
|
||||||
'B': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
|
||||||
'C': ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
|
|
||||||
'D': ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
|
|
||||||
'E': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
|
|
||||||
'F': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
|
|
||||||
'G': ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
|
|
||||||
'H': ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
|
||||||
'I': ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
|
|
||||||
'J': [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
|
|
||||||
'K': ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
|
|
||||||
'L': ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
|
|
||||||
'M': ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
|
|
||||||
'N': ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
|
|
||||||
'O': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
|
||||||
'P': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
|
|
||||||
'Q': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
|
|
||||||
'R': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
|
||||||
'S': ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
|
|
||||||
'T': ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
|
|
||||||
'U': ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
|
||||||
'V': ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
|
||||||
'W': ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
|
|
||||||
'X': ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
|
|
||||||
'Y': ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
|
|
||||||
'Z': ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
|
|
||||||
' ': [' ', ' ', ' ', ' ', ' ', ' ']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEMPLATES = {
|
|
||||||
'arrow-right': ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
|
|
||||||
'arrow-down': ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
|
|
||||||
'decision': ' ╱╲\n ╱ ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
|
|
||||||
'process': '┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘',
|
|
||||||
'flowchart': '┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘',
|
|
||||||
'sequence': ' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query │\n │ ├──────────►│\n │ │ │\n │ │ Result │\n │ │◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
|
|
||||||
'network': ' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
|
|
||||||
'hierarchy': ' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
|
|
||||||
'header': '╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
|
|
||||||
'note': '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
|
|
||||||
'warning': '╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
|
|
||||||
'info': '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
|
|
||||||
'divider': '════════════════════════════════════════',
|
|
||||||
'separator': '╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
|
|
||||||
'banner': '★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
|
|
||||||
'checklist': '☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed'
|
|
||||||
};
|
|
||||||
|
|
||||||
const BOX_STYLES = {
|
|
||||||
single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
|
|
||||||
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
|
||||||
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
|
||||||
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
|
|
||||||
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentMode = 'text';
|
|
||||||
let currentTemplate = null;
|
|
||||||
|
|
||||||
// Mode switching
|
|
||||||
document.querySelectorAll('.mode-tab').forEach(tab => {
|
|
||||||
tab.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
|
|
||||||
tab.classList.add('active');
|
|
||||||
currentMode = tab.dataset.mode;
|
|
||||||
|
|
||||||
document.querySelectorAll('.mode-section').forEach(s => s.classList.remove('active'));
|
|
||||||
document.getElementById(currentMode + '-mode').classList.add('active');
|
|
||||||
|
|
||||||
generatePreview();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Template selection
|
|
||||||
document.querySelectorAll('.template-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
currentTemplate = btn.dataset.template;
|
|
||||||
generatePreview();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate text banner
|
|
||||||
function generateTextBanner(text, style) {
|
|
||||||
const font = FONTS[style] || FONTS.standard;
|
|
||||||
const lines = Array(font.height).fill('');
|
|
||||||
|
|
||||||
for (const char of text.toUpperCase()) {
|
|
||||||
const charArt = font.chars[char] || font.chars[' '];
|
|
||||||
if (charArt) {
|
|
||||||
for (let i = 0; i < font.height; i++) {
|
|
||||||
lines[i] += charArt[i] + ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate box
|
|
||||||
function generateBox(text, style, padding) {
|
|
||||||
const box = BOX_STYLES[style] || BOX_STYLES.single;
|
|
||||||
const lines = text.split('\n');
|
|
||||||
const maxLen = Math.max(...lines.map(l => l.length)) + padding * 2;
|
|
||||||
|
|
||||||
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
|
|
||||||
|
|
||||||
// Add padding lines at top
|
|
||||||
for (let i = 0; i < Math.floor(padding / 2); i++) {
|
|
||||||
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add text lines
|
|
||||||
for (const line of lines) {
|
|
||||||
const paddedLine = ' '.repeat(padding) + line.padEnd(maxLen - padding) + ' ';
|
|
||||||
result += box.v + ' ' + paddedLine + box.v + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add padding lines at bottom
|
|
||||||
for (let i = 0; i < Math.floor(padding / 2); i++) {
|
|
||||||
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
result += box.bl + box.h.repeat(maxLen + 2) + box.br;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate preview
|
|
||||||
function generatePreview() {
|
|
||||||
let result = '';
|
|
||||||
|
|
||||||
if (currentMode === 'text') {
|
|
||||||
const text = document.getElementById('text-input').value || 'HELLO';
|
|
||||||
const style = document.getElementById('font-style').value;
|
|
||||||
result = generateTextBanner(text, style);
|
|
||||||
} else if (currentMode === 'box') {
|
|
||||||
const text = document.getElementById('box-text').value || 'Your text here';
|
|
||||||
const style = document.getElementById('box-style').value;
|
|
||||||
const padding = parseInt(document.getElementById('box-padding').value) || 2;
|
|
||||||
result = generateBox(text, style, padding);
|
|
||||||
} else if (currentMode === 'templates') {
|
|
||||||
result = currentTemplate ? TEMPLATES[currentTemplate] : 'Select a template...';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('preview').textContent = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners for live preview
|
|
||||||
document.getElementById('text-input').addEventListener('input', generatePreview);
|
|
||||||
document.getElementById('font-style').addEventListener('change', generatePreview);
|
|
||||||
document.getElementById('box-text').addEventListener('input', generatePreview);
|
|
||||||
document.getElementById('box-style').addEventListener('change', generatePreview);
|
|
||||||
document.getElementById('box-padding').addEventListener('input', generatePreview);
|
|
||||||
|
|
||||||
document.getElementById('btn-generate').addEventListener('click', generatePreview);
|
|
||||||
|
|
||||||
document.getElementById('btn-insert').addEventListener('click', () => {
|
|
||||||
const content = document.getElementById('preview').textContent;
|
|
||||||
if (content && window.electronAPI) {
|
|
||||||
// Wrap in code block for markdown
|
|
||||||
const wrapped = '```\n' + content + '\n```';
|
|
||||||
window.electronAPI.send('insert-generated-content', wrapped);
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial preview
|
|
||||||
generatePreview();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
class CommandPalette {
|
|
||||||
constructor() {
|
|
||||||
this.overlay = document.getElementById('command-palette-overlay');
|
|
||||||
this.input = document.getElementById('command-palette-input');
|
|
||||||
this.results = document.getElementById('command-palette-results');
|
|
||||||
this.commands = [];
|
|
||||||
this.selectedIndex = 0;
|
|
||||||
this.filteredCommands = [];
|
|
||||||
this.setupEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
register(label, shortcut, action) {
|
|
||||||
this.commands.push({ label, shortcut, action });
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
this.overlay.classList.remove('hidden');
|
|
||||||
this.input.value = '';
|
|
||||||
this.input.focus();
|
|
||||||
this.selectedIndex = 0;
|
|
||||||
this.renderResults('');
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.overlay.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
isOpen() {
|
|
||||||
return !this.overlay.classList.contains('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
|
||||||
this.input.addEventListener('input', () => {
|
|
||||||
this.selectedIndex = 0;
|
|
||||||
this.renderResults(this.input.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === this.overlay) this.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.input.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.close();
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.executeSelected();
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
|
|
||||||
this.updateSelection();
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
|
||||||
this.updateSelection();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderResults(query) {
|
|
||||||
this.filteredCommands = query
|
|
||||||
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
|
|
||||||
: [...this.commands];
|
|
||||||
|
|
||||||
this.results.innerHTML = this.filteredCommands.map((cmd, i) => `
|
|
||||||
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
|
||||||
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
|
|
||||||
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
this.results.querySelectorAll('.command-item').forEach((el) => {
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
const idx = parseInt(el.dataset.index);
|
|
||||||
this.filteredCommands[idx].action();
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
this.selectedIndex = parseInt(el.dataset.index);
|
|
||||||
this.updateSelection();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
highlightMatch(text, query) {
|
|
||||||
if (!query) return text;
|
|
||||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
|
||||||
return text.replace(regex, '<strong>$1</strong>');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSelection() {
|
|
||||||
this.results.querySelectorAll('.command-item').forEach((el, i) => {
|
|
||||||
el.classList.toggle('selected', i === this.selectedIndex);
|
|
||||||
});
|
|
||||||
// Scroll selected into view
|
|
||||||
const selected = this.results.querySelector('.command-item.selected');
|
|
||||||
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
|
|
||||||
executeSelected() {
|
|
||||||
if (this.filteredCommands[this.selectedIndex]) {
|
|
||||||
this.filteredCommands[this.selectedIndex].action();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { CommandPalette };
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/* Local Font Definitions for MarkdownConverter */
|
|
||||||
|
|
||||||
/* Inter Font Family */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JetBrains Mono Font Family - For code, markdown editor, and ASCII art */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'JetBrains Mono';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
-1667
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
// src/main/files/binary.js
|
||||||
|
// Binary file write handler (used by Word .docx export)
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
ipcMain.handle('write-buffer', async (_event, { path: filePath, buffer }) => {
|
||||||
|
await fs.writeFile(filePath, Buffer.from(buffer));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// src/main/files/git.js
|
||||||
|
// Git IPC handlers — thin wrapper over GitOperations
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
const GitOperations = require('../GitOperations');
|
||||||
|
|
||||||
|
function register(currentFileRef) {
|
||||||
|
ipcMain.handle('git-status', async () => {
|
||||||
|
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||||
|
return GitOperations.getStatus(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('git-stage', async (_event, { files }) => {
|
||||||
|
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||||
|
return GitOperations.stage(dir, files);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('git-commit', async (_event, { message }) => {
|
||||||
|
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||||
|
return GitOperations.commit(dir, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('git-log', async () => {
|
||||||
|
const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd();
|
||||||
|
return GitOperations.log(dir);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register };
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// src/main/files/index.js
|
||||||
|
// File ops facade — registers all file-related IPC handlers
|
||||||
|
const { ipcMain, dialog, shell } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { register: registerGit } = require('./git');
|
||||||
|
const { register: registerBinary } = require('./binary');
|
||||||
|
|
||||||
|
function register({ validatePath, resolveWritablePath, isPathAccessible, currentFileRef, mainWindow }) {
|
||||||
|
// pick-folder
|
||||||
|
ipcMain.handle('pick-folder', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory'],
|
||||||
|
});
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
|
return result.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// pick-file
|
||||||
|
ipcMain.handle('pick-file', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }],
|
||||||
|
});
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
|
return result.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// read-file
|
||||||
|
ipcMain.handle('read-file', async (event, filePath) => {
|
||||||
|
const validation = validatePath(filePath);
|
||||||
|
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||||
|
throw new Error(validation.error || 'Invalid file path');
|
||||||
|
}
|
||||||
|
return fs.readFileSync(validation.resolved, 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
// write-file
|
||||||
|
ipcMain.handle('write-file', async (event, payload) => {
|
||||||
|
const validation = resolveWritablePath(payload?.path);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(validation.error || 'Invalid file path');
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(validation.resolved), { recursive: true });
|
||||||
|
fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8');
|
||||||
|
return { path: validation.resolved };
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete-file
|
||||||
|
ipcMain.handle('delete-file', async (event, filePath) => {
|
||||||
|
const validation = validatePath(filePath);
|
||||||
|
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||||
|
throw new Error(validation.error || 'Invalid file path');
|
||||||
|
}
|
||||||
|
fs.rmSync(validation.resolved, { recursive: true, force: false });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ensure-directory
|
||||||
|
ipcMain.handle('ensure-directory', async (event, dirPath) => {
|
||||||
|
const validation = resolveWritablePath(dirPath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(validation.error || 'Invalid directory path');
|
||||||
|
}
|
||||||
|
fs.mkdirSync(validation.resolved, { recursive: true });
|
||||||
|
return validation.resolved;
|
||||||
|
});
|
||||||
|
|
||||||
|
// path-exists
|
||||||
|
ipcMain.handle('path-exists', async (event, filePath) => {
|
||||||
|
const validation = resolveWritablePath(filePath);
|
||||||
|
return validation.valid ? fs.existsSync(validation.resolved) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// is-directory
|
||||||
|
ipcMain.handle('is-directory', async (event, filePath) => {
|
||||||
|
const validation = validatePath(filePath);
|
||||||
|
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fs.statSync(validation.resolved).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
// copy-path
|
||||||
|
ipcMain.handle('copy-path', async (event, payload) => {
|
||||||
|
const sourceValidation = validatePath(payload?.source);
|
||||||
|
const destinationValidation = resolveWritablePath(payload?.destination);
|
||||||
|
|
||||||
|
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
|
||||||
|
throw new Error(sourceValidation.error || 'Invalid source path');
|
||||||
|
}
|
||||||
|
if (!destinationValidation.valid) {
|
||||||
|
throw new Error(destinationValidation.error || 'Invalid destination path');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
|
||||||
|
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
|
||||||
|
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
|
||||||
|
});
|
||||||
|
|
||||||
|
// move-path
|
||||||
|
ipcMain.handle('move-path', async (event, payload) => {
|
||||||
|
const sourceValidation = validatePath(payload?.source);
|
||||||
|
const destinationValidation = resolveWritablePath(payload?.destination);
|
||||||
|
|
||||||
|
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
|
||||||
|
throw new Error(sourceValidation.error || 'Invalid source path');
|
||||||
|
}
|
||||||
|
if (!destinationValidation.valid) {
|
||||||
|
throw new Error(destinationValidation.error || 'Invalid destination path');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(sourceValidation.resolved, destinationValidation.resolved);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'EXDEV') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
|
||||||
|
fs.rmSync(sourceValidation.resolved, { recursive: true, force: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
|
||||||
|
});
|
||||||
|
|
||||||
|
// open-file-path
|
||||||
|
ipcMain.on('open-file-path', (event, filePath) => {
|
||||||
|
try {
|
||||||
|
const validation = validatePath(filePath);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.error('[SECURITY] Invalid file path:', validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPathAccessible(validation.resolved)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(validation.resolved);
|
||||||
|
if (stat.size > 50 * 1024 * 1024) return;
|
||||||
|
const content = fs.readFileSync(validation.resolved, 'utf-8');
|
||||||
|
// Emit set-current-file back to main process so it updates its currentFile variable
|
||||||
|
mainWindow.webContents.send('set-current-file', validation.resolved);
|
||||||
|
mainWindow.webContents.send('file-opened', { path: validation.resolved, content });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('open-file-path error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register sub-modules
|
||||||
|
registerGit(currentFileRef);
|
||||||
|
registerBinary();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register };
|
||||||
+45
-955
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
// src/main/menu/index.js
|
||||||
|
// buildMenu() composes the app menu from items; register() sets it on the app
|
||||||
|
|
||||||
|
const { Menu } = require('electron');
|
||||||
|
const {
|
||||||
|
fileItems,
|
||||||
|
editItems,
|
||||||
|
viewItems,
|
||||||
|
batchItems,
|
||||||
|
convertItems,
|
||||||
|
pdfEditorItems,
|
||||||
|
toolsItems,
|
||||||
|
helpItems
|
||||||
|
} = require('./items');
|
||||||
|
|
||||||
|
function buildMenu(mainWindow) {
|
||||||
|
const template = [
|
||||||
|
{ label: '&File', submenu: fileItems(mainWindow) },
|
||||||
|
{ label: '&Edit', submenu: editItems(mainWindow) },
|
||||||
|
{ label: '&View', submenu: viewItems(mainWindow) },
|
||||||
|
{ label: '&Batch', submenu: batchItems(mainWindow) },
|
||||||
|
{ label: '&Convert', submenu: convertItems(mainWindow) },
|
||||||
|
{ label: 'PDF Editor', submenu: pdfEditorItems(mainWindow) },
|
||||||
|
{ label: '&Tools', submenu: toolsItems(mainWindow) },
|
||||||
|
{ label: '&Help', submenu: helpItems(mainWindow) }
|
||||||
|
];
|
||||||
|
return Menu.buildFromTemplate(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
function register(mainWindow) {
|
||||||
|
Menu.setApplicationMenu(buildMenu(mainWindow));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register, buildMenu };
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
// src/main/menu/items.js
|
||||||
|
// Individual menu items — pure functions that take (mainWindow) and return menu item arrays
|
||||||
|
|
||||||
|
const { app, dialog, shell } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Helper: build recent files submenu
|
||||||
|
function buildRecentFilesMenu(mainWindow) {
|
||||||
|
try {
|
||||||
|
const recentFilesPath = path.join(app.getPath('userData'), 'recent-files.json');
|
||||||
|
if (!fs.existsSync(recentFilesPath)) return [{ label: 'No Recent Files', enabled: false }];
|
||||||
|
const recentFiles = JSON.parse(fs.readFileSync(recentFilesPath, 'utf-8'));
|
||||||
|
const existing = recentFiles.filter(file => fs.existsSync(file));
|
||||||
|
if (existing.length === 0) return [{ label: 'No Recent Files', enabled: false }];
|
||||||
|
const items = existing.map(file => ({
|
||||||
|
label: path.basename(file),
|
||||||
|
click: () => {
|
||||||
|
const { openFileFromPath } = require('../index');
|
||||||
|
openFileFromPath(file);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
items.push(
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Clear Recent Files',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('clear-recent-files');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
} catch (e) {
|
||||||
|
return [{ label: 'No Recent Files', enabled: false }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileItems(mainWindow) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'New',
|
||||||
|
accelerator: 'CmdOrCtrl+N',
|
||||||
|
click: () => mainWindow.webContents.send('file-new')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open',
|
||||||
|
accelerator: 'CmdOrCtrl+O',
|
||||||
|
click: () => {
|
||||||
|
const { openFile } = require('../index');
|
||||||
|
openFile();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open PDF',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+O',
|
||||||
|
click: () => {
|
||||||
|
const { openPdfFile } = require('../index');
|
||||||
|
openPdfFile();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
accelerator: 'CmdOrCtrl+S',
|
||||||
|
click: () => mainWindow.webContents.send('file-save')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Save As',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+S',
|
||||||
|
click: () => {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'New from Template',
|
||||||
|
submenu: [
|
||||||
|
{ label: 'Blog Post', click: () => mainWindow.webContents.send('load-template-menu', 'blog-post.md') },
|
||||||
|
{ label: 'Meeting Notes', click: () => mainWindow.webContents.send('load-template-menu', 'meeting-notes.md') },
|
||||||
|
{ label: 'Technical Spec', click: () => mainWindow.webContents.send('load-template-menu', 'technical-spec.md') },
|
||||||
|
{ label: 'Changelog', click: () => mainWindow.webContents.send('load-template-menu', 'changelog.md') },
|
||||||
|
{ label: 'README', click: () => mainWindow.webContents.send('load-template-menu', 'readme.md') },
|
||||||
|
{ label: 'Project Plan', click: () => mainWindow.webContents.send('load-template-menu', 'project-plan.md') },
|
||||||
|
{ label: 'API Documentation', click: () => mainWindow.webContents.send('load-template-menu', 'api-docs.md') },
|
||||||
|
{ label: 'Tutorial', click: () => mainWindow.webContents.send('load-template-menu', 'tutorial.md') },
|
||||||
|
{ label: 'Release Notes', click: () => mainWindow.webContents.send('load-template-menu', 'release-notes.md') },
|
||||||
|
{ label: 'Comparison', click: () => mainWindow.webContents.send('load-template-menu', 'comparison.md') }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Import Document...',
|
||||||
|
accelerator: 'CmdOrCtrl+I',
|
||||||
|
click: () => {
|
||||||
|
const { importDocument } = require('../index');
|
||||||
|
importDocument();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Export',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'HTML', click: () => {
|
||||||
|
const { exportFile } = require('../index');
|
||||||
|
exportFile('html');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'PDF', click: () => {
|
||||||
|
const { exportFile } = require('../index');
|
||||||
|
exportFile('pdf');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'PDF (Enhanced)', click: () => {
|
||||||
|
const { exportPDFViaWordTemplate } = require('../index');
|
||||||
|
exportPDFViaWordTemplate();
|
||||||
|
}, accelerator: 'Ctrl+Shift+P'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DOCX', click: () => {
|
||||||
|
const { exportFile } = require('../index');
|
||||||
|
exportFile('docx');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DOCX (Enhanced)', click: () => {
|
||||||
|
const { exportWordWithTemplate } = require('../index');
|
||||||
|
exportWordWithTemplate();
|
||||||
|
}, 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'); } },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ 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('../index'); exportSpreadsheet('csv'); } },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ 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('../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('../index');
|
||||||
|
selectWordTemplate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Template Settings...',
|
||||||
|
click: () => {
|
||||||
|
const { showTemplateSettings } = require('../index');
|
||||||
|
showTemplateSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Header & Footer Settings...',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('open-header-footer-dialog');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
|
||||||
|
click: () => app.quit()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function editItems(mainWindow) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Undo',
|
||||||
|
accelerator: 'CmdOrCtrl+Z',
|
||||||
|
click: () => mainWindow.webContents.send('undo')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Redo',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+Z',
|
||||||
|
click: () => mainWindow.webContents.send('redo')
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||||||
|
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
||||||
|
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
|
||||||
|
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Find & Replace',
|
||||||
|
accelerator: 'CmdOrCtrl+F',
|
||||||
|
click: () => mainWindow.webContents.send('toggle-find')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewItems(mainWindow) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Toggle Preview',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+V',
|
||||||
|
click: () => mainWindow.webContents.send('toggle-preview')
|
||||||
|
},
|
||||||
|
// NOTE: Command Palette removed — handled by useCommandStore
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Sidebar',
|
||||||
|
submenu: [
|
||||||
|
{ label: 'File Explorer', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') },
|
||||||
|
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') },
|
||||||
|
{ label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') },
|
||||||
|
{ label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bottom Panel (REPL)',
|
||||||
|
click: () => mainWindow.webContents.send('toggle-bottom-panel')
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Theme',
|
||||||
|
submenu: [
|
||||||
|
{ label: 'Atom One Light (Default)', click: () => { const { setTheme } = require('../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('../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' },
|
||||||
|
{
|
||||||
|
label: 'Font Size',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Increase Font Size',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+Plus',
|
||||||
|
click: () => mainWindow.webContents.send('adjust-font-size', 'increase')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Decrease Font Size',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+-',
|
||||||
|
click: () => mainWindow.webContents.send('adjust-font-size', 'decrease')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reset Font Size',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+0',
|
||||||
|
click: () => mainWindow.webContents.send('adjust-font-size', 'reset')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Spell Check',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: true,
|
||||||
|
click: (menuItem) => {
|
||||||
|
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Custom Preview CSS',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Load Custom Preview CSS...',
|
||||||
|
click: () => mainWindow.webContents.send('load-custom-css')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clear Custom Preview CSS',
|
||||||
|
click: () => mainWindow.webContents.send('clear-custom-css')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
||||||
|
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
|
||||||
|
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
|
||||||
|
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchItems(mainWindow) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Convert Markdown Folder...',
|
||||||
|
click: () => {
|
||||||
|
const { showBatchConversionDialog } = require('../index');
|
||||||
|
showBatchConversionDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Batch Image Conversion...',
|
||||||
|
click: () => mainWindow.webContents.send('show-batch-converter', 'image')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batch Audio Conversion...',
|
||||||
|
click: () => mainWindow.webContents.send('show-batch-converter', 'audio')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batch Video Conversion...',
|
||||||
|
click: () => mainWindow.webContents.send('show-batch-converter', 'video')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batch PDF Conversion...',
|
||||||
|
click: () => mainWindow.webContents.send('show-batch-converter', 'pdf')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertItems(mainWindow) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Universal File Converter...',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+C',
|
||||||
|
click: () => {
|
||||||
|
const { showUniversalConverterDialog } = require('../index');
|
||||||
|
showUniversalConverterDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfEditorItems(mainWindow) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Open PDF File...',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+O',
|
||||||
|
click: () => {
|
||||||
|
const { openPdfFile } = require('../index');
|
||||||
|
openPdfFile();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Merge PDFs...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('merge');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Split PDF...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('split');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Compress PDF...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('compress');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Rotate Pages...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('rotate');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete Pages...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('delete');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reorder Pages...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('reorder');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Add Watermark...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('watermark');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Security',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Add Password Protection...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('encrypt');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Remove Password...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('decrypt');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Set Permissions...',
|
||||||
|
click: () => {
|
||||||
|
const { showPDFEditorDialog } = require('../index');
|
||||||
|
showPDFEditorDialog('permissions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'About PDF Editor',
|
||||||
|
click: () => {
|
||||||
|
const { showAboutDialog } = require('../index');
|
||||||
|
showAboutDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolsItems(mainWindow) {
|
||||||
|
// NOTE: Table Generator and ASCII Art Generator removed — handled by React dialogs
|
||||||
|
return [
|
||||||
|
// Removed: Table Generator (Cmd+Ctrl+Shift+T) — now React <TableGeneratorDialog>
|
||||||
|
// Removed: ASCII Art Generator (Cmd+Ctrl+Shift+A) — now React <AsciiGeneratorDialog>
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Document Compare',
|
||||||
|
click: () => mainWindow.webContents.send('show-document-compare')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function helpItems(mainWindow) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'About MarkdownConverter',
|
||||||
|
click: () => {
|
||||||
|
const { showAboutDialog } = require('../index');
|
||||||
|
showAboutDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Dependencies & Requirements',
|
||||||
|
click: () => {
|
||||||
|
const { showDependenciesDialog } = require('../index');
|
||||||
|
showDependenciesDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Documentation',
|
||||||
|
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Report Issue',
|
||||||
|
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Check for Updates',
|
||||||
|
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fileItems,
|
||||||
|
editItems,
|
||||||
|
viewItems,
|
||||||
|
batchItems,
|
||||||
|
convertItems,
|
||||||
|
pdfEditorItems,
|
||||||
|
toolsItems,
|
||||||
|
helpItems
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// src/main/store.js
|
||||||
|
// Simple JSON-file preferences store (replaces electron-store)
|
||||||
|
|
||||||
|
const { app } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
get(key, defaultValue) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(settingsPath, 'utf-8');
|
||||||
|
const settings = JSON.parse(data);
|
||||||
|
return settings[key] !== undefined ? settings[key] : defaultValue;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
let settings = {};
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(settingsPath, 'utf-8');
|
||||||
|
settings = JSON.parse(data);
|
||||||
|
} catch {}
|
||||||
|
settings[key] = value;
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = store;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// src/main/utils/paths.js
|
||||||
|
// Path helpers — extracted from src/main.js lines 109-207
|
||||||
|
const { app } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
function getAllowedDirectories() {
|
||||||
|
const dirs = [
|
||||||
|
app.getPath('documents'),
|
||||||
|
app.getPath('desktop'),
|
||||||
|
app.getPath('downloads'),
|
||||||
|
app.getPath('home'),
|
||||||
|
process.cwd() // Current working directory
|
||||||
|
].filter(Boolean); // Remove any undefined paths
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a file path is safe and doesn't attempt path traversal
|
||||||
|
* @param {string} filePath - The path to validate
|
||||||
|
* @returns {{ valid: boolean, resolved: string, error?: string }}
|
||||||
|
*/
|
||||||
|
function validatePath(filePath) {
|
||||||
|
if (!filePath || typeof filePath !== 'string') {
|
||||||
|
return { valid: false, resolved: '', error: 'Invalid path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve to absolute path (handles .., ., symlinks)
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = path.resolve(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize path separators
|
||||||
|
resolved = path.normalize(resolved);
|
||||||
|
|
||||||
|
// Check for null bytes (path injection)
|
||||||
|
if (resolved.includes('\0')) {
|
||||||
|
return { valid: false, resolved: '', error: 'Null byte in path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path exists
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
return { valid: false, resolved, error: 'Path does not exist' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a path for operations where the target may not exist yet.
|
||||||
|
* Validates string shape and blocks obviously sensitive locations.
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {{ valid: boolean, resolved: string, error?: string }}
|
||||||
|
*/
|
||||||
|
function resolveWritablePath(filePath) {
|
||||||
|
if (!filePath || typeof filePath !== 'string') {
|
||||||
|
return { valid: false, resolved: '', error: 'Invalid path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = path.normalize(path.resolve(filePath));
|
||||||
|
} catch (err) {
|
||||||
|
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.includes('\0')) {
|
||||||
|
return { valid: false, resolved: '', error: 'Null byte in path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPathAccessible(resolved)) {
|
||||||
|
return { valid: false, resolved, error: 'Path is not accessible' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a resolved path is within allowed directories
|
||||||
|
* For an editor app, we allow access to all user-accessible paths
|
||||||
|
* but log any suspicious access attempts
|
||||||
|
* @param {string} resolvedPath - The resolved absolute path
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isPathAccessible(resolvedPath) {
|
||||||
|
// Block access to sensitive system directories
|
||||||
|
const blockedPaths = [
|
||||||
|
'/etc/passwd', '/etc/shadow', '/root',
|
||||||
|
'C:\\Windows\\System32', 'C:\\Windows\\System',
|
||||||
|
'/System', '/private/etc'
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizedPath = resolvedPath.toLowerCase();
|
||||||
|
for (const blocked of blockedPaths) {
|
||||||
|
if (normalizedPath.startsWith(blocked.toLowerCase())) {
|
||||||
|
console.warn('[SECURITY] Blocked access to sensitive path:', resolvedPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAllowedDirectories, validatePath, resolveWritablePath, isPathAccessible };
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// src/main/window/index.js
|
||||||
|
// Main window creation
|
||||||
|
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const state = require('./state');
|
||||||
|
const menu = require('../menu');
|
||||||
|
|
||||||
|
function createMainWindow() {
|
||||||
|
const bounds = state.load();
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
show: true,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true,
|
||||||
|
contextIsolation: false,
|
||||||
|
spellcheck: true
|
||||||
|
},
|
||||||
|
icon: path.join(__dirname, '../../../assets/icon.png')
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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', () => {
|
||||||
|
win.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.register(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
|
||||||
|
win.webContents.on('context-menu', (event, params) => {
|
||||||
|
const { Menu, MenuItem } = require('electron');
|
||||||
|
const ctxMenu = new Menu();
|
||||||
|
|
||||||
|
// Add spell check suggestions
|
||||||
|
if (params.misspelledWord) {
|
||||||
|
for (const suggestion of params.dictionarySuggestions) {
|
||||||
|
ctxMenu.append(new MenuItem({
|
||||||
|
label: suggestion,
|
||||||
|
click: () => win.webContents.replaceMisspelling(suggestion)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (params.dictionarySuggestions.length > 0) {
|
||||||
|
ctxMenu.append(new MenuItem({ type: 'separator' }));
|
||||||
|
}
|
||||||
|
ctxMenu.append(new MenuItem({
|
||||||
|
label: 'Add to Dictionary',
|
||||||
|
click: () => win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||||
|
}));
|
||||||
|
ctxMenu.append(new MenuItem({ type: 'separator' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard context menu items
|
||||||
|
ctxMenu.append(new MenuItem({ role: 'cut' }));
|
||||||
|
ctxMenu.append(new MenuItem({ role: 'copy' }));
|
||||||
|
ctxMenu.append(new MenuItem({ role: 'paste' }));
|
||||||
|
ctxMenu.append(new MenuItem({ role: 'selectAll' }));
|
||||||
|
|
||||||
|
ctxMenu.popup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the page to fully load before sending file data
|
||||||
|
win.webContents.on('did-finish-load', () => {
|
||||||
|
console.log('Window finished loading');
|
||||||
|
// Don't open file here - wait for renderer-ready signal
|
||||||
|
// The renderer will send renderer-ready when TabManager is initialized
|
||||||
|
});
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createMainWindow };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// src/main/window/state.js
|
||||||
|
// Window state persistence (size, position)
|
||||||
|
|
||||||
|
const { app } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const stateFile = path.join(app.getPath('userData'), 'window-state.json');
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return { width: 1200, height: 800 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(win) {
|
||||||
|
const bounds = win.getBounds();
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify(bounds));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { load, save };
|
||||||
@@ -85,12 +85,6 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'get-pdf-page-count',
|
'get-pdf-page-count',
|
||||||
'select-pdf-folder',
|
'select-pdf-folder',
|
||||||
|
|
||||||
// ASCII generator (separate window)
|
|
||||||
'open-ascii-generator',
|
|
||||||
|
|
||||||
// Table generator (separate window)
|
|
||||||
'open-table-generator',
|
|
||||||
|
|
||||||
// Insert generated content
|
// Insert generated content
|
||||||
'insert-generated-content',
|
'insert-generated-content',
|
||||||
|
|
||||||
@@ -169,15 +163,10 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
// Font
|
// Font
|
||||||
'adjust-font-size',
|
'adjust-font-size',
|
||||||
|
|
||||||
// Print
|
|
||||||
'print-preview',
|
|
||||||
'print-preview-styled',
|
|
||||||
|
|
||||||
// Export dialogs
|
// Export dialogs
|
||||||
'show-export-dialog',
|
'show-export-dialog',
|
||||||
'show-batch-dialog',
|
'show-batch-dialog',
|
||||||
'show-universal-converter-dialog',
|
'show-universal-converter-dialog',
|
||||||
'show-table-generator',
|
|
||||||
'show-pdf-editor-dialog',
|
'show-pdf-editor-dialog',
|
||||||
|
|
||||||
// Converter dialogs
|
// Converter dialogs
|
||||||
@@ -213,12 +202,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'pdf-operation-complete',
|
'pdf-operation-complete',
|
||||||
'pdf-operation-error',
|
'pdf-operation-error',
|
||||||
|
|
||||||
// ASCII Art Generator
|
|
||||||
'show-ascii-generator-window',
|
|
||||||
'show-ascii-generator',
|
|
||||||
|
|
||||||
// Table Generator
|
// Table Generator
|
||||||
'show-table-generator-window',
|
|
||||||
|
|
||||||
// Header/Footer dialog
|
// Header/Footer dialog
|
||||||
'open-header-footer-dialog',
|
'open-header-footer-dialog',
|
||||||
@@ -235,7 +219,6 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
|
|
||||||
// v4 menu-triggered events
|
// v4 menu-triggered events
|
||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-command-palette',
|
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel',
|
'toggle-bottom-panel',
|
||||||
|
|
||||||
@@ -440,12 +423,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
toGif: (data) => ipcRenderer.send('video-gif', data)
|
toGif: (data) => ipcRenderer.send('video-gif', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Generator Windows
|
|
||||||
generators: {
|
|
||||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
|
||||||
openTable: () => ipcRenderer.send('open-table-generator')
|
|
||||||
},
|
|
||||||
|
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version')
|
getAppVersion: () => ipcRenderer.invoke('get-app-version')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
class PrintPreview {
|
|
||||||
constructor() {
|
|
||||||
this.overlay = document.getElementById('print-preview-overlay');
|
|
||||||
this.modal = window.modals?.printPreviewModal;
|
|
||||||
this._lastContent = '';
|
|
||||||
this.setupEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
open(htmlContent) {
|
|
||||||
this._lastContent = htmlContent;
|
|
||||||
if (this.modal) {
|
|
||||||
this.modal.open();
|
|
||||||
} else {
|
|
||||||
this.overlay.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
this.updatePreview(htmlContent);
|
|
||||||
this.updateScaleLabel();
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this.modal) {
|
|
||||||
this.modal.close();
|
|
||||||
} else {
|
|
||||||
this.overlay.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
|
||||||
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
|
|
||||||
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
|
|
||||||
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
|
|
||||||
|
|
||||||
// Update preview on option changes
|
|
||||||
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
|
|
||||||
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scale slider
|
|
||||||
const scaleSlider = document.getElementById('print-scale');
|
|
||||||
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
|
|
||||||
|
|
||||||
// Page range toggle
|
|
||||||
document.getElementById('print-pages')?.addEventListener('change', (e) => {
|
|
||||||
const rangeInput = document.getElementById('print-page-range');
|
|
||||||
if (rangeInput) {
|
|
||||||
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: Backdrop click and Escape key are now handled by ModalManager
|
|
||||||
}
|
|
||||||
|
|
||||||
updateScaleLabel() {
|
|
||||||
const scale = document.getElementById('print-scale')?.value || 100;
|
|
||||||
const label = document.getElementById('print-scale-value');
|
|
||||||
if (label) label.textContent = `${scale}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreview(htmlContent) {
|
|
||||||
const frame = document.getElementById('print-preview-frame');
|
|
||||||
if (!frame) return;
|
|
||||||
|
|
||||||
this._lastContent = htmlContent;
|
|
||||||
|
|
||||||
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
|
|
||||||
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
|
|
||||||
|
|
||||||
// Get dimensions for paper size
|
|
||||||
const sizes = {
|
|
||||||
'A3': { width: '297mm', height: '420mm' },
|
|
||||||
'A4': { width: '210mm', height: '297mm' },
|
|
||||||
'A5': { width: '148mm', height: '210mm' },
|
|
||||||
'Letter': { width: '8.5in', height: '11in' },
|
|
||||||
'Legal': { width: '8.5in', height: '14in' },
|
|
||||||
'Tabloid': { width: '11in', height: '17in' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const size = sizes[paperSize] || sizes['A4'];
|
|
||||||
const width = orientation === 'landscape' ? size.height : size.width;
|
|
||||||
const height = orientation === 'landscape' ? size.width : size.height;
|
|
||||||
|
|
||||||
const previewHtml = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 20px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
@page { size: ${width} ${height}; }
|
|
||||||
pre { background: #f5f5f5; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
|
||||||
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
|
|
||||||
pre code { background: none; padding: 0; }
|
|
||||||
table { border-collapse: collapse; width: 100%; }
|
|
||||||
th, td { border: 1px solid #ddd; padding: 8px; }
|
|
||||||
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 16px; color: #666; }
|
|
||||||
img { max-width: 100%; }
|
|
||||||
h1, h2, h3 { margin-top: 1.5em; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>${htmlContent || ''}</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
frame.srcdoc = previewHtml;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshPreview() {
|
|
||||||
if (this._lastContent) {
|
|
||||||
this.updatePreview(this._lastContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getOptions() {
|
|
||||||
return {
|
|
||||||
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
|
|
||||||
orientation: document.getElementById('print-orientation')?.value || 'portrait',
|
|
||||||
margins: document.getElementById('print-margins')?.value || 'default',
|
|
||||||
scale: parseInt(document.getElementById('print-scale')?.value || '100'),
|
|
||||||
headers: document.getElementById('print-headers')?.checked ?? true,
|
|
||||||
background: document.getElementById('print-background')?.checked ?? true,
|
|
||||||
pages: document.getElementById('print-pages')?.value || 'all',
|
|
||||||
pageRange: document.getElementById('print-page-range')?.value || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
executePrint() {
|
|
||||||
const options = this.getOptions();
|
|
||||||
const { ipcRenderer } = require('electron');
|
|
||||||
ipcRenderer.send('do-print-with-options', options);
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { PrintPreview };
|
|
||||||
-5319
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell } from './components/layout/AppShell';
|
import { AppShell } from './components/layout/AppShell';
|
||||||
import { ModalLayer } from './components/modals/ModalLayer';
|
import { ModalLayer } from './components/modals/ModalLayer';
|
||||||
import { Toaster } from './components/ui/sonner';
|
import { Toaster } from './components/ui/sonner';
|
||||||
|
import { ReplPanel } from './components/tools/ReplPanel';
|
||||||
|
import { PrintPreview } from './components/tools/PrintPreview';
|
||||||
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useWelcomeTrigger();
|
useWelcomeTrigger();
|
||||||
|
const [printOpen, setPrintOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setPrintOpen(true);
|
||||||
|
window.addEventListener('mc:print', handler);
|
||||||
|
return () => window.removeEventListener('mc:print', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppShell />
|
<AppShell />
|
||||||
<ModalLayer />
|
<ModalLayer />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<ReplPanel />
|
||||||
|
{printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { oneDark } from '@codemirror/theme-one-dark';
|
|||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { lightTheme, lightHighlight } from './themes/light';
|
import { lightTheme, lightHighlight } from './themes/light';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { Minimap } from './Minimap';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bufferId: string;
|
bufferId: string;
|
||||||
@@ -24,6 +26,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
|||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const updateContent = useEditorStore((s) => s.updateContent);
|
const updateContent = useEditorStore((s) => s.updateContent);
|
||||||
const setCursor = useEditorStore((s) => s.setCursor);
|
const setCursor = useEditorStore((s) => s.setCursor);
|
||||||
|
const minimap = useSettingsStore((s) => s.minimap);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
@@ -76,5 +79,10 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
|||||||
});
|
});
|
||||||
}, [resolvedTheme]);
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
return <div ref={ref} className="h-full overflow-hidden" />;
|
return (
|
||||||
|
<div className="relative h-full overflow-hidden">
|
||||||
|
<div ref={ref} className="h-full overflow-hidden" />
|
||||||
|
{minimap && <Minimap content={initialContent} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
scrollRatio?: number; // 0-1, where the viewport is
|
||||||
|
visibleRatio?: number; // 0-1, what fraction of content is visible
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
// Compute viewport position in the minimap
|
||||||
|
const viewportTop = Math.round(scrollRatio * 100);
|
||||||
|
const viewportHeight = Math.round(visibleRatio * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="minimap"
|
||||||
|
className="pointer-events-none absolute right-0 top-0 h-full w-[100px] overflow-hidden border-l border-border bg-card/30 p-1 font-mono text-[6px] leading-[8px] text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<pre className="w-full truncate whitespace-pre">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} data-testid="minimap-line">
|
||||||
|
{line || ' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
<div
|
||||||
|
data-testid="minimap-viewport"
|
||||||
|
className="pointer-events-none absolute left-0 right-0 bg-brand/15"
|
||||||
|
style={{ top: `${viewportTop}%`, height: `${Math.max(viewportHeight, 5)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,13 +11,43 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/componen
|
|||||||
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
||||||
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
||||||
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
||||||
|
import { useZenMode } from '@/hooks/use-zen-mode';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
useFileShortcuts();
|
useFileShortcuts();
|
||||||
useRestoreLastFolder();
|
useRestoreLastFolder();
|
||||||
useRegisterMenuCommands();
|
useRegisterMenuCommands();
|
||||||
useBridgeNativeMenu();
|
useBridgeNativeMenu();
|
||||||
|
useZenMode();
|
||||||
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||||
|
const zenMode = useAppStore((s) => s.zenMode);
|
||||||
|
|
||||||
|
if (zenMode) {
|
||||||
|
return (
|
||||||
|
<main className="h-screen w-screen overflow-hidden bg-background">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
onLayoutChange={(sizes) => setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })}
|
||||||
|
>
|
||||||
|
<ResizablePanel defaultSize={previewVisible ? 50 : 100} minSize={20}>
|
||||||
|
<section className="h-full bg-background">
|
||||||
|
<EditorPane />
|
||||||
|
</section>
|
||||||
|
</ResizablePanel>
|
||||||
|
{previewVisible && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20}>
|
||||||
|
<section className="h-full border-l border-border bg-card/10">
|
||||||
|
<PreviewPane />
|
||||||
|
</section>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
@@ -28,7 +58,7 @@ export function AppShell() {
|
|||||||
<main className="flex-1 overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
direction="horizontal"
|
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 && (
|
{sidebarVisible && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
function extractHeadings(content: string): { level: number; text: string }[] {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const headings: { level: number; text: string }[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
if (m) headings.push({ level: m[1].length, text: m[2].trim() });
|
||||||
|
}
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
export function Breadcrumb() {
|
export function Breadcrumb() {
|
||||||
|
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||||
|
const openTabs = useFileStore((s) => s.openTabs);
|
||||||
|
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
|
||||||
|
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
|
||||||
|
|
||||||
|
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
|
||||||
|
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
<nav
|
||||||
<span>No file selected</span>
|
aria-label="File path"
|
||||||
|
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>{tab ? tab.title : 'No file selected'}</span>
|
||||||
|
{headings.map((h, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<span aria-hidden="true">›</span>
|
||||||
|
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,10 +17,10 @@ export function AboutDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}>
|
<Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}>
|
||||||
<DialogContent aria-describedby="about-desc">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>About MarkdownConverter</DialogTitle>
|
<DialogTitle>About MarkdownConverter</DialogTitle>
|
||||||
<DialogDescription id="about-desc">
|
<DialogDescription>
|
||||||
Professional Markdown editor and universal file converter.
|
Professional Markdown editor and universal file converter.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { figletText, FIGLET_FONTS, type FigletFont } from '@/lib/figlet';
|
||||||
|
|
||||||
|
export function AsciiGeneratorDialog() {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const [text, setText] = useState('Hello');
|
||||||
|
const [font, setFont] = useState<FigletFont>('Standard');
|
||||||
|
const [output, setOutput] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
figletText(text || ' ', font)
|
||||||
|
.then((result) => { if (!cancelled) setOutput(result); })
|
||||||
|
.catch(() => { if (!cancelled) setOutput('(render error)'); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [text, font]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>ASCII generator</DialogTitle>
|
||||||
|
<DialogDescription>Type text, pick a font, see ASCII art</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ascii-text">Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ascii-text"
|
||||||
|
aria-label="Text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ascii-font">Font</Label>
|
||||||
|
<Select value={font} onValueChange={(v) => setFont(v as FigletFont)}>
|
||||||
|
<SelectTrigger id="ascii-font" aria-label="Font"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIGLET_FONTS.map((f) => (
|
||||||
|
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Output</Label>
|
||||||
|
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="ascii-output">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal}>Close</Button>
|
||||||
|
<Button onClick={handleCopy}>Copy</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,10 @@ export function ConfirmDialog(props: ConfirmProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && handleCancel()}>
|
<Dialog open onOpenChange={(o) => !o && handleCancel()}>
|
||||||
<DialogContent aria-describedby="confirm-body">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription id="confirm-body">{body}</DialogDescription>
|
<DialogDescription>{body}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={handleCancel}>
|
<Button variant="ghost" onClick={handleCancel}>
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
<DialogContent aria-describedby="batch-desc">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Batch export</DialogTitle>
|
<DialogTitle>Batch export</DialogTitle>
|
||||||
<DialogDescription id="batch-desc">{sourcePaths.length} files</DialogDescription>
|
<DialogDescription>{sourcePaths.length} files</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
<DialogContent aria-describedby="docx-desc">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Export to DOCX</DialogTitle>
|
<DialogTitle>Export to DOCX</DialogTitle>
|
||||||
<DialogDescription id="docx-desc">{sourcePath}</DialogDescription>
|
<DialogDescription>{sourcePath}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
<DialogContent aria-describedby="html-desc">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Export to HTML</DialogTitle>
|
<DialogTitle>Export to HTML</DialogTitle>
|
||||||
<DialogDescription id="html-desc">{sourcePath}</DialogDescription>
|
<DialogDescription>{sourcePath}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
<DialogContent aria-describedby="pdf-desc">
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Export to PDF</DialogTitle>
|
<DialogTitle>Export to PDF</DialogTitle>
|
||||||
<DialogDescription id="pdf-desc">{sourcePath}</DialogDescription>
|
<DialogDescription>{sourcePath}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
filePath: string;
|
||||||
|
line: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindInFilesDialog() {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const rootPath = useFileStore((s) => s.rootPath);
|
||||||
|
const openFile = useFileStore((s) => s.openFile);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [isRegex, setIsRegex] = useState(false);
|
||||||
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!query || !rootPath) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await ipc.file.search({ rootPath, query, isRegex, caseSensitive });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResults(result.data ?? []);
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultClick = (filePath: string) => {
|
||||||
|
openFile(filePath);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Find in files</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{rootPath ? `Search in ${rootPath}` : 'No folder open'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="find-query">Query</Label>
|
||||||
|
<Input
|
||||||
|
id="find-query"
|
||||||
|
aria-label="Query"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={isRegex} onCheckedChange={(c) => setIsRegex(!!c)} aria-label="Regex" />
|
||||||
|
Regex
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={caseSensitive} onCheckedChange={(c) => setCaseSensitive(!!c)} aria-label="Case sensitive" />
|
||||||
|
Case sensitive
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>{results.length} result{results.length === 1 ? '' : 's'}</Label>
|
||||||
|
<div className="max-h-64 overflow-auto rounded border border-border bg-card/20 text-xs">
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={`${r.filePath}:${r.line}:${i}`}
|
||||||
|
onClick={() => handleResultClick(r.filePath)}
|
||||||
|
className="block w-full truncate border-b border-border/30 px-2 py-1 text-left hover:bg-card/50"
|
||||||
|
data-testid="find-result"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-muted-foreground">{r.filePath}:{r.line}</span>
|
||||||
|
<span className="ml-2">{r.content}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Close</Button>
|
||||||
|
<Button onClick={handleSearch} disabled={submitting || !query}>
|
||||||
|
{submitting ? 'Searching…' : 'Search'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { AboutDialog } from './AboutDialog';
|
import { AboutDialog } from './AboutDialog';
|
||||||
|
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
|
||||||
import { ConfirmDialog } from './ConfirmDialog';
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
import { ExportBatchDialog } from './ExportBatchDialog';
|
import { ExportBatchDialog } from './ExportBatchDialog';
|
||||||
import { ExportDocxDialog } from './ExportDocxDialog';
|
import { ExportDocxDialog } from './ExportDocxDialog';
|
||||||
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
||||||
import { ExportPdfDialog } from './ExportPdfDialog';
|
import { ExportPdfDialog } from './ExportPdfDialog';
|
||||||
|
import { FindInFilesDialog } from './FindInFilesDialog';
|
||||||
import { SettingsSheet } from './SettingsSheet';
|
import { SettingsSheet } from './SettingsSheet';
|
||||||
|
import { TableGeneratorDialog } from './TableGeneratorDialog';
|
||||||
import { WelcomeDialog } from './WelcomeDialog';
|
import { WelcomeDialog } from './WelcomeDialog';
|
||||||
|
import { WordExportDialog } from './WordExportDialog';
|
||||||
|
|
||||||
export function ModalLayer() {
|
export function ModalLayer() {
|
||||||
const modal = useAppStore((s) => s.modal);
|
const modal = useAppStore((s) => s.modal);
|
||||||
@@ -29,5 +33,13 @@ export function ModalLayer() {
|
|||||||
return <WelcomeDialog />;
|
return <WelcomeDialog />;
|
||||||
case 'confirm':
|
case 'confirm':
|
||||||
return <ConfirmDialog {...modal.props} />;
|
return <ConfirmDialog {...modal.props} />;
|
||||||
|
case 'export-word':
|
||||||
|
return <WordExportDialog sourcePath={modal.props.sourcePath} />;
|
||||||
|
case 'ascii-generator':
|
||||||
|
return <AsciiGeneratorDialog />;
|
||||||
|
case 'table-generator':
|
||||||
|
return <TableGeneratorDialog />;
|
||||||
|
case 'find-in-files':
|
||||||
|
return <FindInFilesDialog />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,10 @@ export function SettingsSheet() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open onOpenChange={(o) => !o && closeModal()}>
|
<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>
|
<SheetHeader>
|
||||||
<SheetTitle>Settings</SheetTitle>
|
<SheetTitle>Settings</SheetTitle>
|
||||||
<SheetDescription id="settings-desc">Editor, theme, and export preferences</SheetDescription>
|
<SheetDescription>Editor, theme, and export preferences</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<Tabs defaultValue="editor" className="mt-4">
|
<Tabs defaultValue="editor" className="mt-4">
|
||||||
<TabsList className="grid w-full grid-cols-5">
|
<TabsList className="grid w-full grid-cols-5">
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
function pad(s: string, w: number) {
|
||||||
|
return s.padEnd(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTable(rows: number, cols: number, hasHeader: boolean, headers: string[], data: string[][]): string {
|
||||||
|
const width = cols;
|
||||||
|
const colWidths: number[] = Array.from({ length: width }, (_, c) => {
|
||||||
|
const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')];
|
||||||
|
return Math.max(...all.map((s) => s.length), 3);
|
||||||
|
});
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (hasHeader) {
|
||||||
|
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])).join(' | ') + ' |');
|
||||||
|
lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
for (const row of data) {
|
||||||
|
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
// Add empty rows if data has fewer
|
||||||
|
for (let i = data.length; i < rows; i++) {
|
||||||
|
lines.push('| ' + colWidths.map((w) => pad('', w)).join(' | ') + ' |');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableGeneratorDialog() {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const [rows, setRows] = useState(3);
|
||||||
|
const [cols, setCols] = useState(3);
|
||||||
|
const [hasHeader, setHasHeader] = useState(true);
|
||||||
|
const [headers, setHeaders] = useState<string[]>(['Col 1', 'Col 2', 'Col 3']);
|
||||||
|
|
||||||
|
const output = generateTable(rows, cols, hasHeader, headers, []);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHeadersCount = (newCols: number) => {
|
||||||
|
setCols(newCols);
|
||||||
|
setHeaders((prev) => {
|
||||||
|
if (prev.length < newCols) return [...prev, ...Array(newCols - prev.length).fill('').map((_, i) => `Col ${prev.length + i + 1}`)];
|
||||||
|
return prev.slice(0, newCols);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Table generator</DialogTitle>
|
||||||
|
<DialogDescription>Specify rows × columns to generate a markdown table</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="table-rows">Rows</Label>
|
||||||
|
<Input
|
||||||
|
id="table-rows"
|
||||||
|
aria-label="Rows"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={rows}
|
||||||
|
onChange={(e) => setRows(Math.max(1, Math.min(50, Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="table-cols">Columns</Label>
|
||||||
|
<Input
|
||||||
|
id="table-cols"
|
||||||
|
aria-label="Cols"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={cols}
|
||||||
|
onChange={(e) => updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={hasHeader} onCheckedChange={(c) => setHasHeader(!!c)} aria-label="Header" />
|
||||||
|
Include header row
|
||||||
|
</label>
|
||||||
|
{hasHeader && (
|
||||||
|
<div>
|
||||||
|
<Label>Header names</Label>
|
||||||
|
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
|
||||||
|
{Array.from({ length: cols }, (_, i) => (
|
||||||
|
<Input
|
||||||
|
key={i}
|
||||||
|
aria-label={`Header ${i + 1}`}
|
||||||
|
value={headers[i] ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...headers];
|
||||||
|
next[i] = e.target.value;
|
||||||
|
setHeaders(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Label>Output</Label>
|
||||||
|
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="table-output">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal}>Close</Button>
|
||||||
|
<Button onClick={handleCopy}>Copy</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,10 @@ export function WelcomeDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && handleClose()}>
|
<Dialog open onOpenChange={(o) => !o && handleClose()}>
|
||||||
<DialogContent aria-describedby="welcome-desc" className="max-w-xl">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Welcome to MarkdownConverter</DialogTitle>
|
<DialogTitle>Welcome to MarkdownConverter</DialogTitle>
|
||||||
<DialogDescription id="welcome-desc">
|
<DialogDescription>
|
||||||
A polished editor for Markdown, with PDF, DOCX, and HTML export.
|
A polished editor for Markdown, with PDF, DOCX, and HTML export.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
|
export function WordExportDialog({ sourcePath }: { sourcePath: string }) {
|
||||||
|
const closeModal = useAppStore((s) => s.closeModal);
|
||||||
|
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||||
|
const docxCustomTemplatePath = useSettingsStore((s) => s.docxCustomTemplatePath);
|
||||||
|
const source = useExportSource();
|
||||||
|
|
||||||
|
const [templateMode, setTemplateMode] = useState<'standard' | 'custom'>(docxCustomTemplatePath ? 'custom' : 'standard');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTemplateMode(docxCustomTemplatePath ? 'custom' : 'standard');
|
||||||
|
}, [docxCustomTemplatePath]);
|
||||||
|
|
||||||
|
const handleChooseTemplate = async () => {
|
||||||
|
const result = await ipc.app.showSaveDialog?.({ title: 'Choose template path' });
|
||||||
|
if (result?.ok && result.data) {
|
||||||
|
setSetting('docxCustomTemplatePath', result.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!source) { setError('No file open.'); return; }
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const blob = await generateDocx({
|
||||||
|
source: source.source,
|
||||||
|
title: source.title,
|
||||||
|
customTemplatePath: templateMode === 'custom' ? docxCustomTemplatePath : null,
|
||||||
|
});
|
||||||
|
const saveResult = await ipc.app.showSaveDialog?.({ title: 'Save as Word document', defaultPath: source.path.replace(/\.md$/, '.docx') });
|
||||||
|
if (!saveResult?.ok || !saveResult.data) {
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
|
||||||
|
if (!writeResult.ok) {
|
||||||
|
setError(writeResult.error.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Exported ${source.title} to ${saveResult.data}`);
|
||||||
|
closeModal();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(`Export failed: ${msg}`);
|
||||||
|
setError(msg);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Export to Word (.docx)</DialogTitle>
|
||||||
|
<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')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="standard" id="template-standard" />
|
||||||
|
<Label htmlFor="template-standard">Standard (bundled)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem value="custom" id="template-custom" />
|
||||||
|
<Label htmlFor="template-custom">Custom .dotx</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
{templateMode === 'custom' && (
|
||||||
|
<div className="rounded border border-border bg-card/20 p-2 text-xs">
|
||||||
|
{docxCustomTemplatePath ? (
|
||||||
|
<span>Template path: <code>{docxCustomTemplatePath}</code></span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No template selected. Click "Choose template..." to pick a .dotx file.</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleChooseTemplate} className="ml-2">
|
||||||
|
Choose template...
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? 'Exporting…' : 'Export'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { RefreshCw, FileX, FilePlus, FileEdit, FileQuestion } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
interface GitStatus {
|
||||||
|
filePath: string;
|
||||||
|
status: 'modified' | 'added' | 'deleted' | 'untracked';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<GitStatus['status'], JSX.Element> = {
|
||||||
|
modified: <FileEdit className="h-3 w-3 text-warning" />,
|
||||||
|
added: <FilePlus className="h-3 w-3 text-success" />,
|
||||||
|
deleted: <FileX className="h-3 w-3 text-destructive" />,
|
||||||
|
untracked: <FileQuestion className="h-3 w-3 text-muted-foreground" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<GitStatus['status'], string> = {
|
||||||
|
modified: 'M',
|
||||||
|
added: 'A',
|
||||||
|
deleted: 'D',
|
||||||
|
untracked: '?',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GitStatusPanel() {
|
||||||
|
const rootPath = useFileStore((s) => s.rootPath);
|
||||||
|
const openFile = useFileStore((s) => s.openFile);
|
||||||
|
const [status, setStatus] = useState<GitStatus[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!rootPath) {
|
||||||
|
setStatus([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await ipc.file.gitStatus({ rootPath });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error.message);
|
||||||
|
setStatus([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(result.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// Listen for git.refresh command via custom event
|
||||||
|
const handler = () => load();
|
||||||
|
window.addEventListener('mc:git-refresh', handler);
|
||||||
|
return () => window.removeEventListener('mc:git-refresh', handler);
|
||||||
|
}, [rootPath]);
|
||||||
|
|
||||||
|
if (!rootPath) {
|
||||||
|
return <div className="p-3 text-xs text-muted-foreground">No folder open</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 text-xs">
|
||||||
|
<div className="text-destructive">Error: {error}</div>
|
||||||
|
<p className="mt-1 text-muted-foreground">Not a git repository, or git not installed.</p>
|
||||||
|
<Button size="sm" variant="ghost" onClick={load} className="mt-2">Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.length === 0 && !loading) {
|
||||||
|
return <div className="p-3 text-xs text-muted-foreground">Working tree clean</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-2 text-xs">
|
||||||
|
<div className="flex items-center justify-between px-1 py-1">
|
||||||
|
<span className="font-semibold">{status.length} changed file{status.length === 1 ? '' : 's'}</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={load} aria-label="Refresh">
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{status.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.filePath}
|
||||||
|
onClick={() => openFile(s.filePath)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50"
|
||||||
|
data-testid="git-status-row"
|
||||||
|
>
|
||||||
|
{STATUS_ICON[s.status]}
|
||||||
|
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
|
||||||
|
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { Outline } from './Outline';
|
import { Outline } from './Outline';
|
||||||
|
import { GitStatusPanel } from './GitStatusPanel';
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const tree = useFileStore((s) => s.tree);
|
const tree = useFileStore((s) => s.tree);
|
||||||
@@ -49,6 +50,21 @@ export function Sidebar() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Git section */}
|
||||||
|
<Collapsible defaultOpen>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
||||||
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
|
Git
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||||
|
<GitStatusPanel />
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { X, Printer } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MarkdownRenderer } from '@/components/preview/MarkdownRenderer';
|
||||||
|
import { useExportSource } from '@/hooks/use-export-source';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrintPreview({ onClose }: Props) {
|
||||||
|
const source = useExportSource();
|
||||||
|
|
||||||
|
const handlePrint = async () => {
|
||||||
|
if (!source) return;
|
||||||
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${source.title}</title></head><body><pre>${source.source.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c] ?? c))}</pre></body></html>`;
|
||||||
|
await ipc.print({ html });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||||
|
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
|
||||||
|
<h2 className="font-semibold">Print preview</h2>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center text-muted-foreground">
|
||||||
|
No file open
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||||
|
<div className="flex h-12 items-center justify-between border-b border-border bg-card/30 px-4">
|
||||||
|
<h2 className="font-semibold">Print preview — {source.title}</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="mr-2 h-4 w-4" />
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto bg-card/20 p-8">
|
||||||
|
<div className="mx-auto max-w-3xl rounded border border-border bg-background p-8 shadow-lg">
|
||||||
|
<MarkdownRenderer source={source.source} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { renderMarkdown } from '@/lib/markdown';
|
||||||
|
|
||||||
|
export function ReplPanel() {
|
||||||
|
const replOpen = useSettingsStore((s) => s.replOpen);
|
||||||
|
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||||
|
const [source, setSource] = useState('# Markdown preview\n\nType here…');
|
||||||
|
const [debouncedSource, setDebouncedSource] = useState(source);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSource(source), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
if (!replOpen) return null;
|
||||||
|
|
||||||
|
const html = renderMarkdown(debouncedSource);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="REPL"
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-40 flex h-[30vh] flex-col border-t border-border bg-card/95 shadow-2xl backdrop-blur"
|
||||||
|
data-testid="repl-panel"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 items-center justify-between border-b border-border bg-card/50 px-3 text-xs">
|
||||||
|
<span className="font-semibold">REPL — Markdown snippet preview</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Close REPL"
|
||||||
|
onClick={() => setSetting('replOpen', false)}
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<Textarea
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value)}
|
||||||
|
className="h-full flex-1 resize-none rounded-none border-r border-border font-mono text-xs"
|
||||||
|
aria-label="Markdown source"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full flex-1 overflow-auto p-3 text-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
data-testid="repl-preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts a global keydown listener that exits zen mode when the user
|
||||||
|
* presses Escape. Should be called once at the App root.
|
||||||
|
*/
|
||||||
|
export function useZenMode() {
|
||||||
|
const zenMode = useAppStore((s) => s.zenMode);
|
||||||
|
const setZenMode = useAppStore((s) => s.setZenMode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!zenMode) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setZenMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [zenMode, setZenMode]);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com;">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MarkdownConverter</title>
|
<title>MarkdownConverter</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useCommandStore } from '@/stores/command-store';
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
import { useFileStore } from '@/stores/file-store';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
import { useMenuAction } from '@/hooks/use-menu-action';
|
import { useMenuAction } from '@/hooks/use-menu-action';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +46,28 @@ export function registerMenuCommands(): void {
|
|||||||
'app.quit': () => {
|
'app.quit': () => {
|
||||||
/* stub — wired in later phase */
|
/* stub — wired in later phase */
|
||||||
},
|
},
|
||||||
|
'tools.ascii': () => useAppStore.getState().openModal('ascii-generator'),
|
||||||
|
'tools.table': () => useAppStore.getState().openModal('table-generator'),
|
||||||
|
'tools.findInFiles': () => useAppStore.getState().openModal('find-in-files'),
|
||||||
|
'tools.exportWord': () => {
|
||||||
|
const activeTabId = useFileStore.getState().activeTabId;
|
||||||
|
if (!activeTabId) return;
|
||||||
|
useAppStore.getState().openModal('export-word', { sourcePath: activeTabId });
|
||||||
|
},
|
||||||
|
'tools.repl': () => {
|
||||||
|
const current = useSettingsStore.getState().replOpen;
|
||||||
|
useSettingsStore.getState().setSetting('replOpen', !current);
|
||||||
|
},
|
||||||
|
'view.zenMode': () => {
|
||||||
|
const current = useAppStore.getState().zenMode;
|
||||||
|
useAppStore.getState().setZenMode(!current);
|
||||||
|
},
|
||||||
|
'file.print': () => {
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print'));
|
||||||
|
},
|
||||||
|
'git.refresh': () => {
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:git-refresh'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register } = useCommandStore.getState();
|
const { register } = useCommandStore.getState();
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
|
||||||
|
import { applyAsciiTransform } from './ascii-table';
|
||||||
|
|
||||||
|
export interface DocxOptions {
|
||||||
|
source: string;
|
||||||
|
title?: string;
|
||||||
|
customTemplatePath?: string | null; // for future use
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDocx(options: DocxOptions): Promise<Blob> {
|
||||||
|
// Apply ASCII transform (convert markdown tables to preformatted)
|
||||||
|
const transformed = applyAsciiTransform(options.source);
|
||||||
|
|
||||||
|
// Parse the source into a list of Paragraphs
|
||||||
|
// For v1, simple line-by-line: each line is a paragraph
|
||||||
|
// Headings (# ## ###) get heading styles
|
||||||
|
const lines = transformed.split('\n');
|
||||||
|
const children = lines.map((line) => {
|
||||||
|
if (line.startsWith('### ')) return new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 });
|
||||||
|
if (line.startsWith('## ')) return new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 });
|
||||||
|
if (line.startsWith('# ')) return new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 });
|
||||||
|
if (line.startsWith('```')) return new Paragraph({ text: line, alignment: AlignmentType.CENTER });
|
||||||
|
return new Paragraph({ children: [new TextRun(line)] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
sections: [{ children }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Packer.toBlob(doc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import figlet from 'figlet';
|
||||||
|
import type { Fonts } from 'figlet';
|
||||||
|
|
||||||
|
export const FIGLET_FONTS = ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'] as const;
|
||||||
|
export type FigletFont = typeof FIGLET_FONTS[number];
|
||||||
|
|
||||||
|
export function figletText(text: string, font: FigletFont): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
figlet.text(text, { font }, (err, result) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(result ?? '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -76,7 +76,15 @@ export const ipc = {
|
|||||||
}
|
}
|
||||||
return window.electronAPI.file.onChange(cb);
|
return window.electronAPI.file.onChange(cb);
|
||||||
},
|
},
|
||||||
|
search: (args: { rootPath: string; query: string; isRegex: boolean; caseSensitive: boolean }): Promise<IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'search', args),
|
||||||
|
gitStatus: (args: { rootPath: string }): Promise<IpcResult<Array<{ filePath: string; status: 'modified' | 'added' | 'deleted' | 'untracked' }> | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'gitStatus', args),
|
||||||
|
writeBuffer: (args: { path: string; buffer: Uint8Array }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'writeBuffer', args),
|
||||||
},
|
},
|
||||||
|
print: (args: { html: string }): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('print', 'show', args),
|
||||||
export: {
|
export: {
|
||||||
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
safeCall('export', 'pdf', opts),
|
safeCall('export', 'pdf', opts),
|
||||||
@@ -94,6 +102,8 @@ export const ipc = {
|
|||||||
safeCall('app', 'openExternal', url),
|
safeCall('app', 'openExternal', url),
|
||||||
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
safeCall('app', 'showItemInFolder', path),
|
safeCall('app', 'showItemInFolder', path),
|
||||||
|
showSaveDialog: (args?: { title?: string; defaultPath?: string }): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'showSaveDialog', args),
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const settingsSchema = z.object({
|
|||||||
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
|
pdfMargins: z.enum(['normal', 'narrow', 'wide']).default('normal'),
|
||||||
pdfEmbedFonts: z.boolean().default(true),
|
pdfEmbedFonts: z.boolean().default(true),
|
||||||
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
|
docxTemplate: z.enum(['standard', 'minimal', 'modern']).default('standard'),
|
||||||
|
docxCustomTemplatePath: z.string().nullable().default(null),
|
||||||
|
replOpen: z.boolean().default(false),
|
||||||
|
breadcrumbSymbols: z.boolean().default(true),
|
||||||
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
|
htmlHighlightStyle: z.enum(['github', 'monokai', 'nord', 'none']).default('github'),
|
||||||
renderTablesAsAscii: z.boolean().default(false),
|
renderTablesAsAscii: z.boolean().default(false),
|
||||||
welcomeDismissed: z.boolean().default(false),
|
welcomeDismissed: z.boolean().default(false),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import './styles/globals.css';
|
|||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
|
<ThemeProvider defaultTheme="light" attribute="class" enableSystem>
|
||||||
<App />
|
<App />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export type ModalState =
|
|||||||
| { kind: 'settings' }
|
| { kind: 'settings' }
|
||||||
| { kind: 'about' }
|
| { kind: 'about' }
|
||||||
| { kind: 'welcome' }
|
| { kind: 'welcome' }
|
||||||
| { kind: 'confirm'; props: ConfirmProps };
|
| { kind: 'confirm'; props: ConfirmProps }
|
||||||
|
| { kind: 'export-word'; props: { sourcePath: string } }
|
||||||
|
| { kind: 'ascii-generator' }
|
||||||
|
| { kind: 'table-generator' }
|
||||||
|
| { kind: 'find-in-files' };
|
||||||
|
|
||||||
export type ModalKind = ModalState['kind'];
|
export type ModalKind = ModalState['kind'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,969 +0,0 @@
|
|||||||
/**
|
|
||||||
* ConcreteInfo Theme for MarkdownConverter
|
|
||||||
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
|
|
||||||
* Version: 4.1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
CSS Variables - ConcreteInfo Theme
|
|
||||||
============================================ */
|
|
||||||
:root {
|
|
||||||
/* Primary Colors from Logo Palette */
|
|
||||||
--ci-dark-gray: #464646;
|
|
||||||
--ci-medium-gray: #9a9696;
|
|
||||||
--ci-accent: #e5461f;
|
|
||||||
--ci-light-gray: #e3e3e3;
|
|
||||||
--ci-black: #0d0b09;
|
|
||||||
|
|
||||||
/* Extended Palette */
|
|
||||||
--ci-accent-hover: #c93a18;
|
|
||||||
--ci-accent-light: rgba(229, 70, 31, 0.1);
|
|
||||||
--ci-white: #ffffff;
|
|
||||||
--ci-bg: #f5f5f5;
|
|
||||||
--ci-border: #d0d0d0;
|
|
||||||
|
|
||||||
/* Semantic Colors */
|
|
||||||
--ci-success: #28a745;
|
|
||||||
--ci-warning: #ffc107;
|
|
||||||
--ci-danger: #dc3545;
|
|
||||||
--ci-info: #17a2b8;
|
|
||||||
|
|
||||||
/* Typography */
|
|
||||||
--font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
|
||||||
|
|
||||||
/* Spacing */
|
|
||||||
--spacing-xs: 4px;
|
|
||||||
--spacing-sm: 8px;
|
|
||||||
--spacing-md: 16px;
|
|
||||||
--spacing-lg: 24px;
|
|
||||||
--spacing-xl: 32px;
|
|
||||||
|
|
||||||
/* Border Radius */
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
|
|
||||||
/* Shadows */
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
||||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
|
|
||||||
|
|
||||||
/* Transitions */
|
|
||||||
--transition-fast: 150ms ease;
|
|
||||||
--transition-normal: 250ms ease;
|
|
||||||
--transition-slow: 350ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
ConcreteInfo Theme Application
|
|
||||||
============================================ */
|
|
||||||
body.theme-concreteinfo {
|
|
||||||
background: var(--ci-bg);
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .toolbar {
|
|
||||||
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
|
|
||||||
border-bottom: 3px solid var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .toolbar button {
|
|
||||||
color: var(--ci-white);
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .toolbar button:hover {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .tab-bar {
|
|
||||||
background: var(--ci-dark-gray);
|
|
||||||
border-bottom: 2px solid var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .tab {
|
|
||||||
background: var(--ci-medium-gray);
|
|
||||||
color: var(--ci-white);
|
|
||||||
border: none;
|
|
||||||
margin: 2px;
|
|
||||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .tab:hover {
|
|
||||||
background: var(--ci-accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .tab.active {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .editor-textarea {
|
|
||||||
background: var(--ci-white);
|
|
||||||
color: var(--ci-black);
|
|
||||||
border: 1px solid var(--ci-border);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .preview-content {
|
|
||||||
background: var(--ci-white);
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
border-left: 3px solid var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo .status-bar {
|
|
||||||
background: var(--ci-dark-gray);
|
|
||||||
color: var(--ci-light-gray);
|
|
||||||
border-top: 2px solid var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Modern Modal Styles
|
|
||||||
============================================ */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(13, 11, 9, 0.7);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10000;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: all var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay.active {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
max-width: 600px;
|
|
||||||
width: 90%;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow: hidden;
|
|
||||||
transform: scale(0.9) translateY(20px);
|
|
||||||
transition: transform var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay.active .modal-content {
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
|
|
||||||
color: var(--ci-white);
|
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 3px solid var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--ci-white);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close:hover {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 60vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
border-top: 1px solid var(--ci-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Button Styles
|
|
||||||
============================================ */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--ci-accent-hover);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--ci-medium-gray);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--ci-dark-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: transparent;
|
|
||||||
border: 2px solid var(--ci-accent);
|
|
||||||
color: var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover {
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Form Controls
|
|
||||||
============================================ */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input,
|
|
||||||
.form-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border: 2px solid var(--ci-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--ci-white);
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus,
|
|
||||||
.form-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--ci-accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input::placeholder {
|
|
||||||
color: var(--ci-medium-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
App Header with Logo
|
|
||||||
============================================ */
|
|
||||||
.app-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 16px;
|
|
||||||
background: #ffffff;
|
|
||||||
border-bottom: 2px solid #e1e4e8;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo {
|
|
||||||
height: 22px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
|
||||||
color: #24292e;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-version {
|
|
||||||
color: #6a737d;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme header adjustments */
|
|
||||||
body.theme-dark .app-header,
|
|
||||||
body.theme-dracula .app-header,
|
|
||||||
body.theme-onedark .app-header,
|
|
||||||
body.theme-tokyonight .app-header,
|
|
||||||
body.theme-monokai .app-header,
|
|
||||||
body.theme-cobalt2 .app-header,
|
|
||||||
body.theme-gruvbox-dark .app-header,
|
|
||||||
body.theme-ayu-dark .app-header,
|
|
||||||
body.theme-ayu-mirage .app-header,
|
|
||||||
body.theme-palenight .app-header,
|
|
||||||
body.theme-oceanic-next .app-header,
|
|
||||||
body.theme-nord .app-header,
|
|
||||||
body.theme-concrete-dark .app-header {
|
|
||||||
background: #1f2937;
|
|
||||||
border-bottom-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-dark .app-title,
|
|
||||||
body.theme-dracula .app-title,
|
|
||||||
body.theme-onedark .app-title,
|
|
||||||
body.theme-tokyonight .app-title,
|
|
||||||
body.theme-monokai .app-title,
|
|
||||||
body.theme-cobalt2 .app-title,
|
|
||||||
body.theme-gruvbox-dark .app-title,
|
|
||||||
body.theme-ayu-dark .app-title,
|
|
||||||
body.theme-ayu-mirage .app-title,
|
|
||||||
body.theme-palenight .app-title,
|
|
||||||
body.theme-oceanic-next .app-title,
|
|
||||||
body.theme-nord .app-title,
|
|
||||||
body.theme-concrete-dark .app-title {
|
|
||||||
color: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-dark .app-version,
|
|
||||||
body.theme-dracula .app-version,
|
|
||||||
body.theme-onedark .app-version,
|
|
||||||
body.theme-tokyonight .app-version,
|
|
||||||
body.theme-monokai .app-version,
|
|
||||||
body.theme-cobalt2 .app-version,
|
|
||||||
body.theme-gruvbox-dark .app-version,
|
|
||||||
body.theme-ayu-dark .app-version,
|
|
||||||
body.theme-ayu-mirage .app-version,
|
|
||||||
body.theme-palenight .app-version,
|
|
||||||
body.theme-oceanic-next .app-version,
|
|
||||||
body.theme-nord .app-version,
|
|
||||||
body.theme-concrete-dark .app-version {
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-dark .app-logo,
|
|
||||||
body.theme-dracula .app-logo,
|
|
||||||
body.theme-onedark .app-logo,
|
|
||||||
body.theme-tokyonight .app-logo,
|
|
||||||
body.theme-monokai .app-logo,
|
|
||||||
body.theme-cobalt2 .app-logo,
|
|
||||||
body.theme-gruvbox-dark .app-logo,
|
|
||||||
body.theme-ayu-dark .app-logo,
|
|
||||||
body.theme-ayu-mirage .app-logo,
|
|
||||||
body.theme-palenight .app-logo,
|
|
||||||
body.theme-oceanic-next .app-logo,
|
|
||||||
body.theme-nord .app-logo,
|
|
||||||
body.theme-concrete-dark .app-logo {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Converter Cards
|
|
||||||
============================================ */
|
|
||||||
.converter-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.converter-card {
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
text-align: center;
|
|
||||||
border: 2px solid var(--ci-border);
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.converter-card:hover {
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.converter-card-icon {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.converter-card-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.converter-card-desc {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--ci-medium-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Progress Bar
|
|
||||||
============================================ */
|
|
||||||
.progress-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--ci-accent) 0%, var(--ci-accent-hover) 100%);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
transition: width var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
File Drop Zone
|
|
||||||
============================================ */
|
|
||||||
.drop-zone {
|
|
||||||
border: 2px dashed var(--ci-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--spacing-xl);
|
|
||||||
text-align: center;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone:hover,
|
|
||||||
.drop-zone.drag-over {
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
background: var(--ci-accent-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-icon {
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--ci-medium-gray);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-text {
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-hint {
|
|
||||||
color: var(--ci-medium-gray);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Syntax Highlighting for Editor
|
|
||||||
============================================ */
|
|
||||||
.editor-syntax .md-heading {
|
|
||||||
color: var(--ci-accent);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-syntax .md-bold {
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-syntax .md-italic {
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-syntax .md-code {
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
color: var(--ci-accent);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-syntax .md-link {
|
|
||||||
color: var(--ci-info);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-syntax .md-list {
|
|
||||||
color: var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-syntax .md-blockquote {
|
|
||||||
color: var(--ci-medium-gray);
|
|
||||||
border-left: 3px solid var(--ci-accent);
|
|
||||||
padding-left: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
ASCII Art Generator Styles
|
|
||||||
============================================ */
|
|
||||||
.ascii-preview {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.2;
|
|
||||||
background: var(--ci-black);
|
|
||||||
color: #00ff00;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ascii-controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Table Generator Styles
|
|
||||||
============================================ */
|
|
||||||
.table-generator-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--ci-border);
|
|
||||||
border: 1px solid var(--ci-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-generator-cell {
|
|
||||||
background: var(--ci-white);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
min-width: 80px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-generator-cell input {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-generator-cell.header {
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
PDF Viewer/Editor Styles
|
|
||||||
============================================ */
|
|
||||||
.pdf-viewer {
|
|
||||||
background: var(--ci-dark-gray);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
min-height: 400px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-page {
|
|
||||||
background: var(--ci-white);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Dark Mode for ConcreteInfo Theme
|
|
||||||
============================================ */
|
|
||||||
body.theme-concreteinfo-dark {
|
|
||||||
--ci-bg: #1a1a1a;
|
|
||||||
--ci-white: #2a2a2a;
|
|
||||||
--ci-light-gray: #3a3a3a;
|
|
||||||
--ci-border: #4a4a4a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo-dark .editor-textarea,
|
|
||||||
body.theme-concreteinfo-dark .preview-content {
|
|
||||||
background: #2a2a2a;
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo-dark .modal-content {
|
|
||||||
background: #2a2a2a;
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo-dark .modal-body {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concreteinfo-dark .modal-footer {
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Animations
|
|
||||||
============================================ */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-fade-in {
|
|
||||||
animation: fadeIn var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-up {
|
|
||||||
animation: slideUp var(--transition-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse {
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Responsive Design
|
|
||||||
============================================ */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.modal-content {
|
|
||||||
width: 95%;
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.converter-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Concrete Dark Theme
|
|
||||||
============================================ */
|
|
||||||
body.theme-concrete-dark {
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .toolbar {
|
|
||||||
background: linear-gradient(135deg, #0d0b09 0%, #1a1a1a 100%);
|
|
||||||
border-bottom: 3px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .toolbar button {
|
|
||||||
color: #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .toolbar button:hover {
|
|
||||||
background: #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .tab-bar {
|
|
||||||
background: #0d0b09;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .tab {
|
|
||||||
background: #2a2a2a;
|
|
||||||
color: #e3e3e3;
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .tab.active {
|
|
||||||
background: #464646;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .editor-textarea,
|
|
||||||
body.theme-concrete-dark .editor-pane {
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .preview-content {
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .status-bar {
|
|
||||||
background: #0d0b09;
|
|
||||||
color: #9a9696;
|
|
||||||
border-top: 1px solid #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .export-dialog-content,
|
|
||||||
body.theme-concrete-dark .batch-dialog-content {
|
|
||||||
background: #2a2a2a;
|
|
||||||
color: #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .export-dialog-header,
|
|
||||||
body.theme-concrete-dark .batch-dialog-header {
|
|
||||||
background: #1a1a1a;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .export-dialog-footer,
|
|
||||||
body.theme-concrete-dark .batch-dialog-footer {
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark input,
|
|
||||||
body.theme-concrete-dark select,
|
|
||||||
body.theme-concrete-dark textarea {
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #e3e3e3;
|
|
||||||
border-color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark input:focus,
|
|
||||||
body.theme-concrete-dark select:focus,
|
|
||||||
body.theme-concrete-dark textarea:focus {
|
|
||||||
border-color: #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-dark .line-numbers {
|
|
||||||
background: #0d0b09;
|
|
||||||
color: #9a9696;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Concrete Light Theme
|
|
||||||
============================================ */
|
|
||||||
body.theme-concrete-light {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .toolbar {
|
|
||||||
background: #ffffff;
|
|
||||||
border-bottom: 3px solid #e5461f;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .toolbar button {
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .toolbar button:hover {
|
|
||||||
background: #e5461f;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .tab-bar {
|
|
||||||
background: #ffffff;
|
|
||||||
border-bottom: 2px solid #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .tab {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #464646;
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .tab.active {
|
|
||||||
background: #ffffff;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
color: #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .editor-textarea,
|
|
||||||
body.theme-concrete-light .editor-pane {
|
|
||||||
background: #ffffff;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .preview-content {
|
|
||||||
background: #ffffff;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .status-bar {
|
|
||||||
background: #ffffff;
|
|
||||||
color: #9a9696;
|
|
||||||
border-top: 1px solid #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .export-dialog-content,
|
|
||||||
body.theme-concrete-light .batch-dialog-content {
|
|
||||||
background: #ffffff;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .export-dialog-header,
|
|
||||||
body.theme-concrete-light .batch-dialog-header {
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-light .line-numbers {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #9a9696;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
Concrete Warm Theme
|
|
||||||
============================================ */
|
|
||||||
body.theme-concrete-warm {
|
|
||||||
background: #faf8f5;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .toolbar {
|
|
||||||
background: linear-gradient(135deg, #464646 0%, #5a5a5a 100%);
|
|
||||||
border-bottom: 3px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .toolbar button {
|
|
||||||
color: #faf8f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .toolbar button:hover {
|
|
||||||
background: #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .tab-bar {
|
|
||||||
background: #464646;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .tab {
|
|
||||||
background: #6a6a6a;
|
|
||||||
color: #faf8f5;
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .tab.active {
|
|
||||||
background: #faf8f5;
|
|
||||||
color: #e5461f;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .editor-textarea,
|
|
||||||
body.theme-concrete-warm .editor-pane {
|
|
||||||
background: #faf8f5;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .preview-content {
|
|
||||||
background: #faf8f5;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .status-bar {
|
|
||||||
background: #f0ebe4;
|
|
||||||
color: #9a9696;
|
|
||||||
border-top: 1px solid #e3e3e3;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .export-dialog-content,
|
|
||||||
body.theme-concrete-warm .batch-dialog-content {
|
|
||||||
background: #faf8f5;
|
|
||||||
color: #464646;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .export-dialog-header,
|
|
||||||
body.theme-concrete-warm .batch-dialog-header {
|
|
||||||
background: #f0ebe4;
|
|
||||||
border-bottom: 2px solid #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm h1,
|
|
||||||
body.theme-concrete-warm h2,
|
|
||||||
body.theme-concrete-warm h3,
|
|
||||||
body.theme-concrete-warm h4,
|
|
||||||
body.theme-concrete-warm h5,
|
|
||||||
body.theme-concrete-warm h6 {
|
|
||||||
color: #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm a {
|
|
||||||
color: #e5461f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.theme-concrete-warm .line-numbers {
|
|
||||||
background: #f0ebe4;
|
|
||||||
color: #9a9696;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,304 +0,0 @@
|
|||||||
/* Sidebar */
|
|
||||||
.main-content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 100%;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed {
|
|
||||||
width: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar:not(.collapsed) {
|
|
||||||
width: 328px; /* 48px icons + 280px panel */
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-icons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 48px;
|
|
||||||
background: var(--gray-100, #f3f4f6);
|
|
||||||
border-right: 1px solid var(--gray-200, #e5e7eb);
|
|
||||||
padding: 8px 0;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--gray-500, #6b7280);
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-icon:hover {
|
|
||||||
background: var(--gray-200, #e5e7eb);
|
|
||||||
color: var(--gray-700, #374151);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-icon.active {
|
|
||||||
background: var(--gray-200, #e5e7eb);
|
|
||||||
color: var(--primary-dark, #5661b3);
|
|
||||||
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-icon:focus-visible {
|
|
||||||
outline: 2px solid var(--primary-dark, #5661b3);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-panel {
|
|
||||||
width: 280px;
|
|
||||||
background: var(--gray-50, #f9fafb);
|
|
||||||
border-right: 1px solid var(--gray-200, #e5e7eb);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.collapsed .sidebar-panel {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--gray-200, #e5e7eb);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--gray-600, #4b5563);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-panel-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--gray-400, #9ca3af);
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-panel-close:hover {
|
|
||||||
background: var(--gray-200, #e5e7eb);
|
|
||||||
color: var(--gray-600, #4b5563);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-panel-close:focus-visible {
|
|
||||||
outline: 2px solid var(--primary-dark, #5661b3);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-panel-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme support */
|
|
||||||
body[class*="dark"] .sidebar-icons,
|
|
||||||
body[class*="dark"] .sidebar-panel {
|
|
||||||
background: #1e1e1e;
|
|
||||||
border-color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[class*="dark"] .sidebar-icon {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[class*="dark"] .sidebar-icon:hover {
|
|
||||||
background: #333;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[class*="dark"] .sidebar-icon.active {
|
|
||||||
background: #333;
|
|
||||||
color: #8b9aff;
|
|
||||||
box-shadow: inset 3px 0 0 #8b9aff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[class*="dark"] .sidebar-panel-header {
|
|
||||||
border-color: #333;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel list items (templates, etc.) */
|
|
||||||
.panel-list-item {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.panel-list-item:hover {
|
|
||||||
background: var(--gray-200, #e5e7eb);
|
|
||||||
}
|
|
||||||
.panel-list-item-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--gray-800, #1f2937);
|
|
||||||
}
|
|
||||||
.panel-list-item-desc {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--gray-500, #6b7280);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[class*="dark"] .panel-list-item:hover {
|
|
||||||
background: #333;
|
|
||||||
}
|
|
||||||
body[class*="dark"] .panel-list-item-title {
|
|
||||||
color: #ddd;
|
|
||||||
}
|
|
||||||
body[class*="dark"] .panel-list-item-desc {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Explorer Panel */
|
|
||||||
.explorer-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.explorer-path {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid var(--gray-300, #d1d5db);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: white;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.explorer-browse-btn {
|
|
||||||
border: 1px solid var(--gray-300, #d1d5db);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: white;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
.tree-item {
|
|
||||||
padding: 3px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.tree-item:hover {
|
|
||||||
background: var(--gray-100, #f3f4f6);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.tree-icon {
|
|
||||||
margin-right: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.tree-children {
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
.tree-folder.collapsed .tree-children {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tree-name {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Git Panel */
|
|
||||||
.git-section { margin-bottom: 16px; }
|
|
||||||
.git-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500); margin-bottom: 8px; }
|
|
||||||
.git-file { display: flex; align-items: center; padding: 4px 6px; border-radius: 4px; font-size: 13px; gap: 6px; }
|
|
||||||
.git-file:hover { background: var(--gray-100, #f3f4f6); }
|
|
||||||
.git-file-status { font-weight: 700; font-family: monospace; width: 16px; text-align: center; }
|
|
||||||
.git-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.git-stage-btn { border: none; background: var(--gray-200); border-radius: 4px; cursor: pointer; font-size: 14px; width: 22px; height: 22px; }
|
|
||||||
.git-commit-input { width: 100%; padding: 8px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; font-family: inherit; resize: vertical; box-sizing: border-box; }
|
|
||||||
.git-commit-btn { width: 100%; margin-top: 8px; padding: 8px; background: var(--primary-dark, #5661b3); color: white; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
|
|
||||||
.git-commit-btn:hover { opacity: 0.9; }
|
|
||||||
.git-log-entry { padding: 6px 0; border-bottom: 1px solid var(--gray-100); }
|
|
||||||
.git-log-msg { font-size: 13px; }
|
|
||||||
.git-log-meta { font-size: 11px; color: var(--gray-500); margin-top: 2px; }
|
|
||||||
.git-info { font-size: 13px; color: var(--gray-500); padding: 8px 0; }
|
|
||||||
.git-loading { font-size: 13px; color: var(--gray-400); }
|
|
||||||
|
|
||||||
/* Snippets Panel */
|
|
||||||
.snippets-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
|
|
||||||
.snippets-search { flex: 1; padding: 6px 10px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; }
|
|
||||||
.snippets-add-btn { width: 32px; border: 1px solid var(--gray-300); border-radius: 6px; background: white; font-size: 18px; cursor: pointer; }
|
|
||||||
.snippet-item { padding: 8px; border: 1px solid var(--gray-200); border-radius: 6px; margin-bottom: 6px; }
|
|
||||||
.snippet-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
|
||||||
.snippet-name { font-size: 13px; font-weight: 500; }
|
|
||||||
.snippet-lang { font-size: 11px; background: var(--gray-100); padding: 2px 6px; border-radius: 4px; color: var(--gray-500); }
|
|
||||||
.snippet-preview { font-size: 12px; background: var(--gray-50); padding: 6px; border-radius: 4px; margin: 4px 0; overflow: hidden; max-height: 60px; }
|
|
||||||
.snippet-preview code { font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.snippet-actions { display: flex; gap: 4px; }
|
|
||||||
.snippet-insert { font-size: 12px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; }
|
|
||||||
.snippet-delete { font-size: 14px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; color: #ef4444; }
|
|
||||||
|
|
||||||
/* Dark theme for sidebar panels */
|
|
||||||
body[class*="dark"] .explorer-path,
|
|
||||||
body[class*="dark"] .explorer-browse-btn,
|
|
||||||
body[class*="dark"] .snippets-search,
|
|
||||||
body[class*="dark"] .snippets-add-btn,
|
|
||||||
body[class*="dark"] .snippet-insert,
|
|
||||||
body[class*="dark"] .snippet-delete {
|
|
||||||
background: #2d2d2d;
|
|
||||||
border-color: #444;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
body[class*="dark"] .git-commit-input {
|
|
||||||
background: #2d2d2d;
|
|
||||||
border-color: #444;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
body[class*="dark"] .snippet-item {
|
|
||||||
border-color: #444;
|
|
||||||
}
|
|
||||||
body[class*="dark"] .snippet-preview {
|
|
||||||
background: #2d2d2d;
|
|
||||||
}
|
|
||||||
body[class*="dark"] .tree-item:hover,
|
|
||||||
body[class*="dark"] .git-file:hover {
|
|
||||||
background: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Outline Panel */
|
|
||||||
.outline-panel { display: flex; flex-direction: column; height: 100%; }
|
|
||||||
.outline-list { flex: 1; overflow-y: auto; padding: 4px 0; }
|
|
||||||
.outline-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; cursor: pointer; font-size: 13px; color: var(--text-secondary, #6b7280); transition: background 0.15s, color 0.15s; border-left: 2px solid transparent; }
|
|
||||||
.outline-item:hover { background: var(--bg-tertiary, #f3f4f6); color: var(--text-primary, #1f2937); }
|
|
||||||
.outline-item.active { color: var(--accent-blue, #3b82f6); background: rgba(59, 130, 246, 0.08); border-left-color: var(--accent-blue, #3b82f6); font-weight: 600; }
|
|
||||||
.outline-level-1 { padding-left: 12px; }
|
|
||||||
.outline-level-2 { padding-left: 24px; }
|
|
||||||
.outline-level-3 { padding-left: 36px; }
|
|
||||||
.outline-level-4 { padding-left: 48px; }
|
|
||||||
.outline-level-5 { padding-left: 56px; }
|
|
||||||
.outline-level-6 { padding-left: 64px; }
|
|
||||||
.outline-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.outline-badge { font-size: 10px; opacity: 0.5; margin-left: 8px; flex-shrink: 0; }
|
|
||||||
.outline-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-muted, #9ca3af); text-align: center; }
|
|
||||||
.outline-empty p { margin: 4px 0; }
|
|
||||||
.outline-hint { font-family: monospace; font-size: 12px; opacity: 0.7; }
|
|
||||||
.outline-footer { padding: 8px 12px; border-top: 1px solid var(--border-color, #e5e7eb); font-size: 11px; color: var(--text-muted, #9ca3af); }
|
|
||||||
|
|
||||||
/* Outline dark mode */
|
|
||||||
body[class*="dark"] .outline-item { color: #9ca3af; }
|
|
||||||
body[class*="dark"] .outline-item:hover { background: #374151; color: #e5e7eb; }
|
|
||||||
body[class*="dark"] .outline-item.active { color: #8b9aff; background: rgba(139, 154, 255, 0.1); border-left-color: #8b9aff; }
|
|
||||||
body[class*="dark"] .outline-empty { color: #6b7280; }
|
|
||||||
body[class*="dark"] .outline-footer { border-color: #333; color: #6b7280; }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
.welcome-container { padding: 40px; max-width: 900px; margin: 0 auto; }
|
|
||||||
.welcome-hero { text-align: center; margin-bottom: 40px; }
|
|
||||||
.welcome-title { font-size: 32px; font-weight: 700; color: var(--gray-800, #1f2937); }
|
|
||||||
.welcome-version { font-size: 14px; color: var(--primary-dark, #5661b3); margin-top: 4px; font-weight: 500; }
|
|
||||||
.welcome-subtitle { font-size: 16px; color: var(--gray-500, #6b7280); margin-top: 8px; }
|
|
||||||
.welcome-grid { display: grid; gap: 32px; }
|
|
||||||
.welcome-section h2 { font-size: 18px; margin-bottom: 16px; color: var(--gray-700, #374151); }
|
|
||||||
.welcome-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
|
||||||
.welcome-card { padding: 20px; border: 1px solid var(--gray-200, #e5e7eb); border-radius: 12px; cursor: pointer; text-align: center; transition: all 0.2s; }
|
|
||||||
.welcome-card:hover { border-color: var(--primary-dark, #5661b3); box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); }
|
|
||||||
.welcome-card-icon { font-size: 28px; margin-bottom: 8px; }
|
|
||||||
.welcome-card h3 { font-size: 15px; margin-bottom: 4px; }
|
|
||||||
.welcome-card p { font-size: 13px; color: var(--gray-500); }
|
|
||||||
.welcome-card kbd { display: inline-block; margin-top: 8px; padding: 2px 8px; background: var(--gray-100); border: 1px solid var(--gray-300); border-radius: 4px; font-size: 12px; font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.welcome-features { list-style: none; padding: 0; }
|
|
||||||
.welcome-features li { padding: 6px 0; font-size: 14px; border-bottom: 1px solid var(--gray-100, #f3f4f6); }
|
|
||||||
.welcome-features strong { color: var(--primary-dark, #5661b3); }
|
|
||||||
.welcome-recent-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; }
|
|
||||||
.welcome-recent-item:hover { background: var(--gray-100); }
|
|
||||||
.welcome-recent-name { font-size: 14px; font-weight: 500; display: block; }
|
|
||||||
.welcome-recent-path { font-size: 12px; color: var(--gray-500); display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.welcome-muted { color: var(--gray-400); font-size: 14px; }
|
|
||||||
.welcome-footer { margin-top: 32px; text-align: center; }
|
|
||||||
.welcome-checkbox { font-size: 13px; color: var(--gray-500); cursor: pointer; }
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/* Zen Mode — Distraction-free writing styles */
|
|
||||||
|
|
||||||
/* Hide chrome elements */
|
|
||||||
body.zen-mode .app-header,
|
|
||||||
body.zen-mode .tab-bar,
|
|
||||||
body.zen-mode .toolbar,
|
|
||||||
body.zen-mode .status-bar,
|
|
||||||
body.zen-mode .sidebar,
|
|
||||||
body.zen-mode .breadcrumb-bar {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main content fills viewport */
|
|
||||||
body.zen-mode .main-content {
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editor container takes full width */
|
|
||||||
body.zen-mode .editor-container {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide preview panes */
|
|
||||||
body.zen-mode .preview-container,
|
|
||||||
body.zen-mode .preview-pane,
|
|
||||||
body.zen-mode .pane-resizer {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Center and style editor content for comfortable reading width */
|
|
||||||
body.zen-mode .cm-content {
|
|
||||||
max-width: 700px;
|
|
||||||
margin: 0 auto;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.8;
|
|
||||||
padding-top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.zen-mode .cm-line {
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.zen-mode .editor-wrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating HUD pill */
|
|
||||||
.zen-hud {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 24px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
|
||||||
z-index: 9999;
|
|
||||||
pointer-events: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-hud-stat {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-hud-sep {
|
|
||||||
opacity: 0.3;
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Word goal progress bar */
|
|
||||||
.zen-progress-container {
|
|
||||||
width: 120px;
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-top: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zen-progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(255, 178, 70, 0.85);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode — HUD stays dark in both themes */
|
|
||||||
body[class*="dark"].zen-mode .zen-hud {
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
-3723
File diff suppressed because it is too large
Load Diff
@@ -1,545 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Table Generator - MarkdownConverter</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--ci-dark-gray: #464646;
|
|
||||||
--ci-medium-gray: #9a9696;
|
|
||||||
--ci-accent: #e5461f;
|
|
||||||
--ci-light-gray: #e3e3e3;
|
|
||||||
--ci-black: #0d0b09;
|
|
||||||
--ci-white: #ffffff;
|
|
||||||
--ci-bg: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
background: var(--ci-bg);
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 3px solid var(--ci-accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
color: var(--ci-white);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 24px;
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--ci-medium-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input, .form-select {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 2px solid var(--ci-light-gray);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus, .form-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
accent-color: var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-table th,
|
|
||||||
.editable-table td {
|
|
||||||
border: 1px solid var(--ci-light-gray);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-table th {
|
|
||||||
background: var(--ci-dark-gray);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-table input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: none;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-table input:focus {
|
|
||||||
outline: 2px solid var(--ci-accent);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background: rgba(229, 70, 31, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-table th input {
|
|
||||||
color: var(--ci-white);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editable-table th input::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-container {
|
|
||||||
background: var(--ci-black);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #00ff00;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
background: var(--ci-white);
|
|
||||||
border-top: 1px solid var(--ci-light-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-left {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #c93a18;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
color: var(--ci-dark-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #d0d0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background: transparent;
|
|
||||||
border: 2px solid var(--ci-accent);
|
|
||||||
color: var(--ci-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-templates {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-chip {
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--ci-light-gray);
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-chip:hover {
|
|
||||||
background: var(--ci-accent);
|
|
||||||
color: var(--ci-white);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>Table Generator</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">Table Settings</div>
|
|
||||||
<div class="controls-row">
|
|
||||||
<div class="control-group">
|
|
||||||
<span class="control-label">Rows</span>
|
|
||||||
<input type="number" id="rows" class="form-input" min="1" max="50" value="4" style="width: 80px;">
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<span class="control-label">Columns</span>
|
|
||||||
<input type="number" id="cols" class="form-input" min="1" max="20" value="4" style="width: 80px;">
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<span class="control-label">Alignment</span>
|
|
||||||
<select id="alignment" class="form-select">
|
|
||||||
<option value="left">Left</option>
|
|
||||||
<option value="center">Center</option>
|
|
||||||
<option value="right">Right</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="control-group" style="justify-content: flex-end;">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="has-header" checked>
|
|
||||||
Include Header Row
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="quick-templates">
|
|
||||||
<button class="template-chip" data-rows="3" data-cols="2">2-Column (3 rows)</button>
|
|
||||||
<button class="template-chip" data-rows="5" data-cols="3">3-Column (5 rows)</button>
|
|
||||||
<button class="template-chip" data-rows="4" data-cols="4">4x4 Grid</button>
|
|
||||||
<button class="template-chip" data-rows="10" data-cols="3">Data Table (10 rows)</button>
|
|
||||||
<button class="template-chip" data-rows="2" data-cols="5">Wide Table</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">Edit Table Content</div>
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="editable-table" id="editable-table">
|
|
||||||
<!-- Generated dynamically -->
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">Markdown Preview</div>
|
|
||||||
<div class="preview-container">
|
|
||||||
<div id="preview" class="preview-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<div class="footer-left">
|
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-clear">Clear All</button>
|
|
||||||
<button class="btn btn-outline btn-sm" id="btn-fill-sample">Fill Sample Data</button>
|
|
||||||
</div>
|
|
||||||
<div class="footer-right">
|
|
||||||
<button class="btn btn-secondary" id="btn-copy">Copy Markdown</button>
|
|
||||||
<button class="btn btn-primary" id="btn-insert">Insert to Editor</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentRows = 4;
|
|
||||||
let currentCols = 4;
|
|
||||||
let hasHeader = true;
|
|
||||||
let alignment = 'left';
|
|
||||||
|
|
||||||
// Generate the editable table
|
|
||||||
function generateEditableTable() {
|
|
||||||
const table = document.getElementById('editable-table');
|
|
||||||
hasHeader = document.getElementById('has-header').checked;
|
|
||||||
alignment = document.getElementById('alignment').value;
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < currentRows; i++) {
|
|
||||||
html += '<tr>';
|
|
||||||
for (let j = 0; j < currentCols; j++) {
|
|
||||||
const isHeader = hasHeader && i === 0;
|
|
||||||
const tag = isHeader ? 'th' : 'td';
|
|
||||||
const placeholder = isHeader ? `Header ${j + 1}` : `Cell ${i},${j + 1}`;
|
|
||||||
html += `<${tag}><input type="text" data-row="${i}" data-col="${j}" placeholder="${placeholder}"></${tag}>`;
|
|
||||||
}
|
|
||||||
html += '</tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
table.innerHTML = html;
|
|
||||||
|
|
||||||
// Add event listeners for live preview
|
|
||||||
table.querySelectorAll('input').forEach(input => {
|
|
||||||
input.addEventListener('input', updatePreview);
|
|
||||||
});
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get table data
|
|
||||||
function getTableData() {
|
|
||||||
const data = [];
|
|
||||||
for (let i = 0; i < currentRows; i++) {
|
|
||||||
const row = [];
|
|
||||||
for (let j = 0; j < currentCols; j++) {
|
|
||||||
const input = document.querySelector(`input[data-row="${i}"][data-col="${j}"]`);
|
|
||||||
row.push(input ? input.value || '' : '');
|
|
||||||
}
|
|
||||||
data.push(row);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate markdown table
|
|
||||||
function generateMarkdown(data) {
|
|
||||||
if (data.length === 0 || data[0].length === 0) return '';
|
|
||||||
|
|
||||||
// Calculate column widths
|
|
||||||
const colWidths = [];
|
|
||||||
for (let j = 0; j < data[0].length; j++) {
|
|
||||||
let maxWidth = 3; // Minimum width for separator
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
maxWidth = Math.max(maxWidth, (data[i][j] || '').length);
|
|
||||||
}
|
|
||||||
colWidths.push(maxWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate alignment function
|
|
||||||
const alignCell = (text, width) => {
|
|
||||||
text = text || '';
|
|
||||||
const padding = width - text.length;
|
|
||||||
if (alignment === 'center') {
|
|
||||||
const left = Math.floor(padding / 2);
|
|
||||||
const right = padding - left;
|
|
||||||
return ' '.repeat(left) + text + ' '.repeat(right);
|
|
||||||
} else if (alignment === 'right') {
|
|
||||||
return ' '.repeat(padding) + text;
|
|
||||||
}
|
|
||||||
return text + ' '.repeat(padding);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate separator based on alignment
|
|
||||||
const getSeparator = (width) => {
|
|
||||||
if (alignment === 'center') {
|
|
||||||
return ':' + '-'.repeat(width - 2) + ':';
|
|
||||||
} else if (alignment === 'right') {
|
|
||||||
return '-'.repeat(width - 1) + ':';
|
|
||||||
}
|
|
||||||
return '-'.repeat(width);
|
|
||||||
};
|
|
||||||
|
|
||||||
let markdown = '';
|
|
||||||
const startRow = hasHeader ? 0 : -1;
|
|
||||||
|
|
||||||
for (let i = startRow; i < data.length; i++) {
|
|
||||||
if (i === -1) {
|
|
||||||
// Generate empty header for tables without header
|
|
||||||
markdown += '| ' + colWidths.map(w => ' '.repeat(w)).join(' | ') + ' |\n';
|
|
||||||
} else {
|
|
||||||
const row = data[i];
|
|
||||||
const cells = row.map((cell, j) => alignCell(cell, colWidths[j]));
|
|
||||||
markdown += '| ' + cells.join(' | ') + ' |\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add separator after first row
|
|
||||||
if ((hasHeader && i === 0) || (!hasHeader && i === -1)) {
|
|
||||||
const separators = colWidths.map(w => getSeparator(w));
|
|
||||||
markdown += '| ' + separators.join(' | ') + ' |\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return markdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update preview
|
|
||||||
function updatePreview() {
|
|
||||||
const data = getTableData();
|
|
||||||
const markdown = generateMarkdown(data);
|
|
||||||
document.getElementById('preview').textContent = markdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
document.getElementById('rows').addEventListener('change', (e) => {
|
|
||||||
currentRows = parseInt(e.target.value) || 4;
|
|
||||||
generateEditableTable();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('cols').addEventListener('change', (e) => {
|
|
||||||
currentCols = parseInt(e.target.value) || 4;
|
|
||||||
generateEditableTable();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('alignment').addEventListener('change', () => {
|
|
||||||
updatePreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('has-header').addEventListener('change', () => {
|
|
||||||
generateEditableTable();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Quick templates
|
|
||||||
document.querySelectorAll('.template-chip').forEach(chip => {
|
|
||||||
chip.addEventListener('click', () => {
|
|
||||||
currentRows = parseInt(chip.dataset.rows);
|
|
||||||
currentCols = parseInt(chip.dataset.cols);
|
|
||||||
document.getElementById('rows').value = currentRows;
|
|
||||||
document.getElementById('cols').value = currentCols;
|
|
||||||
generateEditableTable();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all
|
|
||||||
document.getElementById('btn-clear').addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('#editable-table input').forEach(input => {
|
|
||||||
input.value = '';
|
|
||||||
});
|
|
||||||
updatePreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fill sample data
|
|
||||||
document.getElementById('btn-fill-sample').addEventListener('click', () => {
|
|
||||||
const sampleHeaders = ['Name', 'Description', 'Status', 'Priority', 'Due Date'];
|
|
||||||
const sampleData = [
|
|
||||||
['Task 1', 'First task description', 'In Progress', 'High', '2024-01-15'],
|
|
||||||
['Task 2', 'Second task description', 'Completed', 'Medium', '2024-01-10'],
|
|
||||||
['Task 3', 'Third task description', 'Pending', 'Low', '2024-01-20'],
|
|
||||||
['Task 4', 'Fourth task description', 'In Review', 'High', '2024-01-18']
|
|
||||||
];
|
|
||||||
|
|
||||||
for (let i = 0; i < currentRows; i++) {
|
|
||||||
for (let j = 0; j < currentCols; j++) {
|
|
||||||
const input = document.querySelector(`input[data-row="${i}"][data-col="${j}"]`);
|
|
||||||
if (input) {
|
|
||||||
if (hasHeader && i === 0) {
|
|
||||||
input.value = sampleHeaders[j % sampleHeaders.length];
|
|
||||||
} else {
|
|
||||||
const dataRow = hasHeader ? i - 1 : i;
|
|
||||||
if (dataRow < sampleData.length && j < sampleData[dataRow].length) {
|
|
||||||
input.value = sampleData[dataRow][j];
|
|
||||||
} else {
|
|
||||||
input.value = `Data ${i}-${j + 1}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updatePreview();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy markdown
|
|
||||||
document.getElementById('btn-copy').addEventListener('click', () => {
|
|
||||||
const markdown = document.getElementById('preview').textContent;
|
|
||||||
navigator.clipboard.writeText(markdown).then(() => {
|
|
||||||
const btn = document.getElementById('btn-copy');
|
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.textContent = 'Copy Markdown';
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert to editor
|
|
||||||
document.getElementById('btn-insert').addEventListener('click', () => {
|
|
||||||
const content = document.getElementById('preview').textContent;
|
|
||||||
if (content && window.electronAPI) {
|
|
||||||
window.electronAPI.send('insert-generated-content', '\n' + content + '\n');
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
generateEditableTable();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
function createWelcomeContent(recentFiles = [], appVersion = '') {
|
|
||||||
const recentHtml = recentFiles.length
|
|
||||||
? recentFiles.map(f => {
|
|
||||||
const name = f.split(/[/\\]/).pop();
|
|
||||||
return `<div class="welcome-recent-item" data-path="${f}"><span class="welcome-recent-name">${name}</span><span class="welcome-recent-path">${f}</span></div>`;
|
|
||||||
}).join('')
|
|
||||||
: '<p class="welcome-muted">No recent files</p>';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="welcome-container">
|
|
||||||
<div class="welcome-hero">
|
|
||||||
<h1 class="welcome-title">MarkdownConverter</h1>
|
|
||||||
<p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p>
|
|
||||||
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="welcome-grid">
|
|
||||||
<div class="welcome-section">
|
|
||||||
<h2>Quick Start</h2>
|
|
||||||
<div class="welcome-cards">
|
|
||||||
<div class="welcome-card" data-action="new-file">
|
|
||||||
<div class="welcome-card-icon">+</div>
|
|
||||||
<h3>New Document</h3>
|
|
||||||
<p>Create a blank document</p>
|
|
||||||
<kbd>Ctrl+N</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="welcome-card" data-action="open-file">
|
|
||||||
<div class="welcome-card-icon">📂</div>
|
|
||||||
<h3>Open File</h3>
|
|
||||||
<p>Open an existing file</p>
|
|
||||||
<kbd>Ctrl+O</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="welcome-card" data-action="open-template">
|
|
||||||
<div class="welcome-card-icon">📋</div>
|
|
||||||
<h3>From Template</h3>
|
|
||||||
<p>Start from a template</p>
|
|
||||||
</div>
|
|
||||||
<div class="welcome-card" data-action="command-palette">
|
|
||||||
<div class="welcome-card-icon">⌘</div>
|
|
||||||
<h3>Command Palette</h3>
|
|
||||||
<p>Search all actions</p>
|
|
||||||
<kbd>Ctrl+Shift+P</kbd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="welcome-section">
|
|
||||||
<h2>What's New in v4.x</h2>
|
|
||||||
<ul class="welcome-features">
|
|
||||||
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
|
|
||||||
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
|
|
||||||
<li><strong>Command Palette</strong> — Quick access to all actions</li>
|
|
||||||
<li><strong>Code Execution</strong> — Run JS, Python, Bash from code blocks</li>
|
|
||||||
<li><strong>Print Preview</strong> — Full print customization dialog</li>
|
|
||||||
<li><strong>New Formats</strong> — Reveal.js, YAML, JSON, Confluence, and more</li>
|
|
||||||
<li><strong>Spell Checking</strong> — System dictionary with suggestions</li>
|
|
||||||
<li><strong>Markdown Extensions</strong> — Footnotes, admonitions, TOC</li>
|
|
||||||
<li><strong>Image Handling</strong> — Paste and drag-drop images</li>
|
|
||||||
<li><strong>PlantUML</strong> — Diagram rendering in preview</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="welcome-section">
|
|
||||||
<h2>Recent Files</h2>
|
|
||||||
<div class="welcome-recent">${recentHtml}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="welcome-footer">
|
|
||||||
<label class="welcome-checkbox">
|
|
||||||
<input type="checkbox" id="show-welcome-startup" checked>
|
|
||||||
Show this tab on startup
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createWelcomeContent };
|
|
||||||
-292
@@ -1,292 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zen Mode — distraction-free writing mode
|
|
||||||
* Provides typewriter scrolling, line dimming, centered content, and a floating HUD.
|
|
||||||
* Toggle with F11, exit with Escape.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const {
|
|
||||||
EditorView,
|
|
||||||
ViewPlugin,
|
|
||||||
Decoration,
|
|
||||||
DecorationSet,
|
|
||||||
} = require('@codemirror/view');
|
|
||||||
const { RangeSetBuilder } = require('@codemirror/state');
|
|
||||||
|
|
||||||
class ZenMode {
|
|
||||||
/**
|
|
||||||
* @param {import('./renderer').TabManager} tabManager
|
|
||||||
*/
|
|
||||||
constructor(tabManager) {
|
|
||||||
this.tabManager = tabManager;
|
|
||||||
this.active = false;
|
|
||||||
this._hud = null;
|
|
||||||
this._timerInterval = null;
|
|
||||||
this._sessionStart = null;
|
|
||||||
this._extensions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
activate() {
|
|
||||||
if (this.active) return;
|
|
||||||
this.active = true;
|
|
||||||
|
|
||||||
document.body.classList.add('zen-mode');
|
|
||||||
|
|
||||||
// Collapse sidebar
|
|
||||||
const sidebar = document.getElementById('sidebar');
|
|
||||||
if (sidebar) sidebar.classList.add('collapsed');
|
|
||||||
|
|
||||||
// Hide preview pane for the active tab
|
|
||||||
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
|
||||||
if (tab) {
|
|
||||||
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
|
||||||
if (previewContainer) previewContainer.classList.add('hidden');
|
|
||||||
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
|
||||||
if (editorPane) editorPane.classList.add('full-width');
|
|
||||||
|
|
||||||
// Add CM6 extensions to the active editor
|
|
||||||
if (tab.editorView) {
|
|
||||||
this._extensions = [
|
|
||||||
typewriterScrollExtension,
|
|
||||||
lineDimmingExtension,
|
|
||||||
];
|
|
||||||
// Reconfigure is not straightforward with CM6's immutable state.
|
|
||||||
// Instead we dispatch effects or simply rebuild with new extensions.
|
|
||||||
// Since we cannot hot-swap extensions on an existing EditorView,
|
|
||||||
// we store a reference and the CSS + updateListener handle the rest.
|
|
||||||
// The typewriter + dimming plugins are applied via a compartment approach
|
|
||||||
// would be ideal, but for simplicity we use DOM + updateListener.
|
|
||||||
this._applyTypewriterBehavior(tab.editorView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create HUD
|
|
||||||
this._createHUD();
|
|
||||||
|
|
||||||
// Start session timer
|
|
||||||
this._sessionStart = Date.now();
|
|
||||||
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
|
|
||||||
|
|
||||||
// Focus editor
|
|
||||||
if (tab?.editorView) tab.editorView.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivate() {
|
|
||||||
if (!this.active) return;
|
|
||||||
this.active = false;
|
|
||||||
|
|
||||||
document.body.classList.remove('zen-mode');
|
|
||||||
|
|
||||||
// Restore preview pane for the active tab
|
|
||||||
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
|
||||||
if (tab) {
|
|
||||||
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
|
||||||
if (previewContainer) previewContainer.classList.remove('hidden');
|
|
||||||
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
|
||||||
if (editorPane) editorPane.classList.remove('full-width');
|
|
||||||
|
|
||||||
// Remove typewriter listener
|
|
||||||
if (tab.editorView && this._selectionListener) {
|
|
||||||
window.removeEventListener('zen-typewriter', this._selectionListener);
|
|
||||||
this._selectionListener = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove HUD
|
|
||||||
if (this._hud && this._hud.parentNode) {
|
|
||||||
this._hud.parentNode.removeChild(this._hud);
|
|
||||||
this._hud = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop timer
|
|
||||||
if (this._timerInterval) {
|
|
||||||
clearInterval(this._timerInterval);
|
|
||||||
this._timerInterval = null;
|
|
||||||
}
|
|
||||||
this._sessionStart = null;
|
|
||||||
this._extensions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
if (this.active) {
|
|
||||||
this.deactivate();
|
|
||||||
} else {
|
|
||||||
this.activate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_applyTypewriterBehavior(view) {
|
|
||||||
// We use a custom update listener that scrolls the cursor to center.
|
|
||||||
// This is stored on the instance so we can clean it up.
|
|
||||||
const scrollTimeout = null;
|
|
||||||
|
|
||||||
const scrollFn = () => {
|
|
||||||
if (!this.active) return;
|
|
||||||
const pos = view.state.selection.main.head;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
view.dispatch({
|
|
||||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for selection/doc changes via a polling approach
|
|
||||||
// since we cannot add extensions to an existing view.
|
|
||||||
// Instead, use the DOM scrollIntoView after selection changes.
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
// No-op, we use interval below
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use interval to keep cursor centered during typing
|
|
||||||
this._scrollInterval = setInterval(() => {
|
|
||||||
if (!this.active) {
|
|
||||||
clearInterval(this._scrollInterval);
|
|
||||||
this._scrollInterval = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Typewriter scroll: center the cursor line
|
|
||||||
try {
|
|
||||||
const pos = view.state.selection.main.head;
|
|
||||||
const coords = view.coordsAtPos(pos);
|
|
||||||
const scroller = view.scrollDOM;
|
|
||||||
if (coords && scroller) {
|
|
||||||
const scrollerRect = scroller.getBoundingClientRect();
|
|
||||||
const cursorCenter = coords.top + (coords.bottom - coords.top) / 2;
|
|
||||||
const viewCenter = scrollerRect.top + scrollerRect.height / 2;
|
|
||||||
const diff = cursorCenter - viewCenter;
|
|
||||||
if (Math.abs(diff) > 30) {
|
|
||||||
scroller.scrollTop += diff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors from destroyed views
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
// Initial scroll to center
|
|
||||||
scrollFn();
|
|
||||||
}
|
|
||||||
|
|
||||||
_createHUD() {
|
|
||||||
const hud = document.createElement('div');
|
|
||||||
hud.className = 'zen-hud';
|
|
||||||
hud.innerHTML = `
|
|
||||||
<span class="zen-hud-item" id="zen-words">0 words</span>
|
|
||||||
<span class="zen-hud-sep">·</span>
|
|
||||||
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
|
||||||
<span class="zen-hud-sep">·</span>
|
|
||||||
<span class="zen-hud-item" id="zen-timer">00:00</span>
|
|
||||||
<div class="zen-progress-container" id="zen-progress-container">
|
|
||||||
<div class="zen-progress-bar" id="zen-progress-bar"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(hud);
|
|
||||||
this._hud = hud;
|
|
||||||
this._updateHUD();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateHUD() {
|
|
||||||
if (!this.active || !this._hud) return;
|
|
||||||
|
|
||||||
const content = this.tabManager.getEditorContent();
|
|
||||||
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0;
|
|
||||||
const readingTime = Math.max(1, Math.ceil(words / 200));
|
|
||||||
|
|
||||||
const wordsEl = document.getElementById('zen-words');
|
|
||||||
const readingEl = document.getElementById('zen-reading-time');
|
|
||||||
const timerEl = document.getElementById('zen-timer');
|
|
||||||
|
|
||||||
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
|
|
||||||
if (readingEl) readingEl.textContent = `${readingTime} min read`;
|
|
||||||
|
|
||||||
// Session timer
|
|
||||||
if (this._sessionStart) {
|
|
||||||
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
|
|
||||||
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
|
|
||||||
const seconds = String(elapsed % 60).padStart(2, '0');
|
|
||||||
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Word goal progress
|
|
||||||
const goalStr = localStorage.getItem('zen-word-goal');
|
|
||||||
const progressBar = document.getElementById('zen-progress-bar');
|
|
||||||
const progressContainer = document.getElementById('zen-progress-container');
|
|
||||||
if (progressBar && progressContainer) {
|
|
||||||
if (goalStr) {
|
|
||||||
const goal = parseInt(goalStr, 10);
|
|
||||||
if (goal > 0) {
|
|
||||||
const pct = Math.min(100, Math.round((words / goal) * 100));
|
|
||||||
progressBar.style.width = `${pct}%`;
|
|
||||||
progressContainer.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
progressContainer.style.display = 'none';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
progressContainer.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typewriter scroll ViewPlugin
|
|
||||||
const typewriterScrollExtension = ViewPlugin.fromClass(class {
|
|
||||||
constructor(view) {
|
|
||||||
this.view = view;
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update) {
|
|
||||||
if (update.selectionSet || update.docChanged) {
|
|
||||||
const pos = update.state.selection.main.head;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this.view.dom && this.view.dom.isConnected) {
|
|
||||||
this.view.dispatch({
|
|
||||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Line dimming ViewPlugin
|
|
||||||
function getOpacity(distance) {
|
|
||||||
if (distance === 0) return 1.0;
|
|
||||||
if (distance === 1) return 0.7;
|
|
||||||
if (distance === 2) return 0.6;
|
|
||||||
if (distance === 3) return 0.45;
|
|
||||||
return 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineDimmingExtension = ViewPlugin.fromClass(class {
|
|
||||||
constructor(view) {
|
|
||||||
this.decorations = this.buildDecorations(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update) {
|
|
||||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
|
||||||
this.decorations = this.buildDecorations(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildDecorations(view) {
|
|
||||||
const builder = new RangeSetBuilder();
|
|
||||||
const pos = view.state.selection.main.head;
|
|
||||||
const activeLine = view.state.doc.lineAt(pos).number;
|
|
||||||
const doc = view.state.doc;
|
|
||||||
|
|
||||||
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
|
|
||||||
const line = doc.line(lineNum);
|
|
||||||
const distance = Math.abs(lineNum - activeLine);
|
|
||||||
const opacity = getOpacity(distance);
|
|
||||||
const deco = Decoration.line({
|
|
||||||
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
|
|
||||||
});
|
|
||||||
builder.add(line.from, line.from, deco);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.finish();
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
decorations: v => v.decorations,
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = { ZenMode };
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* @jest-environment jsdom
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for CommandPalette
|
|
||||||
* Tests command registration, filtering, execution, and keyboard navigation
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('CommandPalette', () => {
|
|
||||||
let CommandPalette;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div class="command-palette-overlay hidden" id="command-palette-overlay">
|
|
||||||
<div class="command-palette">
|
|
||||||
<input type="text" id="command-palette-input">
|
|
||||||
<div id="command-palette-results"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
CommandPalette = require('../src/command-palette').CommandPalette;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('starts hidden', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
expect(palette.isOpen()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens and focuses input', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.open();
|
|
||||||
expect(palette.isOpen()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('closes', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.open();
|
|
||||||
palette.close();
|
|
||||||
expect(palette.isOpen()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registers and renders commands', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.register('Test Command', 'Ctrl+T', () => {});
|
|
||||||
palette.register('Another Command', '', () => {});
|
|
||||||
palette.open();
|
|
||||||
const items = document.querySelectorAll('.command-item');
|
|
||||||
expect(items.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filters commands by search', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.register('Save File', 'Ctrl+S', () => {});
|
|
||||||
palette.register('Open File', 'Ctrl+O', () => {});
|
|
||||||
palette.register('Bold Text', 'Ctrl+B', () => {});
|
|
||||||
palette.renderResults('file');
|
|
||||||
const items = document.querySelectorAll('.command-item');
|
|
||||||
expect(items.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('executes command', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
const action = jest.fn();
|
|
||||||
palette.register('Test', '', action);
|
|
||||||
palette.open();
|
|
||||||
palette.executeSelected();
|
|
||||||
expect(action).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('highlights matching text', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
const result = palette.highlightMatch('Save File', 'save');
|
|
||||||
expect(result).toContain('<strong>');
|
|
||||||
expect(result).toContain('Save');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('highlightMatch returns original text when no query', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
const result = palette.highlightMatch('Save File', '');
|
|
||||||
expect(result).toBe('Save File');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('filters case-insensitively', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.register('Save File', '', () => {});
|
|
||||||
palette.register('Open File', '', () => {});
|
|
||||||
palette.renderResults('SAVE');
|
|
||||||
const items = document.querySelectorAll('.command-item');
|
|
||||||
expect(items.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows all commands when query is empty', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.register('Cmd1', '', () => {});
|
|
||||||
palette.register('Cmd2', '', () => {});
|
|
||||||
palette.register('Cmd3', '', () => {});
|
|
||||||
palette.renderResults('');
|
|
||||||
const items = document.querySelectorAll('.command-item');
|
|
||||||
expect(items.length).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('displays shortcut when provided', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.register('Save File', 'Ctrl+S', () => {});
|
|
||||||
palette.open();
|
|
||||||
const shortcut = document.querySelector('.command-shortcut');
|
|
||||||
expect(shortcut).not.toBeNull();
|
|
||||||
expect(shortcut.textContent).toBe('Ctrl+S');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not display shortcut when empty', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.register('Test Command', '', () => {});
|
|
||||||
palette.open();
|
|
||||||
const shortcut = document.querySelector('.command-shortcut');
|
|
||||||
expect(shortcut).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('closes on overlay click', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.open();
|
|
||||||
// Simulate click on the overlay itself
|
|
||||||
const event = new Event('click', { bubbles: true });
|
|
||||||
Object.defineProperty(event, 'target', { value: palette.overlay });
|
|
||||||
palette.overlay.dispatchEvent(event);
|
|
||||||
expect(palette.isOpen()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('executeSelected does nothing when no commands', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.open();
|
|
||||||
// Should not throw
|
|
||||||
expect(() => palette.executeSelected()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('closes after executing command', () => {
|
|
||||||
const palette = new CommandPalette();
|
|
||||||
palette.register('Test', '', jest.fn());
|
|
||||||
palette.open();
|
|
||||||
palette.executeSelected();
|
|
||||||
expect(palette.isOpen()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Minimap } from '@/components/editor/Minimap';
|
||||||
|
|
||||||
|
describe('Minimap', () => {
|
||||||
|
it('renders lines of content as shrunk text', () => {
|
||||||
|
const content = 'line 1\nline 2\nline 3';
|
||||||
|
render(<Minimap content={content} />);
|
||||||
|
const lines = screen.getAllByTestId('minimap-line');
|
||||||
|
expect(lines).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a viewport indicator', () => {
|
||||||
|
render(
|
||||||
|
<Minimap content={'line 1\nline 2\nline 3'} scrollRatio={0.5} visibleRatio={0.5} />
|
||||||
|
);
|
||||||
|
const indicator = screen.getByTestId('minimap-viewport');
|
||||||
|
expect(indicator).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
|
|
||||||
// Mock react-resizable-panels for jsdom environment
|
// Mock react-resizable-panels for jsdom environment
|
||||||
vi.mock('@/components/ui/resizable', () => ({
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
|
ResizablePanelGroup: ({ children, direction, onLayoutChange }: any) => (
|
||||||
<div data-testid="resizable-panel-group" data-direction={direction}>
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Breadcrumb } from '@/components/layout/Breadcrumb';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
describe('Breadcrumb', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: true });
|
||||||
|
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2', dirty: false }]]) } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No file selected" when no tab is active', () => {
|
||||||
|
useFileStore.setState({ activeTabId: null, openTabs: [] } as any);
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('No file selected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows tab title when a tab is active', () => {
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('test.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows heading symbols when breadcrumbSymbols is true', () => {
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('test.md')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/# H1/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show heading symbols when breadcrumbSymbols is false', () => {
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), breadcrumbSymbols: false });
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
expect(screen.getByText('test.md')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/# H1/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits heading symbols to 3', () => {
|
||||||
|
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# H1\n## H2\n### H3\n#### H4', dirty: false }]]) } as any);
|
||||||
|
render(<Breadcrumb />);
|
||||||
|
// Should show H1, H2, H3 but not H4
|
||||||
|
expect(screen.getByText(/# H1/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/# H2/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/# H3/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/# H4/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { AsciiGeneratorDialog } from '@/components/modals/AsciiGeneratorDialog';
|
||||||
|
|
||||||
|
vi.mock('@/lib/figlet', () => ({
|
||||||
|
figletText: vi.fn().mockResolvedValue(' MOCKED ASCII '),
|
||||||
|
FIGLET_FONTS: ['Standard', 'Big', 'Small', 'Banner', 'Doom', 'Slant', 'Block'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AsciiGeneratorDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with an input textarea and font selector', () => {
|
||||||
|
render(<AsciiGeneratorDialog />);
|
||||||
|
expect(screen.getByText(/ascii generator/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('combobox', { name: /font/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: /text/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the ASCII output as the user types', async () => {
|
||||||
|
render(<AsciiGeneratorDialog />);
|
||||||
|
const input = screen.getByRole('textbox', { name: /text/i });
|
||||||
|
await userEvent.type(input, 'hi');
|
||||||
|
const pre = await screen.findByTestId('ascii-output');
|
||||||
|
expect(pre).toHaveTextContent('MOCKED ASCII');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a copy button next to the output', () => {
|
||||||
|
render(<AsciiGeneratorDialog />);
|
||||||
|
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { FindInFilesDialog } from '@/components/modals/FindInFilesDialog';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
search: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('FindInFilesDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
useAppStore.setState({ modal: { kind: null } } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with a query input and toggles', () => {
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
expect(screen.getByText(/find in files/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: /query/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /regex/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /case/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits a search and shows results', async () => {
|
||||||
|
(ipc.file.search as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ filePath: '/project/a.md', line: 5, content: 'matching line' },
|
||||||
|
{ filePath: '/project/b.md', line: 12, content: 'another match' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /search/i }));
|
||||||
|
expect(await screen.findByText('/project/a.md:5')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/another match/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a result calls useFileStore.openFile', async () => {
|
||||||
|
(ipc.file.search as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [{ filePath: '/project/a.md', line: 5, content: 'matching line' }],
|
||||||
|
});
|
||||||
|
const openFileSpy = vi.fn();
|
||||||
|
useFileStore.setState({ openFile: openFileSpy, rootPath: '/project' } as any);
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /query/i }), 'match');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /search/i }));
|
||||||
|
const result = await screen.findByText('/project/a.md:5');
|
||||||
|
await userEvent.click(result);
|
||||||
|
// openFile is called with the filePath; the test verifies it was called
|
||||||
|
expect(openFileSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error banner when search fails', async () => {
|
||||||
|
(ipc.file.search as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'regex invalid' } });
|
||||||
|
render(<FindInFilesDialog />);
|
||||||
|
await userEvent.paste('[invalid', { initialSelectionStart: 0, initialSelectionEnd: 0 });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /search/i }));
|
||||||
|
expect(await screen.findByText(/regex invalid/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { TableGeneratorDialog } from '@/components/modals/TableGeneratorDialog';
|
||||||
|
|
||||||
|
describe('TableGeneratorDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with rows, cols, and header inputs', () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
expect(screen.getByText(/table generator/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /rows/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('spinbutton', { name: /cols/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('checkbox', { name: /header/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a markdown table on rows/cols change', async () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
const rowsInput = screen.getByRole('spinbutton', { name: /rows/i });
|
||||||
|
const colsInput = screen.getByRole('spinbutton', { name: /cols/i });
|
||||||
|
await userEvent.clear(rowsInput);
|
||||||
|
await userEvent.type(rowsInput, '2');
|
||||||
|
await userEvent.clear(colsInput);
|
||||||
|
await userEvent.type(colsInput, '3');
|
||||||
|
// Output should have header + separator + 2 rows = 4 lines for a 3-col table with header
|
||||||
|
const output = screen.getByTestId('table-output');
|
||||||
|
expect(output.textContent).toContain('|');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a copy button next to the output', () => {
|
||||||
|
render(<TableGeneratorDialog />);
|
||||||
|
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,4 +31,27 @@ describe('WelcomeDialog', () => {
|
|||||||
await userEvent.click(screen.getByRole('button', { name: /get started/i }));
|
await userEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
expect(useSettingsStore.getState().welcomeDismissed).toBe(true);
|
expect(useSettingsStore.getState().welcomeDismissed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression guard: Radix Dialog emits a console.warn when the Content's
|
||||||
|
// aria-describedby points at an id that isn't in the DOM. The default
|
||||||
|
// shadcn/ui pattern (custom id="X-desc" on DialogDescription) breaks that
|
||||||
|
// link because Radix's internal descriptionId is auto-generated via useId.
|
||||||
|
// The fix is to drop the override and let Radix manage the id itself.
|
||||||
|
it('does not emit the Radix "Missing Description" warning', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const spy = vi.spyOn(console, 'warn').mockImplementation((...args) => {
|
||||||
|
warnings.push(args.map(String).join(' '));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
render(<WelcomeDialog />);
|
||||||
|
// Effects that fire the warning run in a microtask; one tick is enough.
|
||||||
|
await Promise.resolve();
|
||||||
|
const offending = warnings.filter((w) =>
|
||||||
|
/Missing\s+`?Description`?|aria-describedby=\{undefined\}/.test(w),
|
||||||
|
);
|
||||||
|
expect(offending).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { WordExportDialog } from '@/components/modals/WordExportDialog';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/docx-export', () => ({
|
||||||
|
generateDocx: vi.fn().mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
app: {
|
||||||
|
showSaveDialog: vi.fn(),
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
writeBuffer: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
describe('WordExportDialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||||
|
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
|
||||||
|
(ipc.app.showSaveDialog as any).mockResolvedValue({ ok: true, data: '/out.docx' });
|
||||||
|
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with template selector (standard / custom)', () => {
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
expect(screen.getByText(/export to word/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/test\.md/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submitting calls generateDocx and writes the file', async () => {
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
|
expect(generateDocx).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Exported test.md'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error message on write failure', async () => {
|
||||||
|
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: false, error: { code: 'E', message: 'write failed' } });
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
|
expect(await screen.findByText(/write failed/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to custom template path when "Custom" is selected', async () => {
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), docxCustomTemplatePath: '/my.dotx' });
|
||||||
|
render(<WordExportDialog sourcePath="/test.md" />);
|
||||||
|
expect(screen.getByText(/\/my\.dotx/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
gitStatus: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
describe('GitStatusPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No folder open" when rootPath is null', () => {
|
||||||
|
useFileStore.setState({ rootPath: null } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
expect(screen.getByText(/no folder open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches and shows git status', async () => {
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ filePath: '/project/a.md', status: 'modified' },
|
||||||
|
{ filePath: '/project/b.md', status: 'added' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
expect(await screen.findByText('a.md')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('b.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Working tree clean" when no changes', async () => {
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({ ok: true, data: [] });
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
expect(await screen.findByText(/working tree clean/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state when gitStatus fails', async () => {
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'IPC_ERROR', message: 'Not a git repository' },
|
||||||
|
});
|
||||||
|
useFileStore.setState({ rootPath: '/project' } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
// The helper text appears in a <p> element distinct from the error heading
|
||||||
|
expect(await screen.findByText('Not a git repository, or git not installed.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens file on click', async () => {
|
||||||
|
const openFile = vi.fn();
|
||||||
|
(ipc.file.gitStatus as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: [{ filePath: '/project/a.md', status: 'modified' }],
|
||||||
|
});
|
||||||
|
useFileStore.setState({ rootPath: '/project', openFile } as any);
|
||||||
|
render(<GitStatusPanel />);
|
||||||
|
const row = await screen.findByTestId('git-status-row');
|
||||||
|
await userEvent.click(row);
|
||||||
|
expect(openFile).toHaveBeenCalledWith('/project/a.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
pickFile: vi.fn(),
|
pickFile: vi.fn(),
|
||||||
read: vi.fn(),
|
read: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
|
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { PrintPreview } from '@/components/tools/PrintPreview';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
print: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
describe('PrintPreview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useFileStore.setState({
|
||||||
|
activeTabId: '/test.md',
|
||||||
|
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||||
|
} as any);
|
||||||
|
useEditorStore.setState({
|
||||||
|
buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]),
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the buffer content', () => {
|
||||||
|
render(<PrintPreview onClose={() => {}} />);
|
||||||
|
expect(screen.getByRole('heading', { name: /hi/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Print button calls ipc.print', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PrintPreview onClose={onClose} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /print/i }));
|
||||||
|
expect(ipc.print).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Close button calls onClose', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<PrintPreview onClose={onClose} />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ReplPanel } from '@/components/tools/ReplPanel';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
describe('ReplPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the textarea and preview when replOpen is true', () => {
|
||||||
|
render(<ReplPanel />);
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/repl/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when replOpen is false', () => {
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), replOpen: false });
|
||||||
|
const { container } = render(<ReplPanel />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typing in the textarea updates the preview', async () => {
|
||||||
|
render(<ReplPanel />);
|
||||||
|
const ta = screen.getByRole('textbox');
|
||||||
|
await userEvent.clear(ta);
|
||||||
|
await userEvent.type(ta, '# Hello');
|
||||||
|
// Wait for 300ms debounce to fire
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
expect(screen.getByRole('heading', { name: /hello/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||||
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||||
onChange: vi.fn(() => () => {}),
|
onChange: vi.fn(() => () => {}),
|
||||||
|
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
on: vi.fn(() => () => {}),
|
on: vi.fn(() => () => {}),
|
||||||
@@ -54,6 +55,8 @@ describe('Phase 5 integration', () => {
|
|||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
});
|
});
|
||||||
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
// Also clear the active buffer so breadcrumb symbols won't show stale heading text
|
||||||
|
useEditorStore.getState().buffers.clear();
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
previewVisible: true,
|
previewVisible: true,
|
||||||
@@ -128,12 +131,14 @@ describe('Phase 5 integration', () => {
|
|||||||
],
|
],
|
||||||
activeTabId: '/a.md',
|
activeTabId: '/a.md',
|
||||||
});
|
});
|
||||||
|
// Use a buffer content that won't conflict with tab title in breadcrumb symbols
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/a.md', { id: '/a.md', path: '/a.md', content: '# Hello', dirty: false }], ['/b.md', { id: '/b.md', path: '/b.md', content: '# World', dirty: false }]]), activeId: '/a.md' });
|
||||||
render(<AppShell />);
|
render(<AppShell />);
|
||||||
const aTab = screen.getByText('a.md').closest('[role="tab"]')!;
|
// Use role="tab" to find tabs specifically, avoiding breadcrumb "a.md" text
|
||||||
const bTab = screen.getByText('b.md').closest('[role="tab"]')!;
|
const tabs = screen.getAllByRole('tab');
|
||||||
expect(aTab).toHaveAttribute('aria-current', 'page');
|
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(bTab);
|
fireEvent.click(tabs[1]);
|
||||||
});
|
});
|
||||||
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ vi.mock('@/lib/ipc', () => ({
|
|||||||
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||||
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||||
onChange: vi.fn(() => () => {}),
|
onChange: vi.fn(() => () => {}),
|
||||||
|
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
|
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import App from '@/App';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
app: {
|
||||||
|
getVersion: vi.fn().mockResolvedValue({ ok: true, data: '5.0.0' }),
|
||||||
|
openExternal: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
showSaveDialog: vi.fn().mockResolvedValue({ ok: true, data: '/out.docx' }),
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
read: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
writeBuffer: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
list: vi.fn(),
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
onChange: vi.fn(),
|
||||||
|
search: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
|
gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
|
},
|
||||||
|
menu: { on: vi.fn(() => () => {}) },
|
||||||
|
print: { show: vi.fn().mockResolvedValue({ ok: true }) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Phase 9 tools integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppStore.setState({ modal: { kind: null }, sidebarVisible: true, previewVisible: true, zenMode: false, paneSizes: { sidebar: 20, editor: 50, preview: 30 } } as any);
|
||||||
|
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||||
|
useSettingsStore.setState({ ...useSettingsStore.getInitialState(), welcomeDismissed: true });
|
||||||
|
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||||
|
useEditorStore.setState({ buffers: new Map([['/x.md', { id: '/x.md', path: '/x.md', content: '# hi', dirty: false }]]) } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.ascii opens ascii-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
useCommandStore.getState().dispatch('tools.ascii');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'ascii-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.table opens table-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
useCommandStore.getState().dispatch('tools.table');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'table-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.findInFiles opens find-in-files modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
useCommandStore.getState().dispatch('tools.findInFiles');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'find-in-files' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.exportWord opens export-word modal with active path', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
useCommandStore.getState().dispatch('tools.exportWord');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.repl toggles replOpen setting', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||||
|
useCommandStore.getState().dispatch('tools.repl');
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('view.zenMode toggles zenMode', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
render(<App />);
|
||||||
|
expect(useAppStore.getState().zenMode).toBe(false);
|
||||||
|
useCommandStore.getState().dispatch('view.zenMode');
|
||||||
|
expect(useAppStore.getState().zenMode).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,8 +48,6 @@ describe('Preload Security', () => {
|
|||||||
'process-pdf-operation',
|
'process-pdf-operation',
|
||||||
'get-pdf-page-count',
|
'get-pdf-page-count',
|
||||||
'select-pdf-folder',
|
'select-pdf-folder',
|
||||||
'open-ascii-generator',
|
|
||||||
'open-table-generator',
|
|
||||||
'insert-generated-content',
|
'insert-generated-content',
|
||||||
'save-pasted-image',
|
'save-pasted-image',
|
||||||
'load-template',
|
'load-template',
|
||||||
@@ -91,12 +89,9 @@ describe('Preload Security', () => {
|
|||||||
'undo',
|
'undo',
|
||||||
'redo',
|
'redo',
|
||||||
'adjust-font-size',
|
'adjust-font-size',
|
||||||
'print-preview',
|
|
||||||
'print-preview-styled',
|
|
||||||
'show-export-dialog',
|
'show-export-dialog',
|
||||||
'show-batch-dialog',
|
'show-batch-dialog',
|
||||||
'show-universal-converter-dialog',
|
'show-universal-converter-dialog',
|
||||||
'show-table-generator',
|
|
||||||
'show-pdf-editor-dialog',
|
'show-pdf-editor-dialog',
|
||||||
'show-image-converter',
|
'show-image-converter',
|
||||||
'show-audio-converter',
|
'show-audio-converter',
|
||||||
@@ -119,13 +114,9 @@ describe('Preload Security', () => {
|
|||||||
'pdf-operation-complete',
|
'pdf-operation-complete',
|
||||||
'pdf-operation-error',
|
'pdf-operation-error',
|
||||||
'pdf-operation-progress',
|
'pdf-operation-progress',
|
||||||
'show-ascii-generator-window',
|
|
||||||
'show-ascii-generator',
|
|
||||||
'show-table-generator-window',
|
|
||||||
'open-header-footer-dialog',
|
'open-header-footer-dialog',
|
||||||
'insert-content',
|
'insert-content',
|
||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-command-palette',
|
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel'
|
'toggle-bottom-panel'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* @jest-environment jsdom
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for PrintPreview
|
|
||||||
* Tests print dialog options, open/close, and preview rendering
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('PrintPreview', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock electron require for executePrint
|
|
||||||
jest.mock('electron', () => ({
|
|
||||||
ipcRenderer: { send: jest.fn(), invoke: jest.fn() }
|
|
||||||
}), { virtual: true });
|
|
||||||
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div class="dialog-overlay hidden" id="print-preview-overlay">
|
|
||||||
<button id="print-preview-close"></button>
|
|
||||||
<button id="print-cancel"></button>
|
|
||||||
<button id="print-execute"></button>
|
|
||||||
<select id="print-paper-size"><option value="A4">A4</option><option value="Letter">Letter</option></select>
|
|
||||||
<select id="print-orientation"><option value="portrait">Portrait</option><option value="landscape">Landscape</option></select>
|
|
||||||
<select id="print-margins"><option value="default">Default</option></select>
|
|
||||||
<input type="range" id="print-scale" value="100">
|
|
||||||
<span id="print-scale-value">100%</span>
|
|
||||||
<input type="checkbox" id="print-headers" checked>
|
|
||||||
<input type="checkbox" id="print-background" checked>
|
|
||||||
<select id="print-pages"><option value="all">All</option><option value="custom">Custom</option></select>
|
|
||||||
<input type="text" id="print-page-range" class="hidden">
|
|
||||||
<iframe id="print-preview-frame"></iframe>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getOptions returns default values', () => {
|
|
||||||
const { PrintPreview } = require('../src/print-preview');
|
|
||||||
const preview = new PrintPreview();
|
|
||||||
const options = preview.getOptions();
|
|
||||||
expect(options.paperSize).toBe('A4');
|
|
||||||
expect(options.orientation).toBe('portrait');
|
|
||||||
expect(options.scale).toBe(100);
|
|
||||||
expect(options.headers).toBe(true);
|
|
||||||
expect(options.background).toBe(true);
|
|
||||||
expect(options.pages).toBe('all');
|
|
||||||
expect(options.margins).toBe('default');
|
|
||||||
expect(options.pageRange).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens and closes', () => {
|
|
||||||
const { PrintPreview } = require('../src/print-preview');
|
|
||||||
const preview = new PrintPreview();
|
|
||||||
preview.open('<p>Test</p>');
|
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(false);
|
|
||||||
preview.close();
|
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateScaleLabel reflects slider value', () => {
|
|
||||||
const { PrintPreview } = require('../src/print-preview');
|
|
||||||
const preview = new PrintPreview();
|
|
||||||
document.getElementById('print-scale').value = '75';
|
|
||||||
preview.updateScaleLabel();
|
|
||||||
expect(document.getElementById('print-scale-value').textContent).toBe('75%');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('close button closes preview', () => {
|
|
||||||
const { PrintPreview } = require('../src/print-preview');
|
|
||||||
const preview = new PrintPreview();
|
|
||||||
preview.open('<p>Test</p>');
|
|
||||||
document.getElementById('print-preview-close').click();
|
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cancel button closes preview', () => {
|
|
||||||
const { PrintPreview } = require('../src/print-preview');
|
|
||||||
const preview = new PrintPreview();
|
|
||||||
preview.open('<p>Test</p>');
|
|
||||||
document.getElementById('print-cancel').click();
|
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getOptions reflects changed values', () => {
|
|
||||||
const { PrintPreview } = require('../src/print-preview');
|
|
||||||
const preview = new PrintPreview();
|
|
||||||
|
|
||||||
// Change paper size to Letter
|
|
||||||
const paperSelect = document.getElementById('print-paper-size');
|
|
||||||
paperSelect.value = 'Letter';
|
|
||||||
|
|
||||||
// Change scale
|
|
||||||
document.getElementById('print-scale').value = '50';
|
|
||||||
|
|
||||||
// Uncheck headers
|
|
||||||
document.getElementById('print-headers').checked = false;
|
|
||||||
|
|
||||||
const options = preview.getOptions();
|
|
||||||
expect(options.paperSize).toBe('Letter');
|
|
||||||
expect(options.scale).toBe(50);
|
|
||||||
expect(options.headers).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stores last content for refresh', () => {
|
|
||||||
const { PrintPreview } = require('../src/print-preview');
|
|
||||||
const preview = new PrintPreview();
|
|
||||||
preview.open('<p>Hello World</p>');
|
|
||||||
expect(preview._lastContent).toBe('<p>Hello World</p>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { registerMenuCommands } from '@/lib/commands/register-menu-commands';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
|
describe('Phase 9 commands', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
|
||||||
|
useAppStore.setState({ modal: { kind: null } } as any);
|
||||||
|
useFileStore.setState({ activeTabId: '/x.md', openTabs: [{ id: '/x.md', path: '/x.md', title: 'x.md', dirty: false }] } as any);
|
||||||
|
// Use resetToDefaults to restore store methods (setSetting/resetToDefaults)
|
||||||
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.ascii opens ascii-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.ascii');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'ascii-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.table opens table-generator modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.table');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'table-generator' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.findInFiles opens find-in-files modal', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.findInFiles');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'find-in-files' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.exportWord opens export-word modal with active path', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
useCommandStore.getState().dispatch('tools.exportWord');
|
||||||
|
expect(useAppStore.getState().modal).toEqual({ kind: 'export-word', props: { sourcePath: '/x.md' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tools.repl toggles replOpen setting', () => {
|
||||||
|
registerMenuCommands();
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||||
|
useCommandStore.getState().dispatch('tools.repl');
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(true);
|
||||||
|
useCommandStore.getState().dispatch('tools.repl');
|
||||||
|
expect(useSettingsStore.getState().replOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useZenMode } from '@/hooks/use-zen-mode';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('useZenMode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches a keydown listener that exits zen mode on Escape', () => {
|
||||||
|
renderHook(() => useZenMode());
|
||||||
|
expect(useAppStore.getState().zenMode).toBe(true);
|
||||||
|
|
||||||
|
// Simulate Escape keydown
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(useAppStore.getState().zenMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if zen mode is already off', () => {
|
||||||
|
useAppStore.setState({ ...useAppStore.getInitialState(), zenMode: false });
|
||||||
|
const setZenMode = vi.fn();
|
||||||
|
useAppStore.setState({ setZenMode });
|
||||||
|
renderHook(() => useZenMode());
|
||||||
|
// No Escape dispatched; setZenMode should not be called
|
||||||
|
expect(setZenMode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateDocx } from '@/lib/docx-export';
|
||||||
|
|
||||||
|
describe('generateDocx', () => {
|
||||||
|
it('returns a Blob for a simple markdown string', async () => {
|
||||||
|
const blob = await generateDocx({ source: '# Hello\n\nWorld' });
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts headings to docx heading styles', async () => {
|
||||||
|
const blob = await generateDocx({ source: '# H1\n## H2\n### H3' });
|
||||||
|
// The blob should be a valid zip-based docx; check size and MIME
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts markdown tables to preformatted (via applyAsciiTransform)', async () => {
|
||||||
|
const source = '| A | B |\n| - | - |\n| 1 | 2 |';
|
||||||
|
const blob = await generateDocx({ source });
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user