mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6a55dd09 | ||
|
|
0e80e37d9d | ||
|
|
9a7e994224 | ||
|
|
3effacf816 | ||
|
|
1be10f9ae5 | ||
|
|
4c0b7d6e84 | ||
|
|
db04ea5854 | ||
|
|
567726022c | ||
|
|
1999a7716e | ||
|
|
16e071ae5e | ||
|
|
7ecf54ceb0 | ||
|
|
95e2f45f1f | ||
|
|
7fbbd60525 | ||
|
|
c715bb354c | ||
|
|
6525de7667 | ||
|
|
a2c1d0c11a | ||
|
|
e0d6315204 | ||
|
|
68ff7d076a | ||
|
|
67b57eae78 | ||
|
|
301c51ac84 | ||
|
|
904ffbdd5b | ||
|
|
ea383783ec | ||
|
|
e758a81c4c | ||
|
|
ea0f3f93bf | ||
|
|
188e73d7c1 | ||
|
|
8265e4ff88 | ||
|
|
8a8671b128 | ||
|
|
3646dc29c6 | ||
|
|
a999f598cc | ||
|
|
7a396f64a6 | ||
|
|
88e9a5290d | ||
|
|
fff15d8d3e | ||
|
|
c574d77c20 | ||
|
|
272215f9af | ||
|
|
0192590567 |
@@ -1,103 +0,0 @@
|
|||||||
# CLAUDE.md — MarkdownConverter (master)
|
|
||||||
|
|
||||||
> General code-quality, JavaScript, git, security, and testing standards are in the **global CLAUDE.md**. This file holds project- and branch-specific notes.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Electron desktop app for Markdown editing and universal file conversion powered by Pandoc. Cross-platform (Win/macOS/Linux). Features: multi-tab editor with live preview, 25+ themes, PDF viewer/editor (merge/split/compress/rotate/watermark/password), export to 20+ formats (PDF/DOCX/ODT/EPUB/HTML/LaTeX/RTF/PPTX), batch conversion, syntax highlighting, diagram support (Mermaid), Git integration, and a plugin system.
|
|
||||||
|
|
||||||
- **Version:** 4.4.5
|
|
||||||
- **License:** MIT
|
|
||||||
- **App ID:** `com.concreteinfo.markdownconverter`
|
|
||||||
|
|
||||||
## Branch Specifics
|
|
||||||
|
|
||||||
This is the **primary/release branch** — a vanilla JavaScript Electron app with no bundler or framework in the renderer. The renderer is a single large `renderer.js` (5,300+ lines) loaded directly via `src/index.html`. All UI is hand-rolled DOM manipulation.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Main Process (`src/main.js` — 4,260 lines)
|
|
||||||
Monolithic main process file. Contains all IPC handlers, Pandoc invocation, file operations, menu definitions (600+ lines), and window lifecycle. Key modules extracted:
|
|
||||||
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib` (merge, split, compress, rotate, delete, reorder, watermark, encrypt, decrypt, permissions)
|
|
||||||
- `src/main/GitOperations.js` — Git status/stage/commit/log via `simple-git`
|
|
||||||
|
|
||||||
### Renderer (`src/renderer.js` — 5,361 lines)
|
|
||||||
Vanilla JS, no framework. Directly manipulates DOM. Loads CodeMirror 6 via `src/editor/codemirror-setup.js`. Uses `marked` + `highlight.js` + `DOMPurify` + `mermaid` for rendering. Lazy-loads sidebar panels, REPL, command palette, zen mode.
|
|
||||||
|
|
||||||
### Preload (`src/preload.js` — 448 lines)
|
|
||||||
Exists as IPC bridge, but **`contextIsolation: false` and `nodeIntegration: true`** — the renderer has full Node access. Preload is effectively a thin passthrough.
|
|
||||||
|
|
||||||
### Security Model
|
|
||||||
- `contextIsolation: false` + `nodeIntegration: true` (legacy; the react-electron branch fixes this)
|
|
||||||
- Pandoc invoked via `execFile` (not `exec`) to prevent shell injection
|
|
||||||
- Path traversal protection: `validatePath()`, `resolveWritablePath()`, blocks sensitive system dirs
|
|
||||||
- Permission handler only allows `clipboard-read`/`clipboard-write`
|
|
||||||
- Rate limiter on conversions (2-second minimum interval)
|
|
||||||
- File size limit: 50MB
|
|
||||||
- Error message sanitization strips absolute paths
|
|
||||||
|
|
||||||
### Plugin System (`src/plugins/`)
|
|
||||||
Manifest-based discovery (`manifest.json`). Built-in `writing-studio` plugin with sprint/goal/snapshot management. Plugin API exposed via `src/plugins/plugin-api.js`.
|
|
||||||
|
|
||||||
### Settings
|
|
||||||
Custom JSON file store at `<userData>/settings.json` (NOT `electron-store` despite the dependency). Recent files at `<userData>/recent-files.json`.
|
|
||||||
|
|
||||||
## System Dependencies
|
|
||||||
|
|
||||||
| Dependency | Required | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x |
|
|
||||||
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. Must be present for DOCX/ODT/EPUB/LaTeX/PPTX export. |
|
|
||||||
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` for packaged builds |
|
|
||||||
| **MiKTeX / TeX Live** | Optional | For LaTeX PDF export; MiKTeX PATH injected on Windows automatically |
|
|
||||||
| **ImageMagick** | Optional | Linux image conversion; listed as deb dependency |
|
|
||||||
| **LibreOffice** | Optional | Enhanced document conversion; listed as deb dependency |
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start # Launch Electron app (dev mode)
|
|
||||||
npm test # Jest test suite
|
|
||||||
npm test:watch # Jest in watch mode
|
|
||||||
npm test:coverage # Jest with coverage report
|
|
||||||
npm run lint # ESLint check (src + tests)
|
|
||||||
npm run lint:fix # ESLint auto-fix
|
|
||||||
npm run format # Prettier write
|
|
||||||
npm run format:check # Prettier check only
|
|
||||||
npm run download-tools # Download Pandoc binaries to bin/
|
|
||||||
npm run generate-icons # Generate app icons via sharp
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build & Package
|
|
||||||
|
|
||||||
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json` (no separate config file).
|
|
||||||
|
|
||||||
| Target | Platforms |
|
|
||||||
|---|---|
|
|
||||||
| `npm run build` | electron-builder (default platform) |
|
|
||||||
| `npm run build:win` | Windows: NSIS installer + portable + zip (x64) |
|
|
||||||
| `npm run build:mac` | macOS: default dmg |
|
|
||||||
| `npm run build:linux` | Linux: deb + AppImage + snap |
|
|
||||||
| `npm run dist` | Build without publish |
|
|
||||||
| `npm run dist:all` | Build for all platforms |
|
|
||||||
|
|
||||||
**Bundled with builds:** Pandoc binary per platform. FFmpeg via `ffmpeg-static` (asarUnpacked). NSIS installer uses custom script at `scripts/nsis-installer.nsh`.
|
|
||||||
|
|
||||||
**Output:** `dist/` directory.
|
|
||||||
|
|
||||||
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
|
|
||||||
|
|
||||||
## Project Conventions / Gotchas
|
|
||||||
|
|
||||||
- **No bundler/transpilation.** The app uses vanilla CommonJS JavaScript. `src/main.js` is loaded directly by Electron. No webpack, no Vite, no TypeScript, no Babel.
|
|
||||||
- **Monolithic files.** `main.js` (4,260 lines) and `renderer.js` (5,361 lines) contain most logic. Not ideal but is the current state of this branch.
|
|
||||||
- **CodeMirror 6** for the editor, configured in `src/editor/codemirror-setup.js`.
|
|
||||||
- **PDF rendering** uses `pdfjs-dist`; **PDF manipulation** uses `pdf-lib` in the main process.
|
|
||||||
- **Renderer security is weak** — full Node access in renderer. Do NOT introduce new privileged renderer code without understanding this.
|
|
||||||
- **Pandoc is external.** Must be installed separately or downloaded via `npm run download-tools`. HTML and built-in PDF export work without Pandoc; other formats require it.
|
|
||||||
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
|
|
||||||
- **ESLint flat config** (`eslint.config.js`) with ECMAScript 2022. Prettier with 2-space indent, single quotes, semicolons, 100-char width.
|
|
||||||
- **Tests:** Jest with jsdom environment, 15% coverage threshold. 24 test files in `tests/`.
|
|
||||||
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
|
|
||||||
- **Single instance lock** enforced via `app.requestSingleInstanceLock()`.
|
|
||||||
- **Adapters layer** (`src/adapters/`) abstracts file system operations for potential future non-Electron targets.
|
|
||||||
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
v4.4.5
|
v4.1.0
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/renderer/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
|||||||
|
# MarkdownConverter — React + shadcn/ui UI Redesign
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Design (awaiting user approval)
|
||||||
|
**Branch:** `react-electron`
|
||||||
|
**Author:** Brainstormed with user via superpowers:brainstorming
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the legacy vanilla JS renderer (`src/renderer.js` is 213 KB; `src/styles.css` is 74 KB) with a modern React 19 + Vite + TypeScript + shadcn/ui renderer that achieves visual feature parity while delivering a Polished + Glassy (Raycast/Arc-style) aesthetic. The Electron main process, preload bridge, and IPC contracts stay unchanged. Work proceeds via vertical slices — each PR ships a demoable feature.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Replace every render-time feature of the legacy renderer with an idiomatic React + shadcn/ui implementation.
|
||||||
|
2. Adopt the "Polished + Glassy" visual language (subtle shadows, 8–12 px radii, gradient accents, gentle depth) on top of the existing ConcreteInfo brand tokens and design system.
|
||||||
|
3. Use a single component foundation (shadcn/ui) and a single motion library (Motion / Framer Motion) to keep the dependency surface narrow and the design coherent.
|
||||||
|
4. Keep the main process, preload, and IPC contracts untouched. The renderer is the only surface being rewritten.
|
||||||
|
5. Keep the app runnable at every commit. No "big bang" merge.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Migrating the main process to TypeScript (out of scope for this spec).
|
||||||
|
- Adding new features the legacy renderer does not have (plugin system, AI features, etc. — those are future specs).
|
||||||
|
- Replacing Pandoc/FFmpeg/ImageMagick orchestration.
|
||||||
|
- Changing the packaging pipeline (electron-builder config stays).
|
||||||
|
- Replacing the CodeMirror 6 editor — it is the right tool and is already wired up.
|
||||||
|
|
||||||
|
## Decisions Locked During Brainstorming
|
||||||
|
|
||||||
|
| Decision | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Scope | Full feature parity with legacy renderer | User-selected option. Renderer is one cohesive surface; splitting it across specs would force premature contracts. |
|
||||||
|
| Visual style | **B — Polished + Glassy** (Raycast/Arc aesthetic) | "Fancy" comes from elevation, gradient accents, and material depth — not over-designed visuals. |
|
||||||
|
| Layout | **2 — IDE-style** (equal editor/preview, draggable divider, collapsible sidebar) | Power-user markdown apps converge on this. Draggable divider + keyboard reset. |
|
||||||
|
| Modal patterns | **A (Centered Dialog), B (Right Side-Sheet), D (Toasts)** | No command palette. |
|
||||||
|
| Command palette | **No** — full menus, no ⌘K | Power users can use the OS-level launcher. App stays focused on writing. |
|
||||||
|
| Animation | **Motion (Framer Motion)** | Best for layout transitions, modal/drawer enter/exit, drag. Sparingly used. |
|
||||||
|
| Component library | **shadcn/ui** | Tailwind + Radix primitives, copy-paste ownership, perfect fit for the "glassy" aesthetic and the HSL-CSS-variable foundation that is already in place. |
|
||||||
|
| Implementation strategy | **Vertical slices** — one feature end-to-end per PR | App stays runnable. Each PR is reviewable. |
|
||||||
|
|
||||||
|
## Defaults (Used Unless Overridden Later)
|
||||||
|
|
||||||
|
| Item | Default | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| State management | Zustand (already in deps) + Immer for nested patches | Already in `package.json`; perfect for editor state. |
|
||||||
|
| Theming | shadcn `next-themes`, dark default + light, system-aware | shadcn canonical pattern. Brand colors already in CSS vars. |
|
||||||
|
| Icons | `lucide-react` (already in deps) | 1000+ tree-shakable icons. |
|
||||||
|
| Forms | `react-hook-form` + `zod` | Standard for shadcn forms. |
|
||||||
|
| Drag/drop (sortable lists) | `@dnd-kit/core` | De facto React drag lib (file tree reordering, plugin list reorder). |
|
||||||
|
| Pane resize (split layout) | `react-resizable-panels` | Canonical React lib for resizable pane groups; handles drag, arrow keys, snap, persisted sizes. |
|
||||||
|
| Testing | Vitest + RTL + Playwright (E2E + visual regression) | Standard for React + Electron. |
|
||||||
|
| TypeScript | Strict mode (already wired) | Already in deps and `vite.renderer.config.ts`. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The Electron app keeps its existing process model. Only the renderer is rewritten.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Electron Main (UNCHANGED) │
|
||||||
|
│ - BrowserWindow, IPC handlers, file/fs ops │
|
||||||
|
│ - Pandoc, FFmpeg, ImageMagick orchestration │
|
||||||
|
└────────────┬─────────────────────────────────────────────┘
|
||||||
|
│ contextBridge (UNCHANGED preload.js)
|
||||||
|
┌────────────▼─────────────────────────────────────────────┐
|
||||||
|
│ React Renderer (REWRITTEN) │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AppShell (layout) │ │
|
||||||
|
│ │ ├─ MenuBar (native) │ │
|
||||||
|
│ │ ├─ AppHeader (logo, breadcrumbs, theme toggle) │ │
|
||||||
|
│ │ ├─ TabBar (open files) │ │
|
||||||
|
│ │ ├─ Toolbar (formatting) │ │
|
||||||
|
│ │ ├─ ResizablePaneGroup (sidebar | editor | preview) │ │
|
||||||
|
│ │ │ ├─ Sidebar (file tree, outline) │ │
|
||||||
|
│ │ │ ├─ EditorPane (CodeMirror 6) │ │
|
||||||
|
│ │ │ └─ PreviewPane (marked + KaTeX + Mermaid) │ │
|
||||||
|
│ │ ├─ StatusBar (word count, encoding, cursor pos) │ │
|
||||||
|
│ │ └─ ModalLayer (Dialog, SideSheet, Toaster) │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Feature Modules (each owns UI + state slice) │ │
|
||||||
|
│ │ ├─ editor/ CodeMirror wrapper, syntax, themes │ │
|
||||||
|
│ │ ├─ preview/ markdown→html, KaTeX, Mermaid │ │
|
||||||
|
│ │ ├─ tabs/ open files, dirty state │ │
|
||||||
|
│ │ ├─ sidebar/ file tree, outline, search results │ │
|
||||||
|
│ │ ├─ modals/ export, settings, about, etc. │ │
|
||||||
|
│ │ ├─ tools/ zen, repl, ascii-gen, table-gen │ │
|
||||||
|
│ │ └─ export/ pdf, docx, html, image batch │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Shared Infrastructure │ │
|
||||||
|
│ │ ├─ stores/ Zustand slices per feature │ │
|
||||||
|
│ │ ├─ hooks/ useFile, useEditor, useTheme, etc. │ │
|
||||||
|
│ │ ├─ lib/ cn, ipc, formatters, validators │ │
|
||||||
|
│ │ ├─ ui/ shadcn primitives: button, dialog… │ │
|
||||||
|
│ │ └─ types/ shared TS types │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architectural Rules
|
||||||
|
|
||||||
|
- **Shell components** (`AppHeader`, `TabBar`, `StatusBar`) subscribe only to `useAppStore`.
|
||||||
|
- **Feature components** subscribe to their own feature's store.
|
||||||
|
- **Cross-feature access** goes through hooks, not direct store imports (e.g., `useFileTree()` wraps `useFileStore`).
|
||||||
|
- **Feature modules are self-contained** — each owns its components, its Zustand slice, and its types. Safe to develop in parallel later.
|
||||||
|
- **No direct `window.electronAPI` calls** from feature code. All IPC goes through `lib/ipc.ts` for type safety and error normalization.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
```
|
||||||
|
stores/
|
||||||
|
├─ useAppStore // global UI: theme, sidebar, pane sizes, modals open
|
||||||
|
├─ useFileStore // file system: tree, open files, active tab
|
||||||
|
├─ useEditorStore // editor: per-file content, cursor, selection, dirty
|
||||||
|
├─ usePreviewStore // preview: scroll sync, zoom, theme
|
||||||
|
├─ useSettingsStore // user prefs (persisted to electron-store)
|
||||||
|
└─ useCommandStore // menu actions registry, recent actions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why slices instead of one mega-store?** Editor content can be megabytes. Keeping it in its own slice lets React avoid re-rendering the preview when only the editor changes — components subscribe with selectors.
|
||||||
|
|
||||||
|
**Persistent state** (auto-saved to electron-store via Zustand `persist` middleware): theme, sidebar visibility, last open files, pane divider positions, settings.
|
||||||
|
|
||||||
|
**Ephemeral state** (lost on quit): active modal, hover states, search query, current file content (saved to disk on idle).
|
||||||
|
|
||||||
|
## Visual Design System
|
||||||
|
|
||||||
|
### Color Tokens
|
||||||
|
|
||||||
|
The existing `globals.css` HSL variables stay. Additions for the "glassy" aesthetic:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
```
|
||||||
|
|
||||||
|
### shadcn Components
|
||||||
|
|
||||||
|
Install via `npx shadcn@latest add`:
|
||||||
|
|
||||||
|
**Primitives** (need all of these): `button`, `dialog`, `sheet`, `popover`, `tooltip`, `select`, `dropdown-menu`, `tabs`, `separator`, `scroll-area`, `toggle`, `switch`, `slider`, `input`, `textarea`, `label`, `form`, `skeleton`, `sonner`, `command`.
|
||||||
|
|
||||||
|
**Composite** (custom-built on top of primitives): `file-tree`, `pane-group`, `divider`, `status-bar`, `menu-bar`, `toast`.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Body: Plus Jakarta Sans 15 px / line-height 1.6
|
||||||
|
- Code: JetBrains Mono 13.5 px
|
||||||
|
- Display: Barlow Condensed 700/800 — used sparingly (page titles, big metrics)
|
||||||
|
- Headings: Plus Jakarta Sans 600/700 with tight letter-spacing
|
||||||
|
- Labels (`.label`): Plus Jakarta Sans 500, 12 px, uppercase, 0.05 em letter-spacing
|
||||||
|
|
||||||
|
### Motion Choreography
|
||||||
|
|
||||||
|
| Element | Motion | Duration | Easing |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Modal (Dialog A) | Scale 0.96→1, opacity 0→1 | 200 ms | `ease-out` |
|
||||||
|
| Side sheet (B) | TranslateX 100%→0, opacity 0→1 | 300 ms | `cubic-bezier(0.16, 1, 0.3, 1)` |
|
||||||
|
| Toast | TranslateY 100%→0, opacity | 250 ms | spring(stiffness: 300, damping: 30) |
|
||||||
|
| Sidebar toggle | Width 264 px↔72 px, content fade | 250 ms | `ease-in-out` |
|
||||||
|
| Divider drag | Live width update | 0 ms | none (instant) |
|
||||||
|
| Theme switch | CSS variables, opacity overlay | 300 ms | `ease-in-out` |
|
||||||
|
| Tab switch | Underline slide | 200 ms | `ease-out` |
|
||||||
|
| Hover (buttons) | `bg` + `shadow` shift | 150 ms | `ease-out` |
|
||||||
|
| Focus ring | Outline grow | 100 ms | `ease-out` |
|
||||||
|
|
||||||
|
**Rules:** Every motion has a purpose. No gratuitous animation. Respects `prefers-reduced-motion` (Motion handles this automatically).
|
||||||
|
|
||||||
|
### Empty / Loading / Error States
|
||||||
|
|
||||||
|
Every async surface ships all three. Sonner toasts for ephemeral feedback. Skeleton screens for content areas. Empty-state components with icon + message + primary CTA.
|
||||||
|
|
||||||
|
## Modal/Overlay Patterns
|
||||||
|
|
||||||
|
| Use case | Pattern | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Export (PDF/DOCX/HTML) | A — Centered Dialog | Focused decision, ~3–5 fields, "do one thing" |
|
||||||
|
| Settings | B — Right Side-Sheet | Long form, tabbed sections, stays open while editing |
|
||||||
|
| Find/Replace | Inline toolbar (not modal) | Always-visible utility |
|
||||||
|
| About | A — Centered Dialog | Tiny info dump |
|
||||||
|
| Confirm destructive (delete, close unsaved) | A — Centered Dialog | Forces attention |
|
||||||
|
| File save error | D — Toast | Non-blocking info |
|
||||||
|
| Pandoc/FFmpeg progress | D — Toast → sticky on >3 s | Background work feedback |
|
||||||
|
| Plugin manager | B — Right Side-Sheet | Lists + per-item actions |
|
||||||
|
| ASCII/Table generators | A — Centered Dialog | Tool-style focused input → output |
|
||||||
|
| Print preview | A — Centered Dialog | Full-screen-ish modal overlay |
|
||||||
|
| Welcome (first launch) | A — Centered Dialog | One-time onboarding |
|
||||||
|
| Word export | A — Centered Dialog | Template selection |
|
||||||
|
| Zen mode | Full-viewport toggle (no modal) | Replaces the editor entirely |
|
||||||
|
| REPL | Bottom-pinned panel (split-pane) | Persistent terminal-like UI |
|
||||||
|
| Quick file open | B — Right Side-Sheet | File tree, search, recent |
|
||||||
|
|
||||||
|
### Dialog A Anatomy (Export as example)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Backdrop (rgba(13,11,9,0.45) + backdrop-blur 4px) ─────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌──── Modal (max-w-md, rounded-2xl, shadow-lg) ─────┐ │
|
||||||
|
│ │ ┌── Header ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Icon] Export as PDF [×] │ │ │
|
||||||
|
│ │ │ Choose format options │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Body ──────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Format: [Letter] [A4] [Legal] │ │ │
|
||||||
|
│ │ │ Margins: ────●──── │ │ │
|
||||||
|
│ │ │ ☐ Include table of contents │ │ │
|
||||||
|
│ │ │ ☐ Embed fonts │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Footer ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Cancel] [Export →] │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Side-Sheet B Anatomy (Settings as example)
|
||||||
|
|
||||||
|
Slides in from right, ~50 % width (max 560 px). Backdrop is `rgba(13,11,9,0.2)` with `backdrop-blur(2 px)` (lighter than dialog — the sheet is the focus). Tab navigation in the sheet header (Editor / Theme / Export / Plugins / About).
|
||||||
|
|
||||||
|
### Toast D Anatomy (Sonner)
|
||||||
|
|
||||||
|
Stacked bottom-right, max 3 visible. Glass background. Color-coded 3 px left border — success `#1a7a56`, error `#ef4444`, info `#0ea5e9`, warning `#eab308`. Icon + title + description. Optional action button. Auto-dismiss 4 s for success, sticky for error.
|
||||||
|
|
||||||
|
## Data Flow (Editor → Preview Pipeline)
|
||||||
|
|
||||||
|
```
|
||||||
|
User types
|
||||||
|
↓
|
||||||
|
CodeMirror onChange
|
||||||
|
↓
|
||||||
|
useEditorStore.updateContent(tabId, content) ← debounced 50 ms
|
||||||
|
↓
|
||||||
|
┌─ Persist to electron-store on idle (1 s) ─┐
|
||||||
|
│ │
|
||||||
|
└─ Publish to usePreviewStore (subscribed) ─┘
|
||||||
|
↓
|
||||||
|
marked(content) → sanitized HTML
|
||||||
|
↓
|
||||||
|
PreviewPane renders HTML
|
||||||
|
KaTeX post-processes $...$
|
||||||
|
Mermaid post-processes ```mermaid
|
||||||
|
Highlight.js post-processes ```lang
|
||||||
|
↓
|
||||||
|
useEffect: scroll-sync editor cursor → preview position
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bidirectional scroll sync:** editor scroll → preview (primary), preview click → editor cursor (secondary). Throttled to 60 fps via `requestAnimationFrame`.
|
||||||
|
|
||||||
|
## IPC Contract
|
||||||
|
|
||||||
|
The preload bridge stays unchanged. A new `src/renderer/lib/ipc.ts` wraps every channel with TypeScript types and normalizes errors to a discriminated union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult>>,
|
||||||
|
read: (path: string): Promise<IpcResult<string>>,
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void>>,
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[]>>,
|
||||||
|
onChange: (cb: (path: string) => void) => () => void,
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult>>,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string>>,
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void>>,
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void>>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Layered defense — no single failure can crash the app:
|
||||||
|
|
||||||
|
1. **IPC errors** → caught in `lib/ipc.ts` wrapper, returned as `IpcResult<T>` discriminated union. Components show toast on error.
|
||||||
|
2. **Component errors** → React error boundary per feature area (one for editor, one for preview, one for modals). On error: show inline error UI with "Reload this panel" + "Copy details" actions.
|
||||||
|
3. **Async operations** (export, file ops) → loading state on button + toast on completion. Long ops (>2 s) get a sticky toast with cancel.
|
||||||
|
4. **Validation errors** (settings, export options) → inline form errors via `react-hook-form` + `zod`. shadcn `Form` component for consistent error display.
|
||||||
|
5. **Pandoc/FFmpeg missing** → detected at startup, banner + disable export menu items. Don't surprise-fail at export time.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit (Vitest):** stores, hooks, lib utilities, formatters, validators
|
||||||
|
- **Component (Vitest + RTL):** shadcn wrappers, dialog interactions, sidebar toggle, divider drag, theme toggle
|
||||||
|
- **Integration (Vitest + RTL):** editor ↔ preview flow, file open → tab add → content display, settings change persists
|
||||||
|
- **E2E (Playwright):** app launches, file opens, markdown renders, export to PDF works
|
||||||
|
- **Visual regression (Playwright snapshots):** locked-in screenshots for header, sidebar, dialogs, toasts — catch accidental style drift
|
||||||
|
- **Target coverage:** stores/hooks/lib ≥ 90 %, components ≥ 75 %, E2E covers all critical paths
|
||||||
|
|
||||||
|
## Accessibility (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
Baked in via shadcn + Radix:
|
||||||
|
|
||||||
|
- All dialogs focus-trap, Esc to close, return focus to trigger
|
||||||
|
- All interactive elements keyboard-reachable
|
||||||
|
- Visible focus rings (2 px brand ring)
|
||||||
|
- 4.5:1 contrast minimum (already met by brand colors)
|
||||||
|
- `aria-label` on icon buttons, `role="alert"` on toasts
|
||||||
|
- Respects `prefers-reduced-motion` (Motion handles this automatically)
|
||||||
|
|
||||||
|
## Implementation Phases (Vertical Slices)
|
||||||
|
|
||||||
|
Each phase is a shippable PR that keeps the app runnable. The legacy `renderer.js` is gradually replaced; until each phase is complete, the old renderer code still runs the missing parts.
|
||||||
|
|
||||||
|
| # | Phase | Output | Acceptance |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Foundation** | shadcn installed; `next-themes`, `motion`, `react-hook-form`, `zod`, `@dnd-kit/core`, `react-resizable-panels`, `sonner` installed; design tokens finalized; `lib/utils.ts` (`cn`), `lib/ipc.ts` typed wrappers, `lib/motion.ts` preset transitions, `App.tsx` shell skeleton renders | `npm run build` succeeds; dev server shows the new shell with theme toggle working |
|
||||||
|
| 2 | **App shell + layout** | `AppHeader`, `TabBar`, `Toolbar`, `Breadcrumb`, `StatusBar`, `ResizablePaneGroup` with sidebar toggle and draggable divider. Empty editor/preview panes. | Resize divider with mouse and arrow keys; sidebar collapses; pane sizes persist. |
|
||||||
|
| 3 | **Editor pane** | CodeMirror 6 wrapped, dark/light themes wired to shadcn theme, syntax highlighting, line numbers, search, autocomplete | Open `.md` file → renders in editor; can edit and save. |
|
||||||
|
| 4 | **Preview pane** | marked + DOMPurify + KaTeX + Mermaid + highlight.js. Bidirectional scroll sync with editor. | Open file → preview renders, follows cursor. |
|
||||||
|
| 5 | **File tree + tabs** | Sidebar file tree (read directory on demand, lazy-expand children only on click). Tabs for open files. Dirty state indicator. File content is read from disk only when the tab is activated. | Open folder → root populates; click folder → children load; click file → opens in tab; close tab reverts dirty. |
|
||||||
|
| 6 | **Native menus + toolbar** | Replace the legacy `CommandPalette` with full menus (File / Edit / View / Insert / Format / Tools / Help) bound to keyboard shortcuts. Toolbar buttons. | All menu items invoke the right action; shortcuts work; toolbar reflects active state. |
|
||||||
|
| 7 | **Modals** | Export dialog (PDF/DOCX/HTML/batch), Settings side-sheet (Editor/Theme/Export/Plugins/About tabs), About dialog, Confirm-destructive dialog | All dialogs and sheet render with proper motion; settings persist; export works end-to-end. |
|
||||||
|
| 8 | **Toasts** | Sonner wired into all async operations (save, export, errors, tool missing) | All operations give appropriate feedback; no silent failures. |
|
||||||
|
| 9 | **Advanced tools** | Zen mode (full-viewport toggle), REPL (bottom-pinned panel), ASCII generator, Table generator, Word export template picker, Print preview | Each tool is feature-complete vs. legacy version. |
|
||||||
|
| 10 | **Polish + delete legacy** | Visual regression snapshots locked; remove `renderer.js` and old `styles*.css` references from build; `npm run build:linux` produces a working installer | Final PR ships an installer with no legacy code paths. |
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| shadcn CLI requires Vite (not vanilla Electron renderer) | Vite is already configured for the renderer in `vite.renderer.config.ts`; the CLI works against the same project. |
|
||||||
|
| shadcn CLI assumes Tailwind is at project root; we have it in renderer/ only | Point the CLI at `src/renderer/components.json` and set `tailwind.css` to the right path. |
|
||||||
|
| Marked + KaTeX + Mermaid + highlight.js together is heavy | Lazy-load Mermaid (only when `mermaid` code block encountered). KaTeX is loaded once, cached. |
|
||||||
|
| Editor content state can be megabytes → re-render storms | Editor content lives in its own slice; preview subscribes to a derived/cached HTML string; components use `useShallow` / selector subscriptions. |
|
||||||
|
| Bidirectional scroll sync loops | `useEffect` debounce + ignore if cursor/selection is the sync source. |
|
||||||
|
| CodeMirror 6 doesn't ship a Tailwind theme by default | Use `@codemirror/theme-one-dark` for dark; build a custom theme that pulls from CSS variables for light. |
|
||||||
|
| The legacy `renderer.js` and `styles*.css` are referenced from `index.html` (now `src/renderer/index.html`) | Phase 10 deletes the references; until then, both run side-by-side and the new React app sits in a known root div. |
|
||||||
|
| Electron `nodeIntegration` is off; we use contextBridge | Already the case. Document the contract clearly in `lib/ipc.ts`. |
|
||||||
|
|
||||||
|
## Open Questions (to confirm before implementation)
|
||||||
|
|
||||||
|
None. All major decisions are locked. Defaults will be used unless the user overrides them when reviewing the implementation plan.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `src/renderer.js` (213 KB) — legacy renderer being replaced
|
||||||
|
- `src/styles.css` (74 KB), `src/styles-modern.css` (71 KB), `src/styles-concreteinfo.css` (21 KB) — legacy styles being replaced
|
||||||
|
- `src/renderer/styles/globals.css` — already shadcn-compatible (HSL CSS variables)
|
||||||
|
- `src/renderer/App.tsx` — empty skeleton that references components we will build
|
||||||
|
- `tailwind.config.js` — brand colors and fonts already defined
|
||||||
|
- `vite.renderer.config.ts` — Vite + React plugin already configured
|
||||||
|
- `package.json` — CodeMirror 6, TanStack Table, lucide-react, cva, clsx, tailwind-merge, tailwindcss-animate already installed
|
||||||
Generated
+3234
-144
File diff suppressed because it is too large
Load Diff
+43
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.4.5",
|
"version": "4.4.2",
|
||||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:renderer": "vitest run",
|
||||||
|
"test:renderer:watch": "vitest",
|
||||||
|
"test:renderer:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src tests",
|
"lint": "eslint src tests",
|
||||||
"lint:fix": "eslint src tests --fix",
|
"lint:fix": "eslint src tests --fix",
|
||||||
"format": "prettier --write src tests",
|
"format": "prettier --write src tests",
|
||||||
@@ -43,7 +46,19 @@
|
|||||||
"url": "https://github.com/amitwh/markdown-converter"
|
"url": "https://github.com/amitwh/markdown-converter"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"@vitest/ui": "^4.1.8",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
@@ -52,8 +67,17 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -70,6 +94,12 @@
|
|||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.16",
|
"@codemirror/view": "^6.39.16",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
@@ -79,16 +109,26 @@
|
|||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
|
"immer": "^11.1.8",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-footnote": "^1.4.0",
|
"marked-footnote": "^1.4.0",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
|
"motion": "^12.40.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.5.207",
|
"pdfjs-dist": "^5.5.207",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pizzip": "^3.2.0",
|
"pizzip": "^3.2.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
|
"react-resizable-panels": "^4.11.2",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"tslib": "^2.8.1"
|
"sonner": "^2.0.7",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
+89
-89
@@ -12,100 +12,100 @@
|
|||||||
* @type {import('../types').FileSystemAdapter}
|
* @type {import('../types').FileSystemAdapter}
|
||||||
*/
|
*/
|
||||||
const electronFsAdapter = {
|
const electronFsAdapter = {
|
||||||
/**
|
/**
|
||||||
* Read file content
|
* Read file content
|
||||||
* @param {string} path - File path
|
* @param {string} path - File path
|
||||||
* @returns {Promise<string>} File content
|
* @returns {Promise<string>} File content
|
||||||
*/
|
*/
|
||||||
async readFile(path) {
|
async readFile(path) {
|
||||||
return await window.electronAPI.file.read(path);
|
return await window.electronAPI.file.read(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write content to file
|
* Write content to file
|
||||||
* @param {string} path - File path
|
* @param {string} path - File path
|
||||||
* @param {string} content - File content
|
* @param {string} content - File content
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async writeFile(path, content) {
|
async writeFile(path, content) {
|
||||||
return await window.electronAPI.file.write(path, content);
|
return await window.electronAPI.file.write(path, content);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete file
|
* Delete file
|
||||||
* @param {string} path - File path
|
* @param {string} path - File path
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async deleteFile(path) {
|
async deleteFile(path) {
|
||||||
return await window.electronAPI.file.delete(path);
|
return await window.electronAPI.file.delete(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure directory exists
|
* Ensure directory exists
|
||||||
* @param {string} path - Directory path
|
* @param {string} path - Directory path
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async ensureDir(path) {
|
async ensureDir(path) {
|
||||||
return await window.electronAPI.file.ensureDir(path);
|
return await window.electronAPI.file.ensureDir(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List directory contents
|
* List directory contents
|
||||||
* @param {string} path - Directory path
|
* @param {string} path - Directory path
|
||||||
* @returns {Promise<Array<import('../types').FileInfo>>}
|
* @returns {Promise<Array<import('../types').FileInfo>>}
|
||||||
*/
|
*/
|
||||||
async listDirectory(path) {
|
async listDirectory(path) {
|
||||||
const result = await window.electronAPI.invoke('list-directory', path);
|
const result = await window.electronAPI.invoke('list-directory', path);
|
||||||
if (!result?.entries) {
|
if (!result?.entries) {
|
||||||
return [];
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.entries.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
isDir: entry.isDirectory,
|
||||||
|
size: entry.size ?? 0,
|
||||||
|
modified: entry.modified ?? 0,
|
||||||
|
path: entry.path
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if path exists
|
||||||
|
* @param {string} path - Path to check
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async exists(path) {
|
||||||
|
return await window.electronAPI.file.exists(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if path is a directory
|
||||||
|
* @param {string} path - Path to check
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isDirectory(path) {
|
||||||
|
return await window.electronAPI.file.isDirectory(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy file or directory
|
||||||
|
* @param {string} source - Source path
|
||||||
|
* @param {string} dest - Destination path
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async copy(source, dest) {
|
||||||
|
return await window.electronAPI.file.copy(source, dest);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move file or directory
|
||||||
|
* @param {string} source - Source path
|
||||||
|
* @param {string} dest - Destination path
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async move(source, dest) {
|
||||||
|
return await window.electronAPI.file.move(source, dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.entries.map((entry) => ({
|
|
||||||
name: entry.name,
|
|
||||||
isDir: entry.isDirectory,
|
|
||||||
size: entry.size ?? 0,
|
|
||||||
modified: entry.modified ?? 0,
|
|
||||||
path: entry.path,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if path exists
|
|
||||||
* @param {string} path - Path to check
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
async exists(path) {
|
|
||||||
return await window.electronAPI.file.exists(path);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if path is a directory
|
|
||||||
* @param {string} path - Path to check
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
async isDirectory(path) {
|
|
||||||
return await window.electronAPI.file.isDirectory(path);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy file or directory
|
|
||||||
* @param {string} source - Source path
|
|
||||||
* @param {string} dest - Destination path
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async copy(source, dest) {
|
|
||||||
return await window.electronAPI.file.copy(source, dest);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move file or directory
|
|
||||||
* @param {string} source - Source path
|
|
||||||
* @param {string} dest - Destination path
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async move(source, dest) {
|
|
||||||
return await window.electronAPI.file.move(source, dest);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { electronFsAdapter };
|
module.exports = { electronFsAdapter };
|
||||||
|
|||||||
@@ -129,5 +129,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// Type definitions are JSDoc only, no runtime exports needed
|
// Type definitions are JSDoc only, no runtime exports needed
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
const { analyze } = require('./writing-analytics');
|
const { analyze } = require('./writing-analytics');
|
||||||
|
|
||||||
function showAnalyticsModal(tabManager) {
|
function showAnalyticsModal(tabManager) {
|
||||||
const existing = document.getElementById('analytics-modal');
|
const existing = document.getElementById('analytics-modal');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
|
|
||||||
const content = tabManager.getEditorContent();
|
const content = tabManager.getEditorContent();
|
||||||
const metrics = analyze(content);
|
const metrics = analyze(content);
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'analytics-modal';
|
overlay.id = 'analytics-modal';
|
||||||
overlay.className = 'analytics-overlay';
|
overlay.className = 'analytics-overlay';
|
||||||
|
|
||||||
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
|
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
|
||||||
|
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="analytics-modal">
|
<div class="analytics-modal">
|
||||||
<div class="analytics-header">
|
<div class="analytics-header">
|
||||||
<h2>Writing Analytics</h2>
|
<h2>Writing Analytics</h2>
|
||||||
@@ -61,15 +61,11 @@ function showAnalyticsModal(tabManager) {
|
|||||||
<span class="analytics-label">Avg Sentence</span>
|
<span class="analytics-label">Avg Sentence</span>
|
||||||
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
||||||
</div>
|
</div>
|
||||||
${
|
${metrics.longestSentenceLength > 0 ? `
|
||||||
metrics.longestSentenceLength > 0
|
|
||||||
? `
|
|
||||||
<div class="analytics-row analytics-longest">
|
<div class="analytics-row analytics-longest">
|
||||||
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
||||||
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
||||||
</div>`
|
</div>` : ''}
|
||||||
: ''
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="analytics-section">
|
<div class="analytics-section">
|
||||||
@@ -78,45 +74,39 @@ function showAnalyticsModal(tabManager) {
|
|||||||
<span class="analytics-label">Unique</span>
|
<span class="analytics-label">Unique</span>
|
||||||
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
||||||
</div>
|
</div>
|
||||||
${
|
${metrics.topWords.length > 0 ? `
|
||||||
metrics.topWords.length > 0
|
|
||||||
? `
|
|
||||||
<div class="word-cloud">
|
<div class="word-cloud">
|
||||||
${metrics.topWords
|
${metrics.topWords.map(w => {
|
||||||
.map((w) => {
|
|
||||||
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
||||||
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
||||||
})
|
}).join('')}
|
||||||
.join('')}
|
</div>` : ''}
|
||||||
</div>`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const closeBtn = overlay.querySelector('.analytics-close');
|
const closeBtn = overlay.querySelector('.analytics-close');
|
||||||
closeBtn.addEventListener('click', () => overlay.remove());
|
closeBtn.addEventListener('click', () => overlay.remove());
|
||||||
overlay.addEventListener('click', (e) => {
|
overlay.addEventListener('click', (e) => {
|
||||||
if (e.target === overlay) overlay.remove();
|
if (e.target === overlay) overlay.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
const escHandler = (e) => {
|
const escHandler = (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
document.removeEventListener('keydown', escHandler);
|
document.removeEventListener('keydown', escHandler);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', escHandler);
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { showAnalyticsModal };
|
module.exports = { showAnalyticsModal };
|
||||||
|
|||||||
+104
-177
@@ -4,197 +4,124 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const STOP_WORDS = new Set([
|
const STOP_WORDS = new Set([
|
||||||
'the',
|
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
||||||
'a',
|
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
||||||
'an',
|
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with',
|
||||||
'is',
|
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it',
|
||||||
'are',
|
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
|
||||||
'was',
|
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
|
||||||
'were',
|
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too',
|
||||||
'be',
|
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who'
|
||||||
'been',
|
|
||||||
'have',
|
|
||||||
'has',
|
|
||||||
'had',
|
|
||||||
'do',
|
|
||||||
'does',
|
|
||||||
'did',
|
|
||||||
'will',
|
|
||||||
'would',
|
|
||||||
'could',
|
|
||||||
'should',
|
|
||||||
'to',
|
|
||||||
'of',
|
|
||||||
'in',
|
|
||||||
'for',
|
|
||||||
'on',
|
|
||||||
'with',
|
|
||||||
'at',
|
|
||||||
'by',
|
|
||||||
'from',
|
|
||||||
'as',
|
|
||||||
'and',
|
|
||||||
'or',
|
|
||||||
'but',
|
|
||||||
'if',
|
|
||||||
'it',
|
|
||||||
'its',
|
|
||||||
'this',
|
|
||||||
'that',
|
|
||||||
'these',
|
|
||||||
'those',
|
|
||||||
'i',
|
|
||||||
'me',
|
|
||||||
'my',
|
|
||||||
'we',
|
|
||||||
'our',
|
|
||||||
'you',
|
|
||||||
'your',
|
|
||||||
'he',
|
|
||||||
'him',
|
|
||||||
'his',
|
|
||||||
'she',
|
|
||||||
'her',
|
|
||||||
'they',
|
|
||||||
'them',
|
|
||||||
'their',
|
|
||||||
'not',
|
|
||||||
'no',
|
|
||||||
'so',
|
|
||||||
'than',
|
|
||||||
'too',
|
|
||||||
'very',
|
|
||||||
'also',
|
|
||||||
'just',
|
|
||||||
'about',
|
|
||||||
'up',
|
|
||||||
'out',
|
|
||||||
'what',
|
|
||||||
'which',
|
|
||||||
'who',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function countSyllables(word) {
|
function countSyllables(word) {
|
||||||
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
||||||
word = word.replace(/^y/, '');
|
word = word.replace(/^y/, '');
|
||||||
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
|
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractWords(text) {
|
function extractWords(text) {
|
||||||
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
|
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReadabilityLabel(score) {
|
function getReadabilityLabel(score) {
|
||||||
if (score >= 90) return 'Very Easy';
|
if (score >= 90) return 'Very Easy';
|
||||||
if (score >= 70) return 'Easy';
|
if (score >= 70) return 'Easy';
|
||||||
if (score >= 50) return 'Standard';
|
if (score >= 50) return 'Standard';
|
||||||
if (score >= 30) return 'Difficult';
|
if (score >= 30) return 'Difficult';
|
||||||
return 'Very Difficult';
|
return 'Very Difficult';
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyze(text) {
|
function analyze(text) {
|
||||||
if (!text || !text.trim()) {
|
if (!text || !text.trim()) {
|
||||||
|
return {
|
||||||
|
wordCount: 0,
|
||||||
|
sentenceCount: 0,
|
||||||
|
paragraphCount: 0,
|
||||||
|
fleschEase: 0,
|
||||||
|
fleschGrade: 0,
|
||||||
|
readabilityLabel: 'N/A',
|
||||||
|
readingTime: 0,
|
||||||
|
speakingTime: 0,
|
||||||
|
uniqueWordCount: 0,
|
||||||
|
lexicalDiversity: 0,
|
||||||
|
avgSentenceLength: 0,
|
||||||
|
longestSentence: '',
|
||||||
|
longestSentenceLength: 0,
|
||||||
|
topWords: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = extractWords(text);
|
||||||
|
const wordCount = words.length;
|
||||||
|
|
||||||
|
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
|
||||||
|
const sentenceCount = Math.max(sentences.length, 1);
|
||||||
|
|
||||||
|
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
|
||||||
|
const paragraphCount = Math.max(paragraphs.length, 1);
|
||||||
|
|
||||||
|
let totalSyllables = 0;
|
||||||
|
for (const w of words) {
|
||||||
|
totalSyllables += countSyllables(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10;
|
||||||
|
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10;
|
||||||
|
const readabilityLabel = getReadabilityLabel(fleschEase);
|
||||||
|
|
||||||
|
const readingTime = Math.ceil(wordCount / 200);
|
||||||
|
const speakingTime = Math.ceil(wordCount / 130);
|
||||||
|
|
||||||
|
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
|
||||||
|
const uniqueWordCount = uniqueWords.size;
|
||||||
|
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
||||||
|
|
||||||
|
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
||||||
|
|
||||||
|
let longestSentence = '';
|
||||||
|
let longestSentenceLength = 0;
|
||||||
|
for (const s of sentences) {
|
||||||
|
const sWords = extractWords(s);
|
||||||
|
if (sWords.length > longestSentenceLength) {
|
||||||
|
longestSentenceLength = sWords.length;
|
||||||
|
longestSentence = s.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (longestSentence.length > 80) {
|
||||||
|
longestSentence = longestSentence.substring(0, 80) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordFreq = {};
|
||||||
|
for (const w of words) {
|
||||||
|
const lower = w.toLowerCase();
|
||||||
|
if (!STOP_WORDS.has(lower) && lower.length > 1) {
|
||||||
|
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topWords = Object.entries(wordFreq)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([word, count]) => ({ word, count }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wordCount: 0,
|
wordCount,
|
||||||
sentenceCount: 0,
|
sentenceCount,
|
||||||
paragraphCount: 0,
|
paragraphCount,
|
||||||
fleschEase: 0,
|
fleschEase,
|
||||||
fleschGrade: 0,
|
fleschGrade,
|
||||||
readabilityLabel: 'N/A',
|
readabilityLabel,
|
||||||
readingTime: 0,
|
readingTime,
|
||||||
speakingTime: 0,
|
speakingTime,
|
||||||
uniqueWordCount: 0,
|
uniqueWordCount,
|
||||||
lexicalDiversity: 0,
|
lexicalDiversity,
|
||||||
avgSentenceLength: 0,
|
avgSentenceLength,
|
||||||
longestSentence: '',
|
longestSentence,
|
||||||
longestSentenceLength: 0,
|
longestSentenceLength,
|
||||||
topWords: [],
|
topWords
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const words = extractWords(text);
|
|
||||||
const wordCount = words.length;
|
|
||||||
|
|
||||||
const sentences = text
|
|
||||||
.split(/[.!?]+/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const sentenceCount = Math.max(sentences.length, 1);
|
|
||||||
|
|
||||||
const paragraphs = text
|
|
||||||
.split(/\n\s*\n/)
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const paragraphCount = Math.max(paragraphs.length, 1);
|
|
||||||
|
|
||||||
let totalSyllables = 0;
|
|
||||||
for (const w of words) {
|
|
||||||
totalSyllables += countSyllables(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fleschEase =
|
|
||||||
Math.round(
|
|
||||||
(206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10
|
|
||||||
) / 10;
|
|
||||||
const fleschGrade =
|
|
||||||
Math.round(
|
|
||||||
(0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10
|
|
||||||
) / 10;
|
|
||||||
const readabilityLabel = getReadabilityLabel(fleschEase);
|
|
||||||
|
|
||||||
const readingTime = Math.ceil(wordCount / 200);
|
|
||||||
const speakingTime = Math.ceil(wordCount / 130);
|
|
||||||
|
|
||||||
const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
|
|
||||||
const uniqueWordCount = uniqueWords.size;
|
|
||||||
const lexicalDiversity =
|
|
||||||
wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
|
||||||
|
|
||||||
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
|
||||||
|
|
||||||
let longestSentence = '';
|
|
||||||
let longestSentenceLength = 0;
|
|
||||||
for (const s of sentences) {
|
|
||||||
const sWords = extractWords(s);
|
|
||||||
if (sWords.length > longestSentenceLength) {
|
|
||||||
longestSentenceLength = sWords.length;
|
|
||||||
longestSentence = s.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (longestSentence.length > 80) {
|
|
||||||
longestSentence = longestSentence.substring(0, 80) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordFreq = {};
|
|
||||||
for (const w of words) {
|
|
||||||
const lower = w.toLowerCase();
|
|
||||||
if (!STOP_WORDS.has(lower) && lower.length > 1) {
|
|
||||||
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const topWords = Object.entries(wordFreq)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(([word, count]) => ({ word, count }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
wordCount,
|
|
||||||
sentenceCount,
|
|
||||||
paragraphCount,
|
|
||||||
fleschEase,
|
|
||||||
fleschGrade,
|
|
||||||
readabilityLabel,
|
|
||||||
readingTime,
|
|
||||||
speakingTime,
|
|
||||||
uniqueWordCount,
|
|
||||||
lexicalDiversity,
|
|
||||||
avgSentenceLength,
|
|
||||||
longestSentence,
|
|
||||||
longestSentenceLength,
|
|
||||||
topWords,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { analyze };
|
module.exports = { analyze };
|
||||||
|
|||||||
+536
-685
File diff suppressed because it is too large
Load Diff
+87
-91
@@ -1,113 +1,109 @@
|
|||||||
class CommandPalette {
|
class CommandPalette {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.overlay = document.getElementById('command-palette-overlay');
|
this.overlay = document.getElementById('command-palette-overlay');
|
||||||
this.input = document.getElementById('command-palette-input');
|
this.input = document.getElementById('command-palette-input');
|
||||||
this.results = document.getElementById('command-palette-results');
|
this.results = document.getElementById('command-palette-results');
|
||||||
this.commands = [];
|
this.commands = [];
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
this.filteredCommands = [];
|
this.filteredCommands = [];
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
register(label, shortcut, action) {
|
register(label, shortcut, action) {
|
||||||
this.commands.push({ label, shortcut, action });
|
this.commands.push({ label, shortcut, action });
|
||||||
}
|
}
|
||||||
|
|
||||||
open() {
|
open() {
|
||||||
this.overlay.classList.remove('hidden');
|
this.overlay.classList.remove('hidden');
|
||||||
this.input.value = '';
|
this.input.value = '';
|
||||||
this.input.focus();
|
this.input.focus();
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
this.renderResults('');
|
this.renderResults('');
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.overlay.classList.add('hidden');
|
this.overlay.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
isOpen() {
|
isOpen() {
|
||||||
return !this.overlay.classList.contains('hidden');
|
return !this.overlay.classList.contains('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
this.input.addEventListener('input', () => {
|
this.input.addEventListener('input', () => {
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
this.renderResults(this.input.value);
|
this.renderResults(this.input.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.overlay.addEventListener('click', (e) => {
|
this.overlay.addEventListener('click', (e) => {
|
||||||
if (e.target === this.overlay) this.close();
|
if (e.target === this.overlay) this.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.input.addEventListener('keydown', (e) => {
|
this.input.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.close();
|
this.close();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.executeSelected();
|
this.executeSelected();
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
|
||||||
this.updateSelection();
|
this.updateSelection();
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||||
this.updateSelection();
|
this.updateSelection();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderResults(query) {
|
renderResults(query) {
|
||||||
this.filteredCommands = query
|
this.filteredCommands = query
|
||||||
? this.commands.filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()))
|
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
: [...this.commands];
|
: [...this.commands];
|
||||||
|
|
||||||
this.results.innerHTML = this.filteredCommands
|
this.results.innerHTML = this.filteredCommands.map((cmd, i) => `
|
||||||
.map(
|
|
||||||
(cmd, i) => `
|
|
||||||
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
||||||
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
|
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
|
||||||
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
|
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`
|
`).join('');
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
this.results.querySelectorAll('.command-item').forEach((el) => {
|
this.results.querySelectorAll('.command-item').forEach((el) => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
const idx = parseInt(el.dataset.index);
|
const idx = parseInt(el.dataset.index);
|
||||||
this.filteredCommands[idx].action();
|
this.filteredCommands[idx].action();
|
||||||
this.close();
|
this.close();
|
||||||
});
|
});
|
||||||
el.addEventListener('mouseenter', () => {
|
el.addEventListener('mouseenter', () => {
|
||||||
this.selectedIndex = parseInt(el.dataset.index);
|
this.selectedIndex = parseInt(el.dataset.index);
|
||||||
this.updateSelection();
|
this.updateSelection();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
highlightMatch(text, query) {
|
highlightMatch(text, query) {
|
||||||
if (!query) return text;
|
if (!query) return text;
|
||||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||||
return text.replace(regex, '<strong>$1</strong>');
|
return text.replace(regex, '<strong>$1</strong>');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelection() {
|
updateSelection() {
|
||||||
this.results.querySelectorAll('.command-item').forEach((el, i) => {
|
this.results.querySelectorAll('.command-item').forEach((el, i) => {
|
||||||
el.classList.toggle('selected', i === this.selectedIndex);
|
el.classList.toggle('selected', i === this.selectedIndex);
|
||||||
});
|
});
|
||||||
// Scroll selected into view
|
// Scroll selected into view
|
||||||
const selected = this.results.querySelector('.command-item.selected');
|
const selected = this.results.querySelector('.command-item.selected');
|
||||||
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
executeSelected() {
|
executeSelected() {
|
||||||
if (this.filteredCommands[this.selectedIndex]) {
|
if (this.filteredCommands[this.selectedIndex]) {
|
||||||
this.filteredCommands[this.selectedIndex].action();
|
this.filteredCommands[this.selectedIndex].action();
|
||||||
this.close();
|
this.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { CommandPalette };
|
module.exports = { CommandPalette };
|
||||||
|
|||||||
@@ -12,23 +12,38 @@ const { EditorState } = require('@codemirror/state');
|
|||||||
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
||||||
// Language extensions loaded lazily on first use
|
// Language extensions loaded lazily on first use
|
||||||
let _javascript, _html, _css, _json, _python;
|
let _javascript, _html, _css, _json, _python;
|
||||||
const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
|
const {
|
||||||
const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
|
defaultKeymap,
|
||||||
const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
|
history,
|
||||||
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
|
historyKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
} = require('@codemirror/commands');
|
||||||
|
const {
|
||||||
|
searchKeymap,
|
||||||
|
highlightSelectionMatches,
|
||||||
|
} = require('@codemirror/search');
|
||||||
|
const {
|
||||||
|
autocompletion,
|
||||||
|
completionKeymap,
|
||||||
|
} = require('@codemirror/autocomplete');
|
||||||
|
const {
|
||||||
|
bracketMatching,
|
||||||
|
foldGutter,
|
||||||
|
indentOnInput,
|
||||||
|
} = require('@codemirror/language');
|
||||||
const { oneDark } = require('@codemirror/theme-one-dark');
|
const { oneDark } = require('@codemirror/theme-one-dark');
|
||||||
|
|
||||||
// Custom theme for JetBrains Mono font
|
// Custom theme for JetBrains Mono font
|
||||||
const jetBrainsMonoTheme = EditorView.theme({
|
const jetBrainsMonoTheme = EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace"
|
||||||
},
|
},
|
||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit'
|
||||||
},
|
},
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,14 +59,7 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
* @returns {EditorView} the created editor view
|
* @returns {EditorView} the created editor view
|
||||||
*/
|
*/
|
||||||
function createEditor(parentElement, options = {}) {
|
function createEditor(parentElement, options = {}) {
|
||||||
console.log(
|
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
|
||||||
'[createEditor] Called with parentElement:',
|
|
||||||
parentElement?.id,
|
|
||||||
'dimensions:',
|
|
||||||
parentElement?.clientWidth,
|
|
||||||
'x',
|
|
||||||
parentElement?.clientHeight
|
|
||||||
);
|
|
||||||
if (!parentElement) {
|
if (!parentElement) {
|
||||||
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
||||||
return null;
|
return null;
|
||||||
@@ -117,26 +125,11 @@ function createEditor(parentElement, options = {}) {
|
|||||||
*/
|
*/
|
||||||
function getLanguageExtension(lang) {
|
function getLanguageExtension(lang) {
|
||||||
const loaders = {
|
const loaders = {
|
||||||
javascript: () => {
|
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); },
|
||||||
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
|
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); },
|
||||||
return _javascript();
|
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); },
|
||||||
},
|
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); },
|
||||||
html: () => {
|
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); },
|
||||||
if (!_html) _html = require('@codemirror/lang-html').html;
|
|
||||||
return _html();
|
|
||||||
},
|
|
||||||
css: () => {
|
|
||||||
if (!_css) _css = require('@codemirror/lang-css').css;
|
|
||||||
return _css();
|
|
||||||
},
|
|
||||||
json: () => {
|
|
||||||
if (!_json) _json = require('@codemirror/lang-json').json;
|
|
||||||
return _json();
|
|
||||||
},
|
|
||||||
python: () => {
|
|
||||||
if (!_python) _python = require('@codemirror/lang-python').python;
|
|
||||||
return _python();
|
|
||||||
},
|
|
||||||
markdown: () => markdown({ base: markdownLanguage }),
|
markdown: () => markdown({ base: markdownLanguage }),
|
||||||
};
|
};
|
||||||
loaders.js = loaders.javascript;
|
loaders.js = loaders.javascript;
|
||||||
|
|||||||
+45
-45
@@ -2,74 +2,74 @@
|
|||||||
|
|
||||||
/* Inter Font Family */
|
/* Inter Font Family */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
|
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
|
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
|
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
|
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
|
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* JetBrains Mono Font Family - For code, markdown editor, and ASCII art */
|
/* JetBrains Mono Font Family - For code, markdown editor, and ASCII art */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono';
|
font-family: 'JetBrains Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono';
|
font-family: 'JetBrains Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
|
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono';
|
font-family: 'JetBrains Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
|
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'JetBrains Mono';
|
font-family: 'JetBrains Mono';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|||||||
+1601
-2388
File diff suppressed because it is too large
Load Diff
+1171
-1672
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ async function getStatus(dir) {
|
|||||||
try {
|
try {
|
||||||
const git = getGitInstance(dir);
|
const git = getGitInstance(dir);
|
||||||
return await git.status();
|
return await git.status();
|
||||||
} catch {
|
} catch (err) {
|
||||||
return { error: 'Not a git repository' };
|
return { error: 'Not a git repository' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-62
@@ -4,11 +4,11 @@ const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
|||||||
|
|
||||||
function parsePageRanges(rangeString, totalPages) {
|
function parsePageRanges(rangeString, totalPages) {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const ranges = rangeString.split(',').map((r) => r.trim());
|
const ranges = rangeString.split(',').map(r => r.trim());
|
||||||
|
|
||||||
for (const range of ranges) {
|
for (const range of ranges) {
|
||||||
if (range.includes('-')) {
|
if (range.includes('-')) {
|
||||||
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
for (let i = start; i <= end && i <= totalPages; i++) {
|
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||||
if (i > 0 && !pages.includes(i - 1)) {
|
if (i > 0 && !pages.includes(i - 1)) {
|
||||||
pages.push(i - 1);
|
pages.push(i - 1);
|
||||||
@@ -27,13 +27,11 @@ function parsePageRanges(rangeString, totalPages) {
|
|||||||
|
|
||||||
function hexToRgb(hex) {
|
function hexToRgb(hex) {
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
return result
|
return result ? {
|
||||||
? {
|
r: parseInt(result[1], 16) / 255,
|
||||||
r: parseInt(result[1], 16) / 255,
|
g: parseInt(result[2], 16) / 255,
|
||||||
g: parseInt(result[2], 16) / 255,
|
b: parseInt(result[3], 16) / 255
|
||||||
b: parseInt(result[3], 16) / 255,
|
} : { r: 0, g: 0, b: 0 };
|
||||||
}
|
|
||||||
: { r: 0, g: 0, b: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pdfMerge(data) {
|
async function pdfMerge(data) {
|
||||||
@@ -44,7 +42,7 @@ async function pdfMerge(data) {
|
|||||||
const pdfBytes = fs.readFileSync(filePath);
|
const pdfBytes = fs.readFileSync(filePath);
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||||
copiedPages.forEach((page) => mergedPdf.addPage(page));
|
copiedPages.forEach(page => mergedPdf.addPage(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await mergedPdf.save();
|
const pdfBytes = await mergedPdf.save();
|
||||||
@@ -65,13 +63,13 @@ async function pdfSplit(data) {
|
|||||||
const splits = [];
|
const splits = [];
|
||||||
|
|
||||||
if (data.splitMode === 'pages') {
|
if (data.splitMode === 'pages') {
|
||||||
const ranges = data.pageRanges.split(',').map((r) => r.trim());
|
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||||
for (let i = 0; i < ranges.length; i++) {
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
const range = ranges[i];
|
const range = ranges[i];
|
||||||
const pages = [];
|
const pages = [];
|
||||||
|
|
||||||
if (range.includes('-')) {
|
if (range.includes('-')) {
|
||||||
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
for (let p = start; p <= end && p <= totalPages; p++) {
|
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||||
pages.push(p - 1);
|
pages.push(p - 1);
|
||||||
}
|
}
|
||||||
@@ -110,7 +108,7 @@ async function pdfSplit(data) {
|
|||||||
for (const split of splits) {
|
for (const split of splits) {
|
||||||
const newPdf = await PDFDocument.create();
|
const newPdf = await PDFDocument.create();
|
||||||
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
copiedPages.forEach(page => newPdf.addPage(page));
|
||||||
|
|
||||||
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||||
const newPdfBytes = await newPdf.save();
|
const newPdfBytes = await newPdf.save();
|
||||||
@@ -131,18 +129,18 @@ async function pdfCompress(data) {
|
|||||||
const compressedPdfBytes = await pdf.save({
|
const compressedPdfBytes = await pdf.save({
|
||||||
useObjectStreams: true,
|
useObjectStreams: true,
|
||||||
addDefaultPage: false,
|
addDefaultPage: false,
|
||||||
objectsPerTick: 50,
|
objectsPerTick: 50
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||||
|
|
||||||
const originalSize = fs.statSync(data.inputPath).size;
|
const originalSize = fs.statSync(data.inputPath).size;
|
||||||
const compressedSize = fs.statSync(data.outputPath).size;
|
const compressedSize = fs.statSync(data.outputPath).size;
|
||||||
const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
|
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
|
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -162,7 +160,7 @@ async function pdfRotate(data) {
|
|||||||
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
}
|
}
|
||||||
|
|
||||||
pagesToRotate.forEach((pageIndex) => {
|
pagesToRotate.forEach(pageIndex => {
|
||||||
const page = pdf.getPage(pageIndex);
|
const page = pdf.getPage(pageIndex);
|
||||||
page.setRotation(degrees(data.angle));
|
page.setRotation(degrees(data.angle));
|
||||||
});
|
});
|
||||||
@@ -172,7 +170,7 @@ async function pdfRotate(data) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
|
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -187,18 +185,16 @@ async function pdfDeletePages(data) {
|
|||||||
|
|
||||||
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||||
|
|
||||||
pagesToDelete
|
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||||
.sort((a, b) => b - a)
|
pdf.removePage(pageIndex);
|
||||||
.forEach((pageIndex) => {
|
});
|
||||||
pdf.removePage(pageIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const newPdfBytes = await pdf.save();
|
const newPdfBytes = await pdf.save();
|
||||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
|
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -211,7 +207,7 @@ async function pdfReorder(data) {
|
|||||||
const pdf = await PDFDocument.load(pdfBytes);
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
const totalPages = pdf.getPageCount();
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
|
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
|
||||||
|
|
||||||
if (newOrder.length !== totalPages) {
|
if (newOrder.length !== totalPages) {
|
||||||
return { success: false, error: `New order must include all ${totalPages} pages` };
|
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||||
@@ -219,7 +215,7 @@ async function pdfReorder(data) {
|
|||||||
|
|
||||||
const newPdf = await PDFDocument.create();
|
const newPdf = await PDFDocument.create();
|
||||||
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
copiedPages.forEach(page => newPdf.addPage(page));
|
||||||
|
|
||||||
const reorderedPdfBytes = await newPdf.save();
|
const reorderedPdfBytes = await newPdf.save();
|
||||||
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||||
@@ -250,9 +246,7 @@ async function pdfWatermark(data) {
|
|||||||
const page = pdf.getPage(pageIndex);
|
const page = pdf.getPage(pageIndex);
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
|
|
||||||
let x,
|
let x, y, rotation = 0;
|
||||||
y,
|
|
||||||
rotation = 0;
|
|
||||||
|
|
||||||
switch (data.position) {
|
switch (data.position) {
|
||||||
case 'center':
|
case 'center':
|
||||||
@@ -300,7 +294,7 @@ async function pdfWatermark(data) {
|
|||||||
font,
|
font,
|
||||||
color: rgb(color.r, color.g, color.b),
|
color: rgb(color.r, color.g, color.b),
|
||||||
opacity: data.opacity,
|
opacity: data.opacity,
|
||||||
rotate: degrees(rotation),
|
rotate: degrees(rotation)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +303,7 @@ async function pdfWatermark(data) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
|
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -331,8 +325,8 @@ async function pdfEncrypt(data) {
|
|||||||
annotating: data.permissions.annotating,
|
annotating: data.permissions.annotating,
|
||||||
fillingForms: data.permissions.fillingForms,
|
fillingForms: data.permissions.fillingForms,
|
||||||
contentAccessibility: data.permissions.contentAccessibility,
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
documentAssembly: data.permissions.documentAssembly,
|
documentAssembly: data.permissions.documentAssembly
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||||
@@ -342,8 +336,7 @@ async function pdfEncrypt(data) {
|
|||||||
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||||
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -382,8 +375,8 @@ async function pdfSetPermissions(data) {
|
|||||||
annotating: data.permissions.annotating,
|
annotating: data.permissions.annotating,
|
||||||
fillingForms: data.permissions.fillingForms,
|
fillingForms: data.permissions.fillingForms,
|
||||||
contentAccessibility: data.permissions.contentAccessibility,
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
documentAssembly: data.permissions.documentAssembly,
|
documentAssembly: data.permissions.documentAssembly
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.writeFileSync(data.outputPath, newPdfBytes);
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
@@ -393,8 +386,7 @@ async function pdfSetPermissions(data) {
|
|||||||
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||||
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
@@ -403,28 +395,17 @@ async function pdfSetPermissions(data) {
|
|||||||
|
|
||||||
function executeOperation(operation, data) {
|
function executeOperation(operation, data) {
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
case 'merge':
|
case 'merge': return pdfMerge(data);
|
||||||
return pdfMerge(data);
|
case 'split': return pdfSplit(data);
|
||||||
case 'split':
|
case 'compress': return pdfCompress(data);
|
||||||
return pdfSplit(data);
|
case 'rotate': return pdfRotate(data);
|
||||||
case 'compress':
|
case 'delete': return pdfDeletePages(data);
|
||||||
return pdfCompress(data);
|
case 'reorder': return pdfReorder(data);
|
||||||
case 'rotate':
|
case 'watermark': return pdfWatermark(data);
|
||||||
return pdfRotate(data);
|
case 'encrypt': return pdfEncrypt(data);
|
||||||
case 'delete':
|
case 'decrypt': return pdfDecrypt(data);
|
||||||
return pdfDeletePages(data);
|
case 'permissions': return pdfSetPermissions(data);
|
||||||
case 'reorder':
|
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||||
return pdfReorder(data);
|
|
||||||
case 'watermark':
|
|
||||||
return pdfWatermark(data);
|
|
||||||
case 'encrypt':
|
|
||||||
return pdfEncrypt(data);
|
|
||||||
case 'decrypt':
|
|
||||||
return pdfDecrypt(data);
|
|
||||||
case 'permissions':
|
|
||||||
return pdfSetPermissions(data);
|
|
||||||
default:
|
|
||||||
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,5 +429,5 @@ module.exports = {
|
|||||||
pdfDecrypt,
|
pdfDecrypt,
|
||||||
pdfSetPermissions,
|
pdfSetPermissions,
|
||||||
executeOperation,
|
executeOperation,
|
||||||
getPageCount,
|
getPageCount
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"description": "Demonstrates the plugin system. Safe to delete.",
|
"description": "Demonstrates the plugin system. Safe to delete.",
|
||||||
"icon": "puzzle",
|
"icon": "puzzle",
|
||||||
"extensionPoints": {
|
"extensionPoints": {
|
||||||
"commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
|
"commands": [
|
||||||
|
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"settings": []
|
"settings": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
|
|
||||||
this.sprintEngine = new SprintEngine({
|
this.sprintEngine = new SprintEngine({
|
||||||
onEvent: (name, data) => context.events.emit(name, data),
|
onEvent: (name, data) => context.events.emit(name, data)
|
||||||
});
|
});
|
||||||
this.goalTracker = new GoalTracker(context.settings);
|
this.goalTracker = new GoalTracker(context.settings);
|
||||||
this.snapshotManager = new SnapshotManager(context.settings);
|
this.snapshotManager = new SnapshotManager(context.settings);
|
||||||
@@ -17,14 +17,14 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
readFile: (p) => context.ipc.invoke('read-file', p),
|
readFile: (p) => context.ipc.invoke('read-file', p),
|
||||||
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
||||||
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
||||||
listDir: (p) => context.ipc.invoke('list-directory', p),
|
listDir: (p) => context.ipc.invoke('list-directory', p)
|
||||||
});
|
});
|
||||||
|
|
||||||
this._engines = {
|
this._engines = {
|
||||||
sprint: this.sprintEngine,
|
sprint: this.sprintEngine,
|
||||||
goals: this.goalTracker,
|
goals: this.goalTracker,
|
||||||
snapshots: this.snapshotManager,
|
snapshots: this.snapshotManager,
|
||||||
projects: this.projectManager,
|
projects: this.projectManager
|
||||||
};
|
};
|
||||||
|
|
||||||
this._registerCommands(context);
|
this._registerCommands(context);
|
||||||
@@ -34,89 +34,59 @@ class WritingStudioPlugin extends PluginAPI {
|
|||||||
_registerCommands(context) {
|
_registerCommands(context) {
|
||||||
const { sprintEngine, snapshotManager, goalTracker } = this;
|
const { sprintEngine, snapshotManager, goalTracker } = this;
|
||||||
|
|
||||||
context.commands.register(
|
context.commands.register('start-sprint', 'Studio: Start Sprint', () => {
|
||||||
'start-sprint',
|
const duration = context.settings.get('sprintDuration') || 25;
|
||||||
'Studio: Start Sprint',
|
const content = context.editor.getContent() || '';
|
||||||
() => {
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
const duration = context.settings.get('sprintDuration') || 25;
|
sprintEngine.start(duration, words);
|
||||||
const content = context.editor.getContent() || '';
|
}, 'Ctrl+Alt+S');
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
|
||||||
sprintEngine.start(duration, words);
|
|
||||||
},
|
|
||||||
'Ctrl+Alt+S'
|
|
||||||
);
|
|
||||||
|
|
||||||
context.commands.register(
|
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => {
|
||||||
'stop-sprint',
|
if (!sprintEngine.isActive()) return;
|
||||||
'Studio: Stop Sprint',
|
const content = context.editor.getContent() || '';
|
||||||
() => {
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
if (!sprintEngine.isActive()) return;
|
const result = sprintEngine.stop(words);
|
||||||
const content = context.editor.getContent() || '';
|
goalTracker.addWords(result.wordDelta);
|
||||||
const words = content.split(/\s+/).filter(Boolean).length;
|
context.events.emit('sprint:stopped', result);
|
||||||
const result = sprintEngine.stop(words);
|
}, 'Ctrl+Alt+Shift+S');
|
||||||
goalTracker.addWords(result.wordDelta);
|
|
||||||
context.events.emit('sprint:stopped', result);
|
|
||||||
},
|
|
||||||
'Ctrl+Alt+Shift+S'
|
|
||||||
);
|
|
||||||
|
|
||||||
context.commands.register(
|
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => {
|
||||||
'take-snapshot',
|
const content = context.editor.getContent() || '';
|
||||||
'Studio: Take Snapshot',
|
snapshotManager.create(content, 'manual');
|
||||||
() => {
|
context.events.emit('snapshot:created', {});
|
||||||
const content = context.editor.getContent() || '';
|
}, 'Ctrl+Alt+N');
|
||||||
snapshotManager.create(content, 'manual');
|
|
||||||
context.events.emit('snapshot:created', {});
|
|
||||||
},
|
|
||||||
'Ctrl+Alt+N'
|
|
||||||
);
|
|
||||||
|
|
||||||
context.commands.register(
|
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => {
|
||||||
'restore-last-snapshot',
|
const snaps = snapshotManager.list();
|
||||||
'Studio: Restore Last Snapshot',
|
if (snaps.length === 0) return;
|
||||||
() => {
|
const content = snapshotManager.restore(snaps[0].id);
|
||||||
const snaps = snapshotManager.list();
|
context.editor.insertAtCursor(content);
|
||||||
if (snaps.length === 0) return;
|
}, 'Ctrl+Alt+Z');
|
||||||
const content = snapshotManager.restore(snaps[0].id);
|
|
||||||
context.editor.insertAtCursor(content);
|
|
||||||
},
|
|
||||||
'Ctrl+Alt+Z'
|
|
||||||
);
|
|
||||||
|
|
||||||
context.commands.register('new-project', 'Studio: New Project', () => {
|
context.commands.register('new-project', 'Studio: New Project', () => {
|
||||||
context.events.emit('studio:new-project', {});
|
context.events.emit('studio:new-project', {});
|
||||||
});
|
});
|
||||||
|
|
||||||
context.commands.register(
|
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => {
|
||||||
'compile-manuscript',
|
context.events.emit('studio:compile', {});
|
||||||
'Studio: Compile Manuscript',
|
}, 'Ctrl+Alt+E');
|
||||||
() => {
|
|
||||||
context.events.emit('studio:compile', {});
|
|
||||||
},
|
|
||||||
'Ctrl+Alt+E'
|
|
||||||
);
|
|
||||||
|
|
||||||
context.commands.register(
|
context.commands.register('proofread-document', 'Studio: Proofread Document', () => {
|
||||||
'proofread-document',
|
if (context.events.hasHandler('ai:analyze')) {
|
||||||
'Studio: Proofread Document',
|
const content = context.editor.getContent() || '';
|
||||||
() => {
|
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
||||||
if (context.events.hasHandler('ai:analyze')) {
|
}
|
||||||
const content = context.editor.getContent() || '';
|
}, 'Ctrl+Alt+G');
|
||||||
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'Ctrl+Alt+G'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_registerStatusBar(context) {
|
_registerStatusBar(context) {
|
||||||
context.statusBar.registerIndicator('word-goal', {
|
context.statusBar.registerIndicator('word-goal', {
|
||||||
text: '0/1000',
|
text: '0/1000',
|
||||||
tooltip: 'Daily word goal progress',
|
tooltip: 'Daily word goal progress'
|
||||||
});
|
});
|
||||||
context.statusBar.registerIndicator('sprint-timer', {
|
context.statusBar.registerIndicator('sprint-timer', {
|
||||||
text: '',
|
text: '',
|
||||||
tooltip: 'Writing sprint timer',
|
tooltip: 'Writing sprint timer'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,34 +15,17 @@
|
|||||||
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||||
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
||||||
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
||||||
{
|
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" },
|
||||||
"id": "restore-last-snapshot",
|
|
||||||
"label": "Studio: Restore Last Snapshot",
|
|
||||||
"shortcut": "Ctrl+Alt+Z"
|
|
||||||
},
|
|
||||||
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
||||||
{
|
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" },
|
||||||
"id": "compile-manuscript",
|
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" }
|
||||||
"label": "Studio: Compile Manuscript",
|
|
||||||
"shortcut": "Ctrl+Alt+E"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "proofread-document",
|
|
||||||
"label": "Studio: Proofread Document",
|
|
||||||
"shortcut": "Ctrl+Alt+G"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
||||||
},
|
},
|
||||||
"settings": [
|
"settings": [
|
||||||
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||||
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
||||||
{
|
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" },
|
||||||
"key": "autoSnapshotInterval",
|
|
||||||
"type": "number",
|
|
||||||
"default": 0,
|
|
||||||
"label": "Auto-snapshot interval (min, 0=off)"
|
|
||||||
},
|
|
||||||
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ function renderGoalsPanel(container, { engines, settings }) {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ws-stat-row';
|
row.className = 'ws-stat-row';
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent =
|
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
||||||
progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
|
||||||
const pct = document.createElement('span');
|
const pct = document.createElement('span');
|
||||||
pct.className = 'ws-pct';
|
pct.className = 'ws-pct';
|
||||||
pct.textContent = progress.pct + '%';
|
pct.textContent = progress.pct + '%';
|
||||||
@@ -82,7 +81,7 @@ function renderGoalsPanel(container, { engines, settings }) {
|
|||||||
|
|
||||||
const chart = document.createElement('div');
|
const chart = document.createElement('div');
|
||||||
chart.className = 'ws-chart';
|
chart.className = 'ws-chart';
|
||||||
const maxWords = Math.max(...last30.map((d) => d.words), 1);
|
const maxWords = Math.max(...last30.map(d => d.words), 1);
|
||||||
for (const day of last30) {
|
for (const day of last30) {
|
||||||
const barEl = document.createElement('div');
|
const barEl = document.createElement('div');
|
||||||
const height = Math.max(2, (day.words / maxWords) * 60);
|
const height = Math.max(2, (day.words / maxWords) * 60);
|
||||||
|
|||||||
@@ -69,8 +69,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ws-stat-row';
|
row.className = 'ws-stat-row';
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent =
|
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
||||||
stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
|
||||||
const pct = document.createElement('span');
|
const pct = document.createElement('span');
|
||||||
pct.className = 'ws-pct';
|
pct.className = 'ws-pct';
|
||||||
pct.textContent = stats.pctComplete + '%';
|
pct.textContent = stats.pctComplete + '%';
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function renderProofreadPanel(container, { events, editor }) {
|
|||||||
if (result && result.issues) {
|
if (result && result.issues) {
|
||||||
renderIssues(issuesList, result.issues);
|
renderIssues(issuesList, result.issues);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -80,10 +80,7 @@ function renderIssues(container, issues) {
|
|||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'ws-issue-actions';
|
actions.className = 'ws-issue-actions';
|
||||||
for (const [, label] of [
|
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) {
|
||||||
['accept', 'Accept'],
|
|
||||||
['dismiss', 'Dismiss'],
|
|
||||||
]) {
|
|
||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'ws-btn ws-btn-sm';
|
actionBtn.className = 'ws-btn ws-btn-sm';
|
||||||
actionBtn.textContent = label;
|
actionBtn.textContent = label;
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ function renderSnapshotsPanel(container, { engines, editor }) {
|
|||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'ws-snapshot-actions';
|
actions.className = 'ws-snapshot-actions';
|
||||||
for (const [action, text, cls] of [
|
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) {
|
||||||
['restore', 'Restore', ''],
|
|
||||||
['diff', 'Diff', ''],
|
|
||||||
['delete', 'Delete', 'ws-btn-danger'],
|
|
||||||
]) {
|
|
||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||||
actionBtn.textContent = text;
|
actionBtn.textContent = text;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ProjectManager {
|
|||||||
type: opts.type || 'manuscript',
|
type: opts.type || 'manuscript',
|
||||||
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
||||||
chapters: [],
|
chapters: [],
|
||||||
metadata: opts.metadata || {},
|
metadata: opts.metadata || {}
|
||||||
};
|
};
|
||||||
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||||
return project;
|
return project;
|
||||||
@@ -66,7 +66,7 @@ class ProjectManager {
|
|||||||
totalWords,
|
totalWords,
|
||||||
chapterCount: project.chapters.length,
|
chapterCount: project.chapters.length,
|
||||||
targetWords: target,
|
targetWords: target,
|
||||||
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0,
|
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class SnapshotManager {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
content,
|
content,
|
||||||
wordCount: content.split(/\s+/).filter(Boolean).length,
|
wordCount: content.split(/\s+/).filter(Boolean).length,
|
||||||
label,
|
label
|
||||||
};
|
};
|
||||||
snaps.unshift(snap);
|
snaps.unshift(snap);
|
||||||
this._saveAll(snaps);
|
this._saveAll(snaps);
|
||||||
@@ -36,7 +36,7 @@ class SnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getById(id) {
|
getById(id) {
|
||||||
return this._getAll().find((s) => s.id === id) || null;
|
return this._getAll().find(s => s.id === id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
restore(id) {
|
restore(id) {
|
||||||
@@ -46,7 +46,7 @@ class SnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(id) {
|
delete(id) {
|
||||||
const snaps = this._getAll().filter((s) => s.id !== id);
|
const snaps = this._getAll().filter(s => s.id !== id);
|
||||||
this._saveAll(snaps);
|
this._saveAll(snaps);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +59,8 @@ class SnapshotManager {
|
|||||||
const newSet = new Set(newLines);
|
const newSet = new Set(newLines);
|
||||||
let added = 0;
|
let added = 0;
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
for (const line of newLines) {
|
for (const line of newLines) { if (!oldSet.has(line)) added++; }
|
||||||
if (!oldSet.has(line)) added++;
|
for (const line of oldLines) { if (!newSet.has(line)) removed++; }
|
||||||
}
|
|
||||||
for (const line of oldLines) {
|
|
||||||
if (!newSet.has(line)) removed++;
|
|
||||||
}
|
|
||||||
return { added, removed };
|
return { added, removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ class SprintEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive() {
|
isActive() { return this._active; }
|
||||||
return this._active;
|
|
||||||
}
|
|
||||||
|
|
||||||
getRemaining() {
|
getRemaining() {
|
||||||
if (!this._active) return 0;
|
if (!this._active) return 0;
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ class PluginContext {
|
|||||||
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
||||||
*/
|
*/
|
||||||
constructor(deps) {
|
constructor(deps) {
|
||||||
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
|
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps;
|
||||||
deps;
|
|
||||||
|
|
||||||
this.sidebar = {
|
this.sidebar = {
|
||||||
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
|
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.commands = {
|
this.commands = {
|
||||||
@@ -29,45 +28,41 @@ class PluginContext {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.statusBar = {
|
this.statusBar = {
|
||||||
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
|
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.settings = {
|
this.settings = {
|
||||||
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
||||||
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
||||||
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb),
|
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.editor = {
|
this.editor = {
|
||||||
getContent: () => editor.getContent(),
|
getContent: () => editor.getContent(),
|
||||||
getSelection: () => editor.getSelection(),
|
getSelection: () => editor.getSelection(),
|
||||||
insertAtCursor: (text) => editor.insertAtCursor(text),
|
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||||
onContentChanged: (cb) => editor.onContentChanged(cb),
|
onContentChanged: (cb) => editor.onContentChanged(cb)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.events = {
|
this.events = {
|
||||||
on: (event, handler) => eventBus.on(event, handler),
|
on: (event, handler) => eventBus.on(event, handler),
|
||||||
off: (event, handler) => eventBus.off(event, handler),
|
off: (event, handler) => eventBus.off(event, handler),
|
||||||
emit: (event, payload) => eventBus.emit(event, payload),
|
emit: (event, payload) => eventBus.emit(event, payload),
|
||||||
hasHandler: (event) => eventBus.hasHandler(event),
|
hasHandler: (event) => eventBus.hasHandler(event)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ipc = {
|
this.ipc = {
|
||||||
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
||||||
on: (channel, handler) => ipc.on(channel, handler),
|
on: (channel, handler) => ipc.on(channel, handler)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.exports = {
|
this.exports = {
|
||||||
registerPreHook: (handler) => {
|
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
|
||||||
if (exportHooks) exportHooks.preHooks.push(handler);
|
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
|
||||||
},
|
|
||||||
registerPostHook: (handler) => {
|
|
||||||
if (exportHooks) exportHooks.postHooks.push(handler);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ class PluginLoader {
|
|||||||
const loaded = require(indexPath);
|
const loaded = require(indexPath);
|
||||||
PluginClass = loaded.Plugin || loaded.default || null;
|
PluginClass = loaded.Plugin || loaded.default || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
|
||||||
`[PluginLoader] Failed to load index.js for "${manifest.id}":`,
|
|
||||||
err.message
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +46,7 @@ class PluginLoader {
|
|||||||
description: manifest.description,
|
description: manifest.description,
|
||||||
manifest,
|
manifest,
|
||||||
PluginClass,
|
PluginClass,
|
||||||
dir: pluginDir,
|
dir: pluginDir
|
||||||
});
|
});
|
||||||
this.loadedIds.add(manifest.id);
|
this.loadedIds.add(manifest.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class PluginRegistry {
|
|||||||
settings: this.deps.settings,
|
settings: this.deps.settings,
|
||||||
editor: this.deps.editor,
|
editor: this.deps.editor,
|
||||||
ipc: this.deps.ipc,
|
ipc: this.deps.ipc,
|
||||||
exportHooks: this.exportHooks,
|
exportHooks: this.exportHooks
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class SettingsStore {
|
|||||||
this.backend.set(key, value);
|
this.backend.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChanged(_key, _callback) {
|
onChanged(key, callback) {
|
||||||
// Deferred: plugins read settings on init/activate for MVP.
|
// Deferred: plugins read settings on init/activate for MVP.
|
||||||
// Full change notification requires IPC watcher in main process.
|
// Full change notification requires IPC watcher in main process.
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-25
@@ -140,7 +140,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
|
|
||||||
// Plugin settings
|
// Plugin settings
|
||||||
'plugin-settings:get',
|
'plugin-settings:get',
|
||||||
'plugin-settings:set',
|
'plugin-settings:set'
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -237,7 +237,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-command-palette',
|
'toggle-command-palette',
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel',
|
'toggle-bottom-panel'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -344,23 +344,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
|
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
|
||||||
},
|
},
|
||||||
|
|
||||||
// Theme Operations
|
// Theme Operations
|
||||||
theme: {
|
theme: {
|
||||||
get: () => ipcRenderer.send('get-theme'),
|
get: () => ipcRenderer.send('get-theme')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Print Operations
|
// Print Operations
|
||||||
print: {
|
print: {
|
||||||
doPrint: (options) => ipcRenderer.send('do-print', options),
|
doPrint: (options) => ipcRenderer.send('do-print', options)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Export Operations
|
// Export Operations
|
||||||
export: {
|
export: {
|
||||||
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
|
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
|
||||||
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }),
|
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format })
|
||||||
},
|
},
|
||||||
|
|
||||||
// Batch Conversion
|
// Batch Conversion
|
||||||
@@ -368,7 +368,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
convert: (inputFolder, outputFolder, format, options) => {
|
convert: (inputFolder, outputFolder, format, options) => {
|
||||||
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
|
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
|
||||||
},
|
},
|
||||||
selectFolder: (type) => ipcRenderer.send('select-folder', type),
|
selectFolder: (type) => ipcRenderer.send('select-folder', type)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Universal Converter
|
// Universal Converter
|
||||||
@@ -377,14 +377,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
|
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
|
||||||
},
|
},
|
||||||
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
|
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
|
||||||
ipcRenderer.send('universal-convert-batch', {
|
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder });
|
||||||
tool,
|
}
|
||||||
fromFormat,
|
|
||||||
toFormat,
|
|
||||||
inputFolder,
|
|
||||||
outputFolder,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Header/Footer Operations
|
// Header/Footer Operations
|
||||||
@@ -392,23 +386,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
|
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
|
||||||
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
|
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
|
||||||
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
|
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
|
||||||
saveLogo: (position, filePath) =>
|
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
||||||
ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position)
|
||||||
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Settings
|
// Page Settings
|
||||||
page: {
|
page: {
|
||||||
getSettings: () => ipcRenderer.send('get-page-settings'),
|
getSettings: () => ipcRenderer.send('get-page-settings'),
|
||||||
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
|
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
|
||||||
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
|
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber)
|
||||||
},
|
},
|
||||||
|
|
||||||
// PDF Operations
|
// PDF Operations
|
||||||
pdf: {
|
pdf: {
|
||||||
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
|
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
|
||||||
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
|
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
|
||||||
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
|
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Image Converter Operations
|
// Image Converter Operations
|
||||||
@@ -417,7 +410,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
||||||
resize: (data) => ipcRenderer.send('image-resize', data),
|
resize: (data) => ipcRenderer.send('image-resize', data),
|
||||||
compress: (data) => ipcRenderer.send('image-compress', data),
|
compress: (data) => ipcRenderer.send('image-compress', data),
|
||||||
rotate: (data) => ipcRenderer.send('image-rotate', data),
|
rotate: (data) => ipcRenderer.send('image-rotate', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Audio Converter Operations
|
// Audio Converter Operations
|
||||||
@@ -426,7 +419,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
||||||
extract: (data) => ipcRenderer.send('audio-extract', data),
|
extract: (data) => ipcRenderer.send('audio-extract', data),
|
||||||
trim: (data) => ipcRenderer.send('audio-trim', data),
|
trim: (data) => ipcRenderer.send('audio-trim', data),
|
||||||
merge: (data) => ipcRenderer.send('audio-merge', data),
|
merge: (data) => ipcRenderer.send('audio-merge', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Video Converter Operations
|
// Video Converter Operations
|
||||||
@@ -436,16 +429,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
compress: (data) => ipcRenderer.send('video-compress', data),
|
compress: (data) => ipcRenderer.send('video-compress', data),
|
||||||
trim: (data) => ipcRenderer.send('video-trim', data),
|
trim: (data) => ipcRenderer.send('video-trim', data),
|
||||||
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
||||||
toGif: (data) => ipcRenderer.send('video-gif', data),
|
toGif: (data) => ipcRenderer.send('video-gif', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Generator Windows
|
// Generator Windows
|
||||||
generators: {
|
generators: {
|
||||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
||||||
openTable: () => ipcRenderer.send('open-table-generator'),
|
openTable: () => ipcRenderer.send('open-table-generator')
|
||||||
},
|
},
|
||||||
|
|
||||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version')
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log successful preload initialization
|
// Log successful preload initialization
|
||||||
|
|||||||
+91
-91
@@ -1,85 +1,85 @@
|
|||||||
class PrintPreview {
|
class PrintPreview {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.overlay = document.getElementById('print-preview-overlay');
|
this.overlay = document.getElementById('print-preview-overlay');
|
||||||
this.modal = window.modals?.printPreviewModal;
|
this.modal = window.modals?.printPreviewModal;
|
||||||
this._lastContent = '';
|
this._lastContent = '';
|
||||||
this.setupEventListeners();
|
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() {
|
open(htmlContent) {
|
||||||
if (this.modal) {
|
this._lastContent = htmlContent;
|
||||||
this.modal.close();
|
if (this.modal) {
|
||||||
} else {
|
this.modal.open();
|
||||||
this.overlay.classList.add('hidden');
|
} else {
|
||||||
|
this.overlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
this.updatePreview(htmlContent);
|
||||||
|
this.updateScaleLabel();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
close() {
|
||||||
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
|
if (this.modal) {
|
||||||
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
|
this.modal.close();
|
||||||
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
|
} else {
|
||||||
|
this.overlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update preview on option changes
|
setupEventListeners() {
|
||||||
['print-paper-size', 'print-orientation', 'print-margins'].forEach((id) => {
|
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
|
||||||
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
|
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
|
||||||
});
|
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
|
||||||
|
|
||||||
// Scale slider
|
// Update preview on option changes
|
||||||
const scaleSlider = document.getElementById('print-scale');
|
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
|
||||||
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
|
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
|
||||||
|
});
|
||||||
|
|
||||||
// Page range toggle
|
// Scale slider
|
||||||
document.getElementById('print-pages')?.addEventListener('change', (e) => {
|
const scaleSlider = document.getElementById('print-scale');
|
||||||
const rangeInput = document.getElementById('print-page-range');
|
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
|
||||||
if (rangeInput) {
|
|
||||||
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: Backdrop click and Escape key are now handled by ModalManager
|
// 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
updateScaleLabel() {
|
// Note: Backdrop click and Escape key are now handled by ModalManager
|
||||||
const scale = document.getElementById('print-scale')?.value || 100;
|
}
|
||||||
const label = document.getElementById('print-scale-value');
|
|
||||||
if (label) label.textContent = `${scale}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreview(htmlContent) {
|
updateScaleLabel() {
|
||||||
const frame = document.getElementById('print-preview-frame');
|
const scale = document.getElementById('print-scale')?.value || 100;
|
||||||
if (!frame) return;
|
const label = document.getElementById('print-scale-value');
|
||||||
|
if (label) label.textContent = `${scale}%`;
|
||||||
|
}
|
||||||
|
|
||||||
this._lastContent = htmlContent;
|
updatePreview(htmlContent) {
|
||||||
|
const frame = document.getElementById('print-preview-frame');
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
|
this._lastContent = htmlContent;
|
||||||
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
|
|
||||||
|
|
||||||
// Get dimensions for paper size
|
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
|
||||||
const sizes = {
|
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
|
||||||
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'];
|
// Get dimensions for paper size
|
||||||
const width = orientation === 'landscape' ? size.height : size.width;
|
const sizes = {
|
||||||
const height = orientation === 'landscape' ? size.width : size.height;
|
'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 previewHtml = `
|
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>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -105,34 +105,34 @@ class PrintPreview {
|
|||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
frame.srcdoc = previewHtml;
|
frame.srcdoc = previewHtml;
|
||||||
}
|
|
||||||
|
|
||||||
refreshPreview() {
|
|
||||||
if (this._lastContent) {
|
|
||||||
this.updatePreview(this._lastContent);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
getOptions() {
|
refreshPreview() {
|
||||||
return {
|
if (this._lastContent) {
|
||||||
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
|
this.updatePreview(this._lastContent);
|
||||||
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() {
|
getOptions() {
|
||||||
const options = this.getOptions();
|
return {
|
||||||
const { ipcRenderer } = require('electron');
|
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
|
||||||
ipcRenderer.send('do-print-with-options', options);
|
orientation: document.getElementById('print-orientation')?.value || 'portrait',
|
||||||
this.close();
|
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 };
|
module.exports = { PrintPreview };
|
||||||
|
|||||||
+4401
-5191
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
import { AppShell } from './components/layout/AppShell';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <AppShell />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
|
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { lightTheme, lightHighlight } from './themes/light';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bufferId: string;
|
||||||
|
initialContent: string;
|
||||||
|
onChange?: (content: string) => void;
|
||||||
|
onCursorChange?: (line: number, column: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const themeCompartment = useRef(new Compartment());
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const updateContent = useEditorStore((s) => s.updateContent);
|
||||||
|
const setCursor = useEditorStore((s) => s.setCursor);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: initialContent,
|
||||||
|
extensions: [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||||
|
autocompletion(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
|
||||||
|
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.updateListener.of((v) => {
|
||||||
|
if (v.docChanged) {
|
||||||
|
const content = v.state.doc.toString();
|
||||||
|
updateContent(bufferId, content);
|
||||||
|
onChange?.(content);
|
||||||
|
}
|
||||||
|
if (v.selectionSet || v.docChanged) {
|
||||||
|
const head = v.state.selection.main.head;
|
||||||
|
const line = v.state.doc.lineAt(head);
|
||||||
|
const lineNo = line.number;
|
||||||
|
const col = head - line.from + 1;
|
||||||
|
setCursor(bufferId, lineNo, col);
|
||||||
|
onCursorChange?.(lineNo, col);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const view = new EditorView({ state, parent: ref.current });
|
||||||
|
viewRef.current = view;
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bufferId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.current.reconfigure(
|
||||||
|
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
return <div ref={ref} className="h-full overflow-hidden" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
export function EditorPane() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
|
||||||
|
if (!buf) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
|
||||||
|
<p>No file open. Use File → Open to start.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<CodeMirrorEditor
|
||||||
|
key={buf.id}
|
||||||
|
bufferId={buf.id}
|
||||||
|
initialContent={buf.content}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#0d0b09',
|
||||||
|
cursor: '#e5461f',
|
||||||
|
selection: 'rgba(229, 70, 31, 0.15)',
|
||||||
|
gutterBackground: '#fafbfc',
|
||||||
|
gutterForeground: '#7a7878',
|
||||||
|
lineHighlight: 'rgba(0, 0, 0, 0.04)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
color: colors.foreground,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: colors.cursor,
|
||||||
|
fontFamily: 'JetBrains Mono, Fira Code, monospace',
|
||||||
|
fontSize: '13.5px',
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: colors.selection,
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: colors.gutterBackground,
|
||||||
|
color: colors.gutterForeground,
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: colors.lineHighlight },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' },
|
||||||
|
},
|
||||||
|
{ dark: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.heading1, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading2, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading3, color: '#464646', fontWeight: '600' },
|
||||||
|
{ tag: t.link, color: '#e5461f', textDecoration: 'underline' },
|
||||||
|
{ tag: t.url, color: '#e5461f' },
|
||||||
|
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||||
|
{ tag: t.strong, fontWeight: '700' },
|
||||||
|
{ tag: t.monospace, color: '#c93a18' },
|
||||||
|
{ tag: t.list, color: '#0ea5e9' },
|
||||||
|
{ tag: t.quote, color: '#7a7878', fontStyle: 'italic' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const lightHighlight = syntaxHighlighting(highlightStyle);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { PanelLeft, PanelRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { sidebarVisible, previewVisible, toggleSidebar, togglePreview } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
|
||||||
|
aria-label="MarkdownConverter logo"
|
||||||
|
/>
|
||||||
|
<h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
aria-pressed={sidebarVisible}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle preview"
|
||||||
|
aria-pressed={previewVisible}
|
||||||
|
onClick={togglePreview}
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { AppHeader } from './AppHeader';
|
||||||
|
import { TabBar } from './TabBar';
|
||||||
|
import { Toolbar } from './Toolbar';
|
||||||
|
import { Breadcrumb } from './Breadcrumb';
|
||||||
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
<AppHeader />
|
||||||
|
<TabBar />
|
||||||
|
<Toolbar />
|
||||||
|
<Breadcrumb />
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
|
||||||
|
>
|
||||||
|
{sidebarVisible && (
|
||||||
|
<>
|
||||||
|
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
||||||
|
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
||||||
|
File tree placeholder
|
||||||
|
</aside>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
||||||
|
<EditorPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
{previewVisible && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
||||||
|
<section className="h-full border-l border-border bg-card/10 p-4 text-sm text-muted-foreground">
|
||||||
|
Preview placeholder
|
||||||
|
</section>
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</main>
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function Breadcrumb() {
|
||||||
|
return (
|
||||||
|
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No file selected</span>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
const wordCount = buf ? countWords(buf.content) : 0;
|
||||||
|
const cursor = buf?.cursor ?? { line: 1, column: 1 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>{wordCount} words</span>
|
||||||
|
<span>UTF-8</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
||||||
|
<span>Markdown</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function TabBar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No files open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function Toolbar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3">
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Bold"><Bold className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Italic"><Italic className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Unordered list"><List className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Ordered list"><ListOrdered className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Code"><Code className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Link"><LinkIcon className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Toggle theme" className="opacity-0">
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanelGroup.displayName = "ResizablePanelGroup"
|
||||||
|
|
||||||
|
const ResizablePanel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Panel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Panel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Panel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanel.displayName = "ResizablePanel"
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
)
|
||||||
|
ResizableHandle.displayName = "ResizableHandle"
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MarkdownConverter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="font-sans antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type {
|
||||||
|
IpcResult,
|
||||||
|
FileResult,
|
||||||
|
FileEntry,
|
||||||
|
PdfOptions,
|
||||||
|
DocxOptions,
|
||||||
|
HtmlOptions,
|
||||||
|
ExportResult,
|
||||||
|
BatchItem,
|
||||||
|
BatchOptions,
|
||||||
|
BatchResult,
|
||||||
|
} from '@/types/ipc';
|
||||||
|
|
||||||
|
type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string };
|
||||||
|
|
||||||
|
function wrap<T>(fn: () => Promise<T>): Promise<IpcResult<T | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fn().then(
|
||||||
|
(data) => ({ ok: true as const, data }),
|
||||||
|
(err: Error) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: { code: err.name || 'IPC_ERROR', message: err.message || String(err) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCall<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
channel: string,
|
||||||
|
method: string,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): Promise<IpcResult<Awaited<ReturnType<T>> | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = (window.electronAPI as any)[channel]?.[method];
|
||||||
|
if (!target) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Missing channel: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof target !== 'function') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Not a function: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wrap(() => target(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'open'),
|
||||||
|
read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'read', path),
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'write', path, content),
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'list', dir),
|
||||||
|
onChange: (cb: (path: string) => void): (() => void) => {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
return window.electronAPI.file.onChange(cb);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'pdf', opts),
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'docx', opts),
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'html', opts),
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'batch', items, opts),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'getVersion'),
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'openExternal', url),
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'showItemInFolder', path),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Transition, Variants } from 'motion/react';
|
||||||
|
|
||||||
|
export const fadeIn: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slideInRight: Variants = {
|
||||||
|
x: '100%',
|
||||||
|
initial: { x: '100%', opacity: 0 },
|
||||||
|
animate: { x: 0, opacity: 1, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] } },
|
||||||
|
exit: { x: '100%', opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modalPop: Variants = {
|
||||||
|
scale: 0.96,
|
||||||
|
opacity: 0,
|
||||||
|
initial: { scale: 0.96, opacity: 0 },
|
||||||
|
animate: { scale: 1, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } },
|
||||||
|
exit: { scale: 0.96, opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastSpring: Transition = {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sidebarToggle: Transition = {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
width: {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tabSwitch: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import { ThemeProvider } from './components/theme-provider';
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface PaneSizes {
|
||||||
|
sidebar: number;
|
||||||
|
editor: number;
|
||||||
|
preview: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
sidebarVisible: boolean;
|
||||||
|
previewVisible: boolean;
|
||||||
|
zenMode: boolean;
|
||||||
|
paneSizes: PaneSizes;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
togglePreview: () => void;
|
||||||
|
setZenMode: (value: boolean) => void;
|
||||||
|
setPaneSizes: (sizes: PaneSizes) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
|
||||||
|
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
|
||||||
|
setZenMode: (value) => set({ zenMode: value }),
|
||||||
|
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
|
||||||
|
}),
|
||||||
|
{ name: 'mc-app-store' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
|
export interface Buffer {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
dirty: boolean;
|
||||||
|
cursor?: { line: number; column: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
buffers: Map<string, Buffer>;
|
||||||
|
activeId: string | null;
|
||||||
|
openBuffer: (id: string, path: string, content: string) => void;
|
||||||
|
updateContent: (id: string, content: string) => void;
|
||||||
|
markSaved: (id: string) => void;
|
||||||
|
setCursor: (id: string, line: number, column: number) => void;
|
||||||
|
closeBuffer: (id: string) => void;
|
||||||
|
setActive: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>()(
|
||||||
|
immer((set) => ({
|
||||||
|
buffers: new Map(),
|
||||||
|
activeId: null,
|
||||||
|
openBuffer: (id, path, content) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.set(id, { id, path, content, dirty: false });
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
updateContent: (id, content) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) {
|
||||||
|
buf.content = content;
|
||||||
|
buf.dirty = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
markSaved: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.dirty = false;
|
||||||
|
}),
|
||||||
|
setCursor: (id, line, column) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.cursor = { line, column };
|
||||||
|
}),
|
||||||
|
closeBuffer: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.delete(id);
|
||||||
|
if (s.activeId === id) s.activeId = null;
|
||||||
|
}),
|
||||||
|
setActive: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 9%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 0 0% 96%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 90%;
|
||||||
|
--input: 0 0% 90%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 30 14% 4%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 30 14% 6%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 30 14% 6%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 30 6% 15%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 30 6% 15%;
|
||||||
|
--muted-foreground: 0 0% 63%;
|
||||||
|
--accent: 30 6% 15%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62% 30%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 30 6% 15%;
|
||||||
|
--input: 30 6% 15%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror integration */
|
||||||
|
.cm-editor {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
.cm-editor .cm-scroller {
|
||||||
|
@apply font-mono text-sm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// Mock window.electronAPI for tests
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||||
|
window.electronAPI = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock matchMedia for next-themes (jsdom doesn't have it)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
|
||||||
|
}
|
||||||
Vendored
+90
@@ -0,0 +1,90 @@
|
|||||||
|
export interface ElectronAPI {
|
||||||
|
// App info
|
||||||
|
getAppVersion: () => Promise<string>;
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>;
|
||||||
|
saveFile: (data: { path: string; content: string }) => Promise<void>;
|
||||||
|
readFile: (path: string) => Promise<string>;
|
||||||
|
setCurrentFile: (path: string | null) => void;
|
||||||
|
|
||||||
|
// Export
|
||||||
|
exportDocument: (format: string, options?: Record<string, any>) => Promise<void>;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
getTheme: () => void;
|
||||||
|
onThemeChanged: (callback: (theme: string) => void) => () => void;
|
||||||
|
|
||||||
|
// File events
|
||||||
|
onFileOpened: (callback: (data: { path: string; content: string }) => void) => () => void;
|
||||||
|
onFileNew: (callback: () => void) => () => void;
|
||||||
|
onFileSave: (callback: () => void) => () => void;
|
||||||
|
|
||||||
|
// Conversion status
|
||||||
|
onConversionStatus: (callback: (message: string) => void) => () => void;
|
||||||
|
onConversionComplete: (callback: (data: { format: string; outputPath: string }) => void) => () => void;
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
showExportDialog: (format: string) => void;
|
||||||
|
showBatchDialog: () => void;
|
||||||
|
showUniversalConverterDialog: () => void;
|
||||||
|
showTableGenerator: () => void;
|
||||||
|
showPdfEditorDialog: () => void;
|
||||||
|
showHeaderFooterDialog: () => void;
|
||||||
|
showFieldPickerDialog: () => void;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: (key: string) => Promise<any>;
|
||||||
|
setSettings: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Plugin settings
|
||||||
|
getPluginSetting: (key: string) => Promise<any>;
|
||||||
|
setPluginSetting: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
toggleBottomPanel: () => void;
|
||||||
|
|
||||||
|
// Print
|
||||||
|
doPrint: (options?: any) => void;
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
openPdfViewer: (filePath: string) => void;
|
||||||
|
processPdfOperation: (data: any) => void;
|
||||||
|
|
||||||
|
// Images
|
||||||
|
selectFolder: () => Promise<string | null>;
|
||||||
|
savePastedImage: (data: { base64: string; ext: string }) => Promise<{ relativePath: string } | null>;
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
loadTemplate: (file: string) => Promise<string>;
|
||||||
|
getSnippets: () => Promise<any[]>;
|
||||||
|
saveSnippet: (snippet: any) => Promise<void>;
|
||||||
|
deleteSnippet: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Git
|
||||||
|
gitStatus: () => Promise<any>;
|
||||||
|
gitDiff: (file: string) => Promise<any>;
|
||||||
|
gitStage: (files: string[]) => Promise<void>;
|
||||||
|
gitCommit: (message: string) => Promise<void>;
|
||||||
|
gitLog: () => Promise<any[]>;
|
||||||
|
|
||||||
|
// Directory
|
||||||
|
listDirectory: (dir: string) => Promise<any[] | null>;
|
||||||
|
openFilePath: (path: string) => void;
|
||||||
|
|
||||||
|
// Custom CSS
|
||||||
|
selectCustomCSS: () => Promise<string | null>;
|
||||||
|
loadCustomCSS: () => void;
|
||||||
|
clearCustomCSS: () => void;
|
||||||
|
|
||||||
|
// Generic invoke/on
|
||||||
|
invoke: (channel: string, data?: any) => Promise<any>;
|
||||||
|
on: (channel: string, callback: (...args: any[]) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size?: number;
|
||||||
|
modifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileResult {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
format?: 'letter' | 'a4' | 'legal';
|
||||||
|
margins?: { top: number; right: number; bottom: number; left: number };
|
||||||
|
toc?: boolean;
|
||||||
|
embedFonts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
template?: string;
|
||||||
|
referenceDoc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmlOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
highlightStyle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
outputPath: string;
|
||||||
|
bytes: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchItem {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOptions {
|
||||||
|
format: 'pdf' | 'docx' | 'html' | 'png';
|
||||||
|
concurrency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchResult {
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ item: BatchItem; ok: boolean; error?: string }>;
|
||||||
|
}
|
||||||
+34
-34
@@ -1,49 +1,49 @@
|
|||||||
class ReplPanel {
|
class ReplPanel {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.panel = document.getElementById('bottom-panel');
|
this.panel = document.getElementById('bottom-panel');
|
||||||
this.output = document.getElementById('repl-output');
|
this.output = document.getElementById('repl-output');
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
document.getElementById('bottom-panel-toggle')?.addEventListener('click', () => this.toggle());
|
document.getElementById('bottom-panel-toggle')?.addEventListener('click', () => this.toggle());
|
||||||
document.getElementById('repl-clear')?.addEventListener('click', () => this.clear());
|
document.getElementById('repl-clear')?.addEventListener('click', () => this.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.panel.classList.toggle('collapsed');
|
this.panel.classList.toggle('collapsed');
|
||||||
const btn = document.getElementById('bottom-panel-toggle');
|
const btn = document.getElementById('bottom-panel-toggle');
|
||||||
if (btn) btn.textContent = this.panel.classList.contains('collapsed') ? '\u25BC' : '\u25B2';
|
if (btn) btn.textContent = this.panel.classList.contains('collapsed') ? '\u25BC' : '\u25B2';
|
||||||
}
|
}
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
this.panel.classList.remove('collapsed');
|
this.panel.classList.remove('collapsed');
|
||||||
const btn = document.getElementById('bottom-panel-toggle');
|
const btn = document.getElementById('bottom-panel-toggle');
|
||||||
if (btn) btn.textContent = '\u25B2';
|
if (btn) btn.textContent = '\u25B2';
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
if (this.output) this.output.innerHTML = '';
|
if (this.output) this.output.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
appendOutput(command, result) {
|
appendOutput(command, result) {
|
||||||
const entry = document.createElement('div');
|
const entry = document.createElement('div');
|
||||||
entry.className = 'repl-entry';
|
entry.className = 'repl-entry';
|
||||||
entry.innerHTML = `
|
entry.innerHTML = `
|
||||||
<div class="repl-command">\u25B6 ${command}</div>
|
<div class="repl-command">\u25B6 ${command}</div>
|
||||||
${result.stdout ? `<div class="repl-stdout">${this.escapeHtml(result.stdout)}</div>` : ''}
|
${result.stdout ? `<div class="repl-stdout">${this.escapeHtml(result.stdout)}</div>` : ''}
|
||||||
${result.stderr ? `<div class="repl-stderr">${this.escapeHtml(result.stderr)}</div>` : ''}
|
${result.stderr ? `<div class="repl-stderr">${this.escapeHtml(result.stderr)}</div>` : ''}
|
||||||
${result.error ? `<div class="repl-error">Error: ${this.escapeHtml(result.error)}</div>` : ''}
|
${result.error ? `<div class="repl-error">Error: ${this.escapeHtml(result.error)}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
this.output?.appendChild(entry);
|
this.output?.appendChild(entry);
|
||||||
this.output?.scrollTo(0, this.output.scrollHeight);
|
this.output?.scrollTo(0, this.output.scrollHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ReplPanel };
|
module.exports = { ReplPanel };
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
|
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="explorer-panel">
|
<div class="explorer-panel">
|
||||||
<div class="explorer-toolbar">
|
<div class="explorer-toolbar">
|
||||||
<input type="text" class="explorer-path" id="explorer-path" value="${currentDir || ''}" placeholder="Open a folder..." readonly>
|
<input type="text" class="explorer-path" id="explorer-path" value="${currentDir || ''}" placeholder="Open a folder..." readonly>
|
||||||
@@ -9,86 +11,60 @@ function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('explorer-browse')?.addEventListener('click', async () => {
|
document.getElementById('explorer-browse')?.addEventListener('click', async () => {
|
||||||
const dir = await listDirectory(null); // null means open folder dialog
|
const dir = await listDirectory(null); // null means open folder dialog
|
||||||
if (dir) {
|
if (dir) {
|
||||||
document.getElementById('explorer-path').value = dir.path;
|
document.getElementById('explorer-path').value = dir.path;
|
||||||
renderTree(
|
renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, dir.path);
|
||||||
document.getElementById('explorer-tree'),
|
}
|
||||||
dir.entries,
|
|
||||||
listDirectory,
|
|
||||||
onFileOpen,
|
|
||||||
dir.path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentDir) {
|
|
||||||
listDirectory(currentDir).then((dir) => {
|
|
||||||
if (dir)
|
|
||||||
renderTree(
|
|
||||||
document.getElementById('explorer-tree'),
|
|
||||||
dir.entries,
|
|
||||||
listDirectory,
|
|
||||||
onFileOpen,
|
|
||||||
currentDir
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
if (currentDir) {
|
||||||
|
listDirectory(currentDir).then(dir => {
|
||||||
|
if (dir) renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, currentDir);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTree(container, entries, listDirectory, onFileOpen, _basePath) {
|
function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
|
||||||
container.innerHTML = entries
|
container.innerHTML = entries.map(entry => {
|
||||||
.map((entry) => {
|
if (entry.isDirectory) {
|
||||||
if (entry.isDirectory) {
|
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
|
||||||
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
|
|
||||||
<span class="tree-icon">▶</span>
|
<span class="tree-icon">▶</span>
|
||||||
<span class="tree-name">${entry.name}</span>
|
<span class="tree-name">${entry.name}</span>
|
||||||
<div class="tree-children"></div>
|
<div class="tree-children"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
return `<div class="tree-item tree-file" data-path="${entry.path}">
|
return `<div class="tree-item tree-file" data-path="${entry.path}">
|
||||||
<span class="tree-icon">${getFileIcon(entry.name)}</span>
|
<span class="tree-icon">${getFileIcon(entry.name)}</span>
|
||||||
<span class="tree-name">${entry.name}</span>
|
<span class="tree-name">${entry.name}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
})
|
}).join('');
|
||||||
.join('');
|
|
||||||
|
|
||||||
container.querySelectorAll('.tree-folder').forEach((el) => {
|
container.querySelectorAll('.tree-folder').forEach(el => {
|
||||||
el.querySelector('.tree-name').addEventListener('click', async () => {
|
el.querySelector('.tree-name').addEventListener('click', async () => {
|
||||||
const isCollapsed = el.classList.contains('collapsed');
|
const isCollapsed = el.classList.contains('collapsed');
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
const dir = await listDirectory(el.dataset.path);
|
const dir = await listDirectory(el.dataset.path);
|
||||||
if (dir) {
|
if (dir) {
|
||||||
const childContainer = el.querySelector('.tree-children');
|
const childContainer = el.querySelector('.tree-children');
|
||||||
renderTree(childContainer, dir.entries, listDirectory, onFileOpen, el.dataset.path);
|
renderTree(childContainer, dir.entries, listDirectory, onFileOpen, el.dataset.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.classList.toggle('collapsed');
|
el.classList.toggle('collapsed');
|
||||||
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed')
|
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed') ? '\u25B6' : '\u25BC';
|
||||||
? '\u25B6'
|
});
|
||||||
: '\u25BC';
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.tree-file').forEach((el) => {
|
container.querySelectorAll('.tree-file').forEach(el => {
|
||||||
el.addEventListener('click', () => onFileOpen(el.dataset.path));
|
el.addEventListener('click', () => onFileOpen(el.dataset.path));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileIcon(filename) {
|
function getFileIcon(filename) {
|
||||||
const ext = filename.split('.').pop().toLowerCase();
|
const ext = filename.split('.').pop().toLowerCase();
|
||||||
const icons = {
|
const icons = { md: '\u{1F4DD}', js: '\u{1F4DC}', json: '{}', html: '\u{1F310}', css: '\u{1F3A8}', py: '\u{1F40D}', pdf: '\u{1F4D5}', txt: '\u{1F4C4}' };
|
||||||
md: '\u{1F4DD}',
|
return icons[ext] || '\u{1F4C4}';
|
||||||
js: '\u{1F4DC}',
|
|
||||||
json: '{}',
|
|
||||||
html: '\u{1F310}',
|
|
||||||
css: '\u{1F3A8}',
|
|
||||||
py: '\u{1F40D}',
|
|
||||||
pdf: '\u{1F4D5}',
|
|
||||||
txt: '\u{1F4C4}',
|
|
||||||
};
|
|
||||||
return icons[ext] || '\u{1F4C4}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { renderExplorerPanel };
|
module.exports = { renderExplorerPanel };
|
||||||
|
|||||||
+45
-55
@@ -1,5 +1,5 @@
|
|||||||
function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, gitLog }) {
|
function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gitLog }) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="git-panel">
|
<div class="git-panel">
|
||||||
<div class="git-section">
|
<div class="git-section">
|
||||||
<h4 class="git-section-title">Changes</h4>
|
<h4 class="git-section-title">Changes</h4>
|
||||||
@@ -19,76 +19,66 @@ function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, g
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
loadGitStatus();
|
loadGitStatus();
|
||||||
|
|
||||||
async function loadGitStatus() {
|
async function loadGitStatus() {
|
||||||
const status = await gitStatus();
|
const status = await gitStatus();
|
||||||
const changesEl = document.getElementById('git-changes');
|
const changesEl = document.getElementById('git-changes');
|
||||||
if (!status || !changesEl) return;
|
if (!status || !changesEl) return;
|
||||||
|
|
||||||
if (status.error) {
|
if (status.error) {
|
||||||
changesEl.innerHTML = `<p class="git-info">${status.error}</p>`;
|
changesEl.innerHTML = `<p class="git-info">${status.error}</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = [
|
const files = [
|
||||||
...status.modified.map((f) => ({ file: f, status: 'M', color: '#f59e0b' })),
|
...status.modified.map(f => ({ file: f, status: 'M', color: '#f59e0b' })),
|
||||||
...status.not_added.map((f) => ({ file: f, status: '?', color: '#6b7280' })),
|
...status.not_added.map(f => ({ file: f, status: '?', color: '#6b7280' })),
|
||||||
...status.created.map((f) => ({ file: f, status: 'A', color: '#10b981' })),
|
...status.created.map(f => ({ file: f, status: 'A', color: '#10b981' })),
|
||||||
...status.deleted.map((f) => ({ file: f, status: 'D', color: '#ef4444' })),
|
...status.deleted.map(f => ({ file: f, status: 'D', color: '#ef4444' })),
|
||||||
...status.staged.map((f) => ({ file: f, status: 'S', color: '#3b82f6' })),
|
...status.staged.map(f => ({ file: f, status: 'S', color: '#3b82f6' })),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
changesEl.innerHTML = '<p class="git-info">No changes</p>';
|
changesEl.innerHTML = '<p class="git-info">No changes</p>';
|
||||||
} else {
|
} else {
|
||||||
changesEl.innerHTML = files
|
changesEl.innerHTML = files.map(f => `
|
||||||
.map(
|
|
||||||
(f) => `
|
|
||||||
<div class="git-file" data-file="${f.file}">
|
<div class="git-file" data-file="${f.file}">
|
||||||
<span class="git-file-status" style="color:${f.color}">${f.status}</span>
|
<span class="git-file-status" style="color:${f.color}">${f.status}</span>
|
||||||
<span class="git-file-name">${f.file}</span>
|
<span class="git-file-name">${f.file}</span>
|
||||||
<button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button>
|
<button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`).join('');
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
changesEl.querySelectorAll('.git-stage-btn').forEach((btn) => {
|
changesEl.querySelectorAll('.git-stage-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
await gitStage([btn.dataset.file]);
|
await gitStage([btn.dataset.file]);
|
||||||
loadGitStatus();
|
loadGitStatus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load log
|
// Load log
|
||||||
const log = await gitLog();
|
const log = await gitLog();
|
||||||
const logEl = document.getElementById('git-log');
|
const logEl = document.getElementById('git-log');
|
||||||
if (log && logEl) {
|
if (log && logEl) {
|
||||||
logEl.innerHTML =
|
logEl.innerHTML = (log.all || []).slice(0, 10).map(entry => `
|
||||||
(log.all || [])
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(
|
|
||||||
(entry) => `
|
|
||||||
<div class="git-log-entry">
|
<div class="git-log-entry">
|
||||||
<div class="git-log-msg">${entry.message}</div>
|
<div class="git-log-msg">${entry.message}</div>
|
||||||
<div class="git-log-meta">${entry.date?.substring(0, 10) || ''} · ${entry.author_name || ''}</div>
|
<div class="git-log-meta">${entry.date?.substring(0, 10) || ''} · ${entry.author_name || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`).join('') || '<p class="git-info">No commits</p>';
|
||||||
)
|
}
|
||||||
.join('') || '<p class="git-info">No commits</p>';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('git-commit-btn')?.addEventListener('click', async () => {
|
document.getElementById('git-commit-btn')?.addEventListener('click', async () => {
|
||||||
const msg = document.getElementById('git-commit-msg')?.value?.trim();
|
const msg = document.getElementById('git-commit-msg')?.value?.trim();
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
await gitCommit(msg);
|
await gitCommit(msg);
|
||||||
document.getElementById('git-commit-msg').value = '';
|
document.getElementById('git-commit-msg').value = '';
|
||||||
loadGitStatus();
|
loadGitStatus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { renderGitPanel };
|
module.exports = { renderGitPanel };
|
||||||
|
|||||||
@@ -4,54 +4,54 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHeadingClick }) {
|
function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHeadingClick }) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="outline-panel">
|
<div class="outline-panel">
|
||||||
<div class="outline-list" id="outline-list"></div>
|
<div class="outline-list" id="outline-list"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const listEl = document.getElementById('outline-list');
|
const listEl = document.getElementById('outline-list');
|
||||||
let headings = [];
|
let headings = [];
|
||||||
let activeLine = 1;
|
let activeLine = 1;
|
||||||
let debounceTimer = null;
|
let debounceTimer = null;
|
||||||
|
|
||||||
function parseHeadings(content) {
|
function parseHeadings(content) {
|
||||||
const result = [];
|
const result = [];
|
||||||
if (!content) return result;
|
if (!content) return result;
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const regex = /^(#{1,6})\s+(.+)$/;
|
const regex = /^(#{1,6})\s+(.+)$/;
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const match = regex.exec(lines[i]);
|
const match = regex.exec(lines[i]);
|
||||||
if (match) {
|
if (match) {
|
||||||
result.push({
|
result.push({
|
||||||
level: match[1].length,
|
level: match[1].length,
|
||||||
text: match[2].trim(),
|
text: match[2].trim(),
|
||||||
line: i + 1,
|
line: i + 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findActiveHeading(currentLine) {
|
function findActiveHeading(currentLine) {
|
||||||
let active = null;
|
let active = null;
|
||||||
for (const h of headings) {
|
for (const h of headings) {
|
||||||
if (h.line <= currentLine) {
|
if (h.line <= currentLine) {
|
||||||
active = h;
|
active = h;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return active;
|
||||||
}
|
}
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHeadings() {
|
function renderHeadings() {
|
||||||
const content = getEditorContent();
|
const content = getEditorContent();
|
||||||
headings = parseHeadings(content);
|
headings = parseHeadings(content);
|
||||||
activeLine = getActiveLine();
|
activeLine = getActiveLine();
|
||||||
|
|
||||||
if (headings.length === 0) {
|
if (headings.length === 0) {
|
||||||
listEl.innerHTML = `
|
listEl.innerHTML = `
|
||||||
<div class="outline-empty">
|
<div class="outline-empty">
|
||||||
<p>No headings found</p>
|
<p>No headings found</p>
|
||||||
<p class="outline-hint"># Heading 1</p>
|
<p class="outline-hint"># Heading 1</p>
|
||||||
@@ -59,62 +59,56 @@ function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHead
|
|||||||
<p class="outline-hint">### Heading 3</p>
|
<p class="outline-hint">### Heading 3</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeHeading = findActiveHeading(activeLine);
|
const activeHeading = findActiveHeading(activeLine);
|
||||||
|
|
||||||
listEl.innerHTML =
|
listEl.innerHTML = headings.map((h, idx) => `
|
||||||
headings
|
|
||||||
.map(
|
|
||||||
(h, idx) => `
|
|
||||||
<div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}"
|
<div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}"
|
||||||
data-line="${h.line}" data-index="${idx}">
|
data-line="${h.line}" data-index="${idx}">
|
||||||
<span class="outline-text">${escapeHtml(h.text)}</span>
|
<span class="outline-text">${escapeHtml(h.text)}</span>
|
||||||
<span class="outline-badge">H${h.level}</span>
|
<span class="outline-badge">H${h.level}</span>
|
||||||
</div>
|
</div>
|
||||||
`
|
`).join('') + `<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
|
||||||
)
|
|
||||||
.join('') +
|
|
||||||
`<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.outline-item').forEach((item) => {
|
listEl.querySelectorAll('.outline-item').forEach(item => {
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
const line = parseInt(item.dataset.line, 10);
|
const line = parseInt(item.dataset.line, 10);
|
||||||
onHeadingClick(line);
|
onHeadingClick(line);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(renderHeadings, 300);
|
debounceTimer = setTimeout(renderHeadings, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveHeading(line) {
|
function setActiveHeading(line) {
|
||||||
activeLine = line;
|
activeLine = line;
|
||||||
const activeHeading = findActiveHeading(line);
|
const activeHeading = findActiveHeading(line);
|
||||||
|
|
||||||
listEl.querySelectorAll('.outline-item').forEach((item) => {
|
listEl.querySelectorAll('.outline-item').forEach(item => {
|
||||||
const itemLine = parseInt(item.dataset.line, 10);
|
const itemLine = parseInt(item.dataset.line, 10);
|
||||||
if (activeHeading && itemLine === activeHeading.line) {
|
if (activeHeading && itemLine === activeHeading.line) {
|
||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
item.classList.remove('active');
|
item.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
container._refreshOutline = refresh;
|
container._refreshOutline = refresh;
|
||||||
container._setActiveHeading = setActiveHeading;
|
container._setActiveHeading = setActiveHeading;
|
||||||
|
|
||||||
renderHeadings();
|
renderHeadings();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { renderOutlinePanel };
|
module.exports = { renderOutlinePanel };
|
||||||
|
|||||||
@@ -1,52 +1,50 @@
|
|||||||
class SidebarManager {
|
class SidebarManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sidebar = document.getElementById('sidebar');
|
this.sidebar = document.getElementById('sidebar');
|
||||||
this.panelContent = document.getElementById('sidebar-panel-content');
|
this.panelContent = document.getElementById('sidebar-panel-content');
|
||||||
this.panelTitle = document.querySelector('.sidebar-panel-title');
|
this.panelTitle = document.querySelector('.sidebar-panel-title');
|
||||||
this.activePanel = null;
|
this.activePanel = null;
|
||||||
this.panels = new Map();
|
this.panels = new Map();
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
|
||||||
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
|
|
||||||
});
|
|
||||||
document
|
|
||||||
.querySelector('.sidebar-panel-close')
|
|
||||||
?.addEventListener('click', () => this.collapse());
|
|
||||||
}
|
|
||||||
|
|
||||||
registerPanel(name, { title, render }) {
|
|
||||||
this.panels.set(name, { title, render });
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePanel(name) {
|
|
||||||
if (this.activePanel === name) {
|
|
||||||
this.collapse();
|
|
||||||
} else {
|
|
||||||
this.expand(name);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
expand(name) {
|
setupEventListeners() {
|
||||||
const panel = this.panels.get(name);
|
document.querySelectorAll('.sidebar-icon').forEach(btn => {
|
||||||
if (!panel) return;
|
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
|
||||||
this.sidebar.classList.remove('collapsed');
|
});
|
||||||
this.panelTitle.textContent = panel.title;
|
document.querySelector('.sidebar-panel-close')?.addEventListener('click', () => this.collapse());
|
||||||
this.panelContent.innerHTML = '';
|
}
|
||||||
panel.render(this.panelContent);
|
|
||||||
this.activePanel = name;
|
|
||||||
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
|
|
||||||
btn.classList.toggle('active', btn.dataset.panel === name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
collapse() {
|
registerPanel(name, { title, render }) {
|
||||||
this.sidebar.classList.add('collapsed');
|
this.panels.set(name, { title, render });
|
||||||
this.activePanel = null;
|
}
|
||||||
document.querySelectorAll('.sidebar-icon').forEach((btn) => btn.classList.remove('active'));
|
|
||||||
}
|
togglePanel(name) {
|
||||||
|
if (this.activePanel === name) {
|
||||||
|
this.collapse();
|
||||||
|
} else {
|
||||||
|
this.expand(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expand(name) {
|
||||||
|
const panel = this.panels.get(name);
|
||||||
|
if (!panel) return;
|
||||||
|
this.sidebar.classList.remove('collapsed');
|
||||||
|
this.panelTitle.textContent = panel.title;
|
||||||
|
this.panelContent.innerHTML = '';
|
||||||
|
panel.render(this.panelContent);
|
||||||
|
this.activePanel = name;
|
||||||
|
document.querySelectorAll('.sidebar-icon').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.panel === name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collapse() {
|
||||||
|
this.sidebar.classList.add('collapsed');
|
||||||
|
this.activePanel = null;
|
||||||
|
document.querySelectorAll('.sidebar-icon').forEach(btn => btn.classList.remove('active'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { SidebarManager };
|
module.exports = { SidebarManager };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippet, onInsert }) {
|
function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippet, onInsert }) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="snippets-panel">
|
<div class="snippets-panel">
|
||||||
<div class="snippets-toolbar">
|
<div class="snippets-toolbar">
|
||||||
<input type="text" class="snippets-search" id="snippets-search" placeholder="Search snippets...">
|
<input type="text" class="snippets-search" id="snippets-search" placeholder="Search snippets...">
|
||||||
@@ -9,29 +9,22 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let snippets = [];
|
let snippets = [];
|
||||||
|
|
||||||
async function loadSnippets() {
|
async function loadSnippets() {
|
||||||
snippets = (await getSnippets()) || [];
|
snippets = await getSnippets() || [];
|
||||||
renderList(document.getElementById('snippets-search')?.value || '');
|
renderList(document.getElementById('snippets-search')?.value || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList(query) {
|
function renderList(query) {
|
||||||
const list = document.getElementById('snippets-list');
|
const list = document.getElementById('snippets-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
const filtered = query
|
const filtered = query
|
||||||
? snippets.filter(
|
? snippets.filter(s => s.name.toLowerCase().includes(query.toLowerCase()) || (s.language || '').toLowerCase().includes(query.toLowerCase()))
|
||||||
(s) =>
|
: snippets;
|
||||||
s.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
(s.language || '').toLowerCase().includes(query.toLowerCase())
|
|
||||||
)
|
|
||||||
: snippets;
|
|
||||||
|
|
||||||
list.innerHTML = filtered.length
|
list.innerHTML = filtered.length ? filtered.map(s => `
|
||||||
? filtered
|
|
||||||
.map(
|
|
||||||
(s) => `
|
|
||||||
<div class="snippet-item" data-id="${s.id}">
|
<div class="snippet-item" data-id="${s.id}">
|
||||||
<div class="snippet-header">
|
<div class="snippet-header">
|
||||||
<span class="snippet-name">${s.name}</span>
|
<span class="snippet-name">${s.name}</span>
|
||||||
@@ -43,41 +36,36 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
|
|||||||
<button class="snippet-delete" data-id="${s.id}" title="Delete">×</button>
|
<button class="snippet-delete" data-id="${s.id}" title="Delete">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`).join('') : '<p class="git-info">No snippets yet. Click + to add one.</p>';
|
||||||
)
|
|
||||||
.join('')
|
|
||||||
: '<p class="git-info">No snippets yet. Click + to add one.</p>';
|
|
||||||
|
|
||||||
list.querySelectorAll('.snippet-insert').forEach((btn) => {
|
list.querySelectorAll('.snippet-insert').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const s = snippets.find((sn) => sn.id === btn.dataset.id);
|
const s = snippets.find(sn => sn.id === btn.dataset.id);
|
||||||
if (s) onInsert(s.code);
|
if (s) onInsert(s.code);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
list.querySelectorAll('.snippet-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await deleteSnippet(btn.dataset.id);
|
||||||
|
loadSnippets();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('snippets-search')?.addEventListener('input', (e) => renderList(e.target.value));
|
||||||
|
|
||||||
|
document.getElementById('snippets-add')?.addEventListener('click', () => {
|
||||||
|
const name = prompt('Snippet name:');
|
||||||
|
if (!name) return;
|
||||||
|
const language = prompt('Language (e.g., javascript, python, html):') || 'text';
|
||||||
|
const code = prompt('Paste your code snippet:');
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
saveSnippet({ id: Date.now().toString(), name, language, code }).then(() => loadSnippets());
|
||||||
});
|
});
|
||||||
|
|
||||||
list.querySelectorAll('.snippet-delete').forEach((btn) => {
|
loadSnippets();
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
await deleteSnippet(btn.dataset.id);
|
|
||||||
loadSnippets();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document
|
|
||||||
.getElementById('snippets-search')
|
|
||||||
?.addEventListener('input', (e) => renderList(e.target.value));
|
|
||||||
|
|
||||||
document.getElementById('snippets-add')?.addEventListener('click', () => {
|
|
||||||
const name = prompt('Snippet name:');
|
|
||||||
if (!name) return;
|
|
||||||
const language = prompt('Language (e.g., javascript, python, html):') || 'text';
|
|
||||||
const code = prompt('Paste your code snippet:');
|
|
||||||
if (!code) return;
|
|
||||||
|
|
||||||
saveSnippet({ id: Date.now().toString(), name, language, code }).then(() => loadSnippets());
|
|
||||||
});
|
|
||||||
|
|
||||||
loadSnippets();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { renderSnippetsPanel };
|
module.exports = { renderSnippetsPanel };
|
||||||
|
|||||||
@@ -1,39 +1,34 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const templates = [
|
const templates = [
|
||||||
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
|
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
|
||||||
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
|
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
|
||||||
{
|
{ name: 'Technical Spec', file: 'technical-spec.md', description: 'Requirements and architecture' },
|
||||||
name: 'Technical Spec',
|
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
|
||||||
file: 'technical-spec.md',
|
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
|
||||||
description: 'Requirements and architecture',
|
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
|
||||||
},
|
{ name: 'API Docs', file: 'api-docs.md', description: 'API endpoint documentation' },
|
||||||
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
|
{ name: 'Tutorial', file: 'tutorial.md', description: 'Step-by-step guide' },
|
||||||
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
|
{ name: 'Release Notes', file: 'release-notes.md', description: 'Version release summary' },
|
||||||
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
|
{ name: 'Comparison', file: 'comparison.md', description: 'Feature comparison table' },
|
||||||
{ name: 'API Docs', file: 'api-docs.md', description: 'API endpoint documentation' },
|
|
||||||
{ name: 'Tutorial', file: 'tutorial.md', description: 'Step-by-step guide' },
|
|
||||||
{ name: 'Release Notes', file: 'release-notes.md', description: 'Version release summary' },
|
|
||||||
{ name: 'Comparison', file: 'comparison.md', description: 'Feature comparison table' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function renderTemplatesPanel(container, onSelect) {
|
function renderTemplatesPanel(container, onSelect) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="panel-list">
|
<div class="panel-list">
|
||||||
${templates
|
${templates.map(t => `
|
||||||
.map(
|
|
||||||
(t) => `
|
|
||||||
<div class="panel-list-item template-item" data-file="${t.file}">
|
<div class="panel-list-item template-item" data-file="${t.file}">
|
||||||
<div class="panel-list-item-title">${t.name}</div>
|
<div class="panel-list-item-title">${t.name}</div>
|
||||||
<div class="panel-list-item-desc">${t.description}</div>
|
<div class="panel-list-item-desc">${t.description}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`).join('')}
|
||||||
)
|
|
||||||
.join('')}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.querySelectorAll('.template-item').forEach((el) => {
|
container.querySelectorAll('.template-item').forEach(el => {
|
||||||
el.addEventListener('click', () => onSelect(el.dataset.file));
|
el.addEventListener('click', () => onSelect(el.dataset.file));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { renderTemplatesPanel, templates };
|
module.exports = { renderTemplatesPanel, templates };
|
||||||
|
|||||||
@@ -675,33 +675,18 @@ body.theme-concreteinfo-dark .modal-footer {
|
|||||||
Animations
|
Animations
|
||||||
============================================ */
|
============================================ */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from { opacity: 0; }
|
||||||
opacity: 0;
|
to { opacity: 1; }
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from {
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
transform: translateY(20px);
|
to { transform: translateY(0); opacity: 1; }
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%,
|
0%, 100% { transform: scale(1); }
|
||||||
100% {
|
50% { transform: scale(1.05); }
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
|
|||||||
+197
-331
@@ -60,15 +60,7 @@
|
|||||||
|
|
||||||
/* Base styles - Reset is in styles.css */
|
/* Base styles - Reset is in styles.css */
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
'Inter',
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
'Helvetica Neue',
|
|
||||||
Arial,
|
|
||||||
sans-serif;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
/* Background controlled by themes - do not set here */
|
/* Background controlled by themes - do not set here */
|
||||||
@@ -76,15 +68,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes gradientShift {
|
@keyframes gradientShift {
|
||||||
0% {
|
0% { background-position: 0% 50%; }
|
||||||
background-position: 0% 50%;
|
50% { background-position: 100% 50%; }
|
||||||
}
|
100% { background-position: 0% 50%; }
|
||||||
50% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -359,9 +345,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Preview Styles - Modern Typography */
|
/* Preview Styles - Modern Typography */
|
||||||
.preview-content h1,
|
.preview-content h1, .preview-content h2, .preview-content h3 {
|
||||||
.preview-content h2,
|
|
||||||
.preview-content h3 {
|
|
||||||
/* Color controlled by theme */
|
/* Color controlled by theme */
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
@@ -711,7 +695,11 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
background: linear-gradient(90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,9 +798,7 @@ body {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.4);
|
background: rgba(255, 255, 255, 0.4);
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
transition:
|
transition: width 0.6s, height 0.6s;
|
||||||
width 0.6s,
|
|
||||||
height 0.6s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar button:hover::before,
|
.toolbar button:hover::before,
|
||||||
@@ -1060,7 +1046,7 @@ body.theme-paper .pdf-viewer-toolbar,
|
|||||||
body.theme-rosepine-dawn .pdf-viewer-toolbar {
|
body.theme-rosepine-dawn .pdf-viewer-toolbar {
|
||||||
background: linear-gradient(180deg, #fafafa 0%, #f0f0f0 100%) !important;
|
background: linear-gradient(180deg, #fafafa 0%, #f0f0f0 100%) !important;
|
||||||
border-bottom: 1px solid #d0d0d0 !important;
|
border-bottom: 1px solid #d0d0d0 !important;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.theme-light .pdf-viewer-toolbar button,
|
body.theme-light .pdf-viewer-toolbar button,
|
||||||
@@ -1075,7 +1061,7 @@ body.theme-rosepine-dawn .pdf-viewer-toolbar button {
|
|||||||
background: #fff !important;
|
background: #fff !important;
|
||||||
border: 1px solid #ccc !important;
|
border: 1px solid #ccc !important;
|
||||||
color: #333 !important;
|
color: #333 !important;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.theme-light .pdf-viewer-toolbar button:hover,
|
body.theme-light .pdf-viewer-toolbar button:hover,
|
||||||
@@ -1458,7 +1444,7 @@ body.theme-rosepine-dawn .pdf-viewer {
|
|||||||
.export-dialog,
|
.export-dialog,
|
||||||
.batch-dialog,
|
.batch-dialog,
|
||||||
.editor-pane,
|
.editor-pane,
|
||||||
[id^='editor-pane-'],
|
[id^="editor-pane-"],
|
||||||
.new-tab-button,
|
.new-tab-button,
|
||||||
.tab-close {
|
.tab-close {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -1466,9 +1452,9 @@ body.theme-rosepine-dawn .pdf-viewer {
|
|||||||
|
|
||||||
/* Show preview content */
|
/* Show preview content */
|
||||||
.preview-pane,
|
.preview-pane,
|
||||||
[id^='preview-pane-'],
|
[id^="preview-pane-"],
|
||||||
.preview-content,
|
.preview-content,
|
||||||
[id^='preview-'] {
|
[id^="preview-"] {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
padding: 20px !important;
|
padding: 20px !important;
|
||||||
@@ -1505,9 +1491,9 @@ body.theme-rosepine-dawn .pdf-viewer {
|
|||||||
body.printing-no-styles .tab-content,
|
body.printing-no-styles .tab-content,
|
||||||
body.printing-no-styles .pane,
|
body.printing-no-styles .pane,
|
||||||
body.printing-no-styles .preview-pane,
|
body.printing-no-styles .preview-pane,
|
||||||
body.printing-no-styles [id^='preview-pane-'],
|
body.printing-no-styles [id^="preview-pane-"],
|
||||||
body.printing-no-styles .preview-content,
|
body.printing-no-styles .preview-content,
|
||||||
body.printing-no-styles [id^='preview-'] {
|
body.printing-no-styles [id^="preview-"] {
|
||||||
background: white !important;
|
background: white !important;
|
||||||
color: #000 !important;
|
color: #000 !important;
|
||||||
}
|
}
|
||||||
@@ -1553,27 +1539,27 @@ body.theme-rosepine-dawn .pdf-viewer {
|
|||||||
|
|
||||||
/* Common dark theme styles for preview content */
|
/* Common dark theme styles for preview content */
|
||||||
body.theme-dark .preview-content,
|
body.theme-dark .preview-content,
|
||||||
body.theme-dark [id^='preview-'],
|
body.theme-dark [id^="preview-"],
|
||||||
body.theme-one-dark .preview-content,
|
body.theme-one-dark .preview-content,
|
||||||
body.theme-one-dark [id^='preview-'],
|
body.theme-one-dark [id^="preview-"],
|
||||||
body.theme-dracula .preview-content,
|
body.theme-dracula .preview-content,
|
||||||
body.theme-dracula [id^='preview-'],
|
body.theme-dracula [id^="preview-"],
|
||||||
body.theme-nord .preview-content,
|
body.theme-nord .preview-content,
|
||||||
body.theme-nord [id^='preview-'],
|
body.theme-nord [id^="preview-"],
|
||||||
body.theme-tokyo-night .preview-content,
|
body.theme-tokyo-night .preview-content,
|
||||||
body.theme-tokyo-night [id^='preview-'],
|
body.theme-tokyo-night [id^="preview-"],
|
||||||
body.theme-palenight .preview-content,
|
body.theme-palenight .preview-content,
|
||||||
body.theme-palenight [id^='preview-'],
|
body.theme-palenight [id^="preview-"],
|
||||||
body.theme-ayu-dark .preview-content,
|
body.theme-ayu-dark .preview-content,
|
||||||
body.theme-ayu-dark [id^='preview-'],
|
body.theme-ayu-dark [id^="preview-"],
|
||||||
body.theme-ayu-mirage .preview-content,
|
body.theme-ayu-mirage .preview-content,
|
||||||
body.theme-ayu-mirage [id^='preview-'],
|
body.theme-ayu-mirage [id^="preview-"],
|
||||||
body.theme-oceanic-next .preview-content,
|
body.theme-oceanic-next .preview-content,
|
||||||
body.theme-oceanic-next [id^='preview-'],
|
body.theme-oceanic-next [id^="preview-"],
|
||||||
body.theme-gruvbox-dark .preview-content,
|
body.theme-gruvbox-dark .preview-content,
|
||||||
body.theme-gruvbox-dark [id^='preview-'],
|
body.theme-gruvbox-dark [id^="preview-"],
|
||||||
body.theme-cobalt2 .preview-content,
|
body.theme-cobalt2 .preview-content,
|
||||||
body.theme-cobalt2 [id^='preview-'] {
|
body.theme-cobalt2 [id^="preview-"] {
|
||||||
color: #e6e6e6;
|
color: #e6e6e6;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -2159,353 +2145,233 @@ body.theme-rosepine-dawn .batch-dialog-footer {
|
|||||||
Command Palette (Ctrl+Shift+P)
|
Command Palette (Ctrl+Shift+P)
|
||||||
======================================== */
|
======================================== */
|
||||||
.command-palette-overlay {
|
.command-palette-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 80px;
|
padding-top: 80px;
|
||||||
}
|
}
|
||||||
.command-palette-overlay.hidden {
|
.command-palette-overlay.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.command-palette {
|
.command-palette {
|
||||||
width: 560px;
|
width: 560px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.command-palette-input {
|
.command-palette-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.command-palette-results {
|
.command-palette-results {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 340px;
|
max-height: 340px;
|
||||||
}
|
}
|
||||||
.command-item {
|
.command-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.command-item:hover,
|
.command-item:hover,
|
||||||
.command-item.selected {
|
.command-item.selected {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
.command-label strong {
|
.command-label strong {
|
||||||
color: #5661b3;
|
color: #5661b3;
|
||||||
}
|
}
|
||||||
.command-shortcut {
|
.command-shortcut {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme command palette */
|
/* Dark theme command palette */
|
||||||
body[class*='dark'] .command-palette {
|
body[class*="dark"] .command-palette {
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .command-palette-input {
|
body[class*="dark"] .command-palette-input {
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
color: #eee;
|
color: #eee;
|
||||||
border-color: #333;
|
border-color: #333;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .command-item:hover,
|
body[class*="dark"] .command-item:hover,
|
||||||
body[class*='dark'] .command-item.selected {
|
body[class*="dark"] .command-item.selected {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .command-shortcut {
|
body[class*="dark"] .command-shortcut {
|
||||||
background: #333;
|
background: #333;
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Breadcrumb Bar
|
Breadcrumb Bar
|
||||||
======================================== */
|
======================================== */
|
||||||
.breadcrumb-bar {
|
.breadcrumb-bar {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--gray-500, #6b7280);
|
color: var(--gray-500, #6b7280);
|
||||||
border-bottom: 1px solid var(--gray-200, #e5e7eb);
|
border-bottom: 1px solid var(--gray-200, #e5e7eb);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--gray-50, #f9fafb);
|
background: var(--gray-50, #f9fafb);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .breadcrumb-bar {
|
body[class*="dark"] .breadcrumb-bar {
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
color: #888;
|
color: #888;
|
||||||
border-color: #333;
|
border-color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Print Preview Dialog */
|
/* Print Preview Dialog */
|
||||||
.print-preview-dialog {
|
.print-preview-dialog {
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
height: 80vh;
|
height: 80vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.print-preview-body {
|
.print-preview-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.print-preview-sidebar {
|
.print-preview-sidebar {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-right: 1px solid var(--gray-200, #e5e7eb);
|
border-right: 1px solid var(--gray-200, #e5e7eb);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.print-preview-content {
|
.print-preview-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.print-preview-content iframe {
|
.print-preview-content iframe {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.print-option-group {
|
.print-option-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.print-option-group label {
|
.print-option-group label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--gray-600, #4b5563);
|
color: var(--gray-600, #4b5563);
|
||||||
}
|
}
|
||||||
.print-option-group select,
|
.print-option-group select,
|
||||||
.print-option-group input[type='text'] {
|
.print-option-group input[type="text"] {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--gray-300, #d1d5db);
|
border: 1px solid var(--gray-300, #d1d5db);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
.scale-control {
|
.scale-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.scale-control input[type='range'] {
|
.scale-control input[type="range"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.checkbox-label {
|
.checkbox-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.print-range-input {
|
.print-range-input {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.print-actions {
|
.print-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: var(--primary-dark, #5661b3);
|
background: var(--primary-dark, #5661b3);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: var(--gray-100, #f3f4f6);
|
background: var(--gray-100, #f3f4f6);
|
||||||
color: var(--gray-700, #374151);
|
color: var(--gray-700, #374151);
|
||||||
border: 1px solid var(--gray-300, #d1d5db);
|
border: 1px solid var(--gray-300, #d1d5db);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Writing Analytics Modal */
|
/* Writing Analytics Modal */
|
||||||
.analytics-overlay {
|
.analytics-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; z-index:10000; animation:analyticsFadeIn 0.2s ease; }
|
||||||
position: fixed;
|
.analytics-modal { background:var(--bg-primary,#fff); border-radius:12px; width:520px; max-height:80vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,0.3); animation:analyticsSlideUp 0.3s ease; }
|
||||||
inset: 0;
|
.analytics-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px; border-bottom:1px solid var(--border-color,#e5e7eb); }
|
||||||
background: rgba(0, 0, 0, 0.5);
|
.analytics-header h2 { margin:0; font-size:18px; font-weight:600; color:var(--text-primary); }
|
||||||
backdrop-filter: blur(4px);
|
.analytics-close { background:none; border:none; font-size:24px; cursor:pointer; color:var(--text-muted); padding:4px 8px; border-radius:4px; }
|
||||||
display: flex;
|
.analytics-close:hover { background:var(--bg-tertiary); color:var(--text-primary); }
|
||||||
align-items: center;
|
.analytics-body { padding:16px 24px 24px; }
|
||||||
justify-content: center;
|
.analytics-section { margin-bottom:20px; }
|
||||||
z-index: 10000;
|
.analytics-section h3 { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted,#9ca3af); margin:0 0 10px; }
|
||||||
animation: analyticsFadeIn 0.2s ease;
|
.analytics-row { display:flex; justify-content:space-between; align-items:baseline; padding:6px 0; font-size:14px; }
|
||||||
}
|
.analytics-label { color:var(--text-secondary,#6b7280); }
|
||||||
.analytics-modal {
|
.analytics-value { font-weight:500; color:var(--text-primary); }
|
||||||
background: var(--bg-primary, #fff);
|
.analytics-value small { color:var(--text-muted); font-weight:400; margin-left:6px; }
|
||||||
border-radius: 12px;
|
.readability-meter { height:4px; background:var(--bg-tertiary,#f3f4f6); border-radius:2px; margin-top:8px; overflow:hidden; }
|
||||||
width: 520px;
|
.readability-fill { height:100%; background:linear-gradient(90deg,#ef4444,#f59e0b,#10b981); border-radius:2px; transition:width 0.5s ease; }
|
||||||
max-height: 80vh;
|
.word-cloud { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
|
||||||
overflow-y: auto;
|
.word-tag { background:var(--bg-tertiary,#f3f4f6); padding:4px 10px; border-radius:12px; font-size:13px; color:var(--text-secondary); }
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
.word-tag small { opacity:0.5; font-size:10px; margin-left:2px; }
|
||||||
animation: analyticsSlideUp 0.3s ease;
|
.analytics-longest { flex-direction:column; gap:4px; }
|
||||||
}
|
.analytics-sentence-preview { font-style:italic; font-size:13px; color:var(--text-secondary); font-weight:400; }
|
||||||
.analytics-header {
|
@keyframes analyticsFadeIn { from{opacity:0} to{opacity:1} }
|
||||||
display: flex;
|
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
|
||||||
justify-content: space-between;
|
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
|
||||||
align-items: center;
|
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
|
||||||
}
|
|
||||||
.analytics-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.analytics-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.analytics-close:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.analytics-body {
|
|
||||||
padding: 16px 24px 24px;
|
|
||||||
}
|
|
||||||
.analytics-section {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.analytics-section h3 {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-muted, #9ca3af);
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
.analytics-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 6px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.analytics-label {
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
}
|
|
||||||
.analytics-value {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.analytics-value small {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
.readability-meter {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--bg-tertiary, #f3f4f6);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-top: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.readability-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #ef4444, #f59e0b, #10b981);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
.word-cloud {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.word-tag {
|
|
||||||
background: var(--bg-tertiary, #f3f4f6);
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.word-tag small {
|
|
||||||
opacity: 0.5;
|
|
||||||
font-size: 10px;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
.analytics-longest {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.analytics-sentence-preview {
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
@keyframes analyticsFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes analyticsSlideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(16px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body[class*='dark'] .analytics-modal {
|
|
||||||
background: var(--gray-800, #1f2937);
|
|
||||||
}
|
|
||||||
body[class*='dark'] .word-tag {
|
|
||||||
background: var(--gray-700, #374151);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Interactive PDF Thumbnail Sidebar Styles
|
Interactive PDF Thumbnail Sidebar Styles
|
||||||
@@ -2583,7 +2449,7 @@ body[class*='dark'] .word-tag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pdf-thumbnail-card.marked-delete::after {
|
.pdf-thumbnail-card.marked-delete::after {
|
||||||
content: 'DELETE';
|
content: "DELETE";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -2738,21 +2604,21 @@ body[class*='dark'] .word-tag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode adjustments */
|
/* Dark mode adjustments */
|
||||||
body[class*='dark'] .pdf-thumbnail-card {
|
body[class*="dark"] .pdf-thumbnail-card {
|
||||||
background: var(--gray-800, #1f2937);
|
background: var(--gray-800, #1f2937);
|
||||||
border-color: var(--gray-700, #374151);
|
border-color: var(--gray-700, #374151);
|
||||||
}
|
}
|
||||||
body[class*='dark'] .pdf-thumbnail-header .badge {
|
body[class*="dark"] .pdf-thumbnail-header .badge {
|
||||||
background: var(--gray-700, #374151);
|
background: var(--gray-700, #374151);
|
||||||
color: var(--gray-200);
|
color: var(--gray-200);
|
||||||
}
|
}
|
||||||
body[class*='dark'] .canvas-container {
|
body[class*="dark"] .canvas-container {
|
||||||
background: var(--gray-900, #111827);
|
background: var(--gray-900, #111827);
|
||||||
border-color: var(--gray-700);
|
border-color: var(--gray-700);
|
||||||
}
|
}
|
||||||
body[class*='dark'] .btn-rotate-thumbnail,
|
body[class*="dark"] .btn-rotate-thumbnail,
|
||||||
body[class*='dark'] .btn-reorder-left,
|
body[class*="dark"] .btn-reorder-left,
|
||||||
body[class*='dark'] .btn-reorder-right {
|
body[class*="dark"] .btn-reorder-right {
|
||||||
background: var(--gray-700, #374151);
|
background: var(--gray-700, #374151);
|
||||||
border-color: var(--gray-600);
|
border-color: var(--gray-600);
|
||||||
color: var(--gray-200);
|
color: var(--gray-200);
|
||||||
|
|||||||
+196
-415
@@ -1,523 +1,304 @@
|
|||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.main-content {
|
.main-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar:not(.collapsed) {
|
.sidebar:not(.collapsed) {
|
||||||
width: 328px; /* 48px icons + 280px panel */
|
width: 328px; /* 48px icons + 280px panel */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-icons {
|
.sidebar-icons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
background: var(--gray-100, #f3f4f6);
|
background: var(--gray-100, #f3f4f6);
|
||||||
border-right: 1px solid var(--gray-200, #e5e7eb);
|
border-right: 1px solid var(--gray-200, #e5e7eb);
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-icon {
|
.sidebar-icon {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--gray-500, #6b7280);
|
color: var(--gray-500, #6b7280);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-icon:hover {
|
.sidebar-icon:hover {
|
||||||
background: var(--gray-200, #e5e7eb);
|
background: var(--gray-200, #e5e7eb);
|
||||||
color: var(--gray-700, #374151);
|
color: var(--gray-700, #374151);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-icon.active {
|
.sidebar-icon.active {
|
||||||
background: var(--gray-200, #e5e7eb);
|
background: var(--gray-200, #e5e7eb);
|
||||||
color: var(--primary-dark, #5661b3);
|
color: var(--primary-dark, #5661b3);
|
||||||
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
|
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-icon:focus-visible {
|
.sidebar-icon:focus-visible {
|
||||||
outline: 2px solid var(--primary-dark, #5661b3);
|
outline: 2px solid var(--primary-dark, #5661b3);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel {
|
.sidebar-panel {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
background: var(--gray-50, #f9fafb);
|
background: var(--gray-50, #f9fafb);
|
||||||
border-right: 1px solid var(--gray-200, #e5e7eb);
|
border-right: 1px solid var(--gray-200, #e5e7eb);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed .sidebar-panel {
|
.sidebar.collapsed .sidebar-panel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel-header {
|
.sidebar-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--gray-200, #e5e7eb);
|
border-bottom: 1px solid var(--gray-200, #e5e7eb);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--gray-600, #4b5563);
|
color: var(--gray-600, #4b5563);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel-close {
|
.sidebar-panel-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--gray-400, #9ca3af);
|
color: var(--gray-400, #9ca3af);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel-close:hover {
|
.sidebar-panel-close:hover {
|
||||||
background: var(--gray-200, #e5e7eb);
|
background: var(--gray-200, #e5e7eb);
|
||||||
color: var(--gray-600, #4b5563);
|
color: var(--gray-600, #4b5563);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel-close:focus-visible {
|
.sidebar-panel-close:focus-visible {
|
||||||
outline: 2px solid var(--primary-dark, #5661b3);
|
outline: 2px solid var(--primary-dark, #5661b3);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel-content {
|
.sidebar-panel-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme support */
|
/* Dark theme support */
|
||||||
body[class*='dark'] .sidebar-icons,
|
body[class*="dark"] .sidebar-icons,
|
||||||
body[class*='dark'] .sidebar-panel {
|
body[class*="dark"] .sidebar-panel {
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
border-color: #333;
|
border-color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[class*='dark'] .sidebar-icon {
|
body[class*="dark"] .sidebar-icon {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[class*='dark'] .sidebar-icon:hover {
|
body[class*="dark"] .sidebar-icon:hover {
|
||||||
background: #333;
|
background: #333;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[class*='dark'] .sidebar-icon.active {
|
body[class*="dark"] .sidebar-icon.active {
|
||||||
background: #333;
|
background: #333;
|
||||||
color: #8b9aff;
|
color: #8b9aff;
|
||||||
box-shadow: inset 3px 0 0 #8b9aff;
|
box-shadow: inset 3px 0 0 #8b9aff;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[class*='dark'] .sidebar-panel-header {
|
body[class*="dark"] .sidebar-panel-header {
|
||||||
border-color: #333;
|
border-color: #333;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Panel list items (templates, etc.) */
|
/* Panel list items (templates, etc.) */
|
||||||
.panel-list-item {
|
.panel-list-item {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.panel-list-item:hover {
|
.panel-list-item:hover {
|
||||||
background: var(--gray-200, #e5e7eb);
|
background: var(--gray-200, #e5e7eb);
|
||||||
}
|
}
|
||||||
.panel-list-item-title {
|
.panel-list-item-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--gray-800, #1f2937);
|
color: var(--gray-800, #1f2937);
|
||||||
}
|
}
|
||||||
.panel-list-item-desc {
|
.panel-list-item-desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--gray-500, #6b7280);
|
color: var(--gray-500, #6b7280);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[class*='dark'] .panel-list-item:hover {
|
body[class*="dark"] .panel-list-item:hover {
|
||||||
background: #333;
|
background: #333;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .panel-list-item-title {
|
body[class*="dark"] .panel-list-item-title {
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .panel-list-item-desc {
|
body[class*="dark"] .panel-list-item-desc {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Explorer Panel */
|
/* Explorer Panel */
|
||||||
.explorer-toolbar {
|
.explorer-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.explorer-path {
|
.explorer-path {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid var(--gray-300, #d1d5db);
|
border: 1px solid var(--gray-300, #d1d5db);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: white;
|
background: white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.explorer-browse-btn {
|
.explorer-browse-btn {
|
||||||
border: 1px solid var(--gray-300, #d1d5db);
|
border: 1px solid var(--gray-300, #d1d5db);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: white;
|
background: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
.tree-item {
|
.tree-item {
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.tree-item:hover {
|
.tree-item:hover {
|
||||||
background: var(--gray-100, #f3f4f6);
|
background: var(--gray-100, #f3f4f6);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.tree-icon {
|
.tree-icon {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.tree-children {
|
.tree-children {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
.tree-folder.collapsed .tree-children {
|
.tree-folder.collapsed .tree-children {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.tree-name {
|
.tree-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Git Panel */
|
/* Git Panel */
|
||||||
.git-section {
|
.git-section { margin-bottom: 16px; }
|
||||||
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-section-title {
|
.git-file:hover { background: var(--gray-100, #f3f4f6); }
|
||||||
font-size: 11px;
|
.git-file-status { font-weight: 700; font-family: monospace; width: 16px; text-align: center; }
|
||||||
text-transform: uppercase;
|
.git-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
letter-spacing: 0.05em;
|
.git-stage-btn { border: none; background: var(--gray-200); border-radius: 4px; cursor: pointer; font-size: 14px; width: 22px; height: 22px; }
|
||||||
color: var(--gray-500);
|
.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; }
|
||||||
margin-bottom: 8px;
|
.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-file {
|
.git-log-entry { padding: 6px 0; border-bottom: 1px solid var(--gray-100); }
|
||||||
display: flex;
|
.git-log-msg { font-size: 13px; }
|
||||||
align-items: center;
|
.git-log-meta { font-size: 11px; color: var(--gray-500); margin-top: 2px; }
|
||||||
padding: 4px 6px;
|
.git-info { font-size: 13px; color: var(--gray-500); padding: 8px 0; }
|
||||||
border-radius: 4px;
|
.git-loading { font-size: 13px; color: var(--gray-400); }
|
||||||
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 Panel */
|
||||||
.snippets-toolbar {
|
.snippets-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||||
display: flex;
|
.snippets-search { flex: 1; padding: 6px 10px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; }
|
||||||
gap: 4px;
|
.snippets-add-btn { width: 32px; border: 1px solid var(--gray-300); border-radius: 6px; background: white; font-size: 18px; cursor: pointer; }
|
||||||
margin-bottom: 8px;
|
.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; }
|
||||||
.snippets-search {
|
.snippet-name { font-size: 13px; font-weight: 500; }
|
||||||
flex: 1;
|
.snippet-lang { font-size: 11px; background: var(--gray-100); padding: 2px 6px; border-radius: 4px; color: var(--gray-500); }
|
||||||
padding: 6px 10px;
|
.snippet-preview { font-size: 12px; background: var(--gray-50); padding: 6px; border-radius: 4px; margin: 4px 0; overflow: hidden; max-height: 60px; }
|
||||||
border: 1px solid var(--gray-300);
|
.snippet-preview code { font-family: 'JetBrains Mono', monospace; }
|
||||||
border-radius: 6px;
|
.snippet-actions { display: flex; gap: 4px; }
|
||||||
font-size: 13px;
|
.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; }
|
||||||
.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 */
|
/* Dark theme for sidebar panels */
|
||||||
body[class*='dark'] .explorer-path,
|
body[class*="dark"] .explorer-path,
|
||||||
body[class*='dark'] .explorer-browse-btn,
|
body[class*="dark"] .explorer-browse-btn,
|
||||||
body[class*='dark'] .snippets-search,
|
body[class*="dark"] .snippets-search,
|
||||||
body[class*='dark'] .snippets-add-btn,
|
body[class*="dark"] .snippets-add-btn,
|
||||||
body[class*='dark'] .snippet-insert,
|
body[class*="dark"] .snippet-insert,
|
||||||
body[class*='dark'] .snippet-delete {
|
body[class*="dark"] .snippet-delete {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
border-color: #444;
|
border-color: #444;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .git-commit-input {
|
body[class*="dark"] .git-commit-input {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
border-color: #444;
|
border-color: #444;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .snippet-item {
|
body[class*="dark"] .snippet-item {
|
||||||
border-color: #444;
|
border-color: #444;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .snippet-preview {
|
body[class*="dark"] .snippet-preview {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
}
|
}
|
||||||
body[class*='dark'] .tree-item:hover,
|
body[class*="dark"] .tree-item:hover,
|
||||||
body[class*='dark'] .git-file:hover {
|
body[class*="dark"] .git-file:hover {
|
||||||
background: #333;
|
background: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Outline Panel */
|
/* Outline Panel */
|
||||||
.outline-panel {
|
.outline-panel { display: flex; flex-direction: column; height: 100%; }
|
||||||
display: flex;
|
.outline-list { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||||
flex-direction: column;
|
.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; }
|
||||||
height: 100%;
|
.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-list {
|
.outline-level-1 { padding-left: 12px; }
|
||||||
flex: 1;
|
.outline-level-2 { padding-left: 24px; }
|
||||||
overflow-y: auto;
|
.outline-level-3 { padding-left: 36px; }
|
||||||
padding: 4px 0;
|
.outline-level-4 { padding-left: 48px; }
|
||||||
}
|
.outline-level-5 { padding-left: 56px; }
|
||||||
.outline-item {
|
.outline-level-6 { padding-left: 64px; }
|
||||||
display: flex;
|
.outline-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
align-items: center;
|
.outline-badge { font-size: 10px; opacity: 0.5; margin-left: 8px; flex-shrink: 0; }
|
||||||
justify-content: space-between;
|
.outline-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-muted, #9ca3af); text-align: center; }
|
||||||
padding: 4px 12px;
|
.outline-empty p { margin: 4px 0; }
|
||||||
cursor: pointer;
|
.outline-hint { font-family: monospace; font-size: 12px; opacity: 0.7; }
|
||||||
font-size: 13px;
|
.outline-footer { padding: 8px 12px; border-top: 1px solid var(--border-color, #e5e7eb); font-size: 11px; color: var(--text-muted, #9ca3af); }
|
||||||
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 */
|
/* Outline dark mode */
|
||||||
body[class*='dark'] .outline-item {
|
body[class*="dark"] .outline-item { color: #9ca3af; }
|
||||||
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-item:hover {
|
body[class*="dark"] .outline-empty { color: #6b7280; }
|
||||||
background: #374151;
|
body[class*="dark"] .outline-footer { border-color: #333; color: #6b7280; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-121
@@ -1,121 +1,24 @@
|
|||||||
.welcome-container {
|
.welcome-container { padding: 40px; max-width: 900px; margin: 0 auto; }
|
||||||
padding: 40px;
|
.welcome-hero { text-align: center; margin-bottom: 40px; }
|
||||||
max-width: 900px;
|
.welcome-title { font-size: 32px; font-weight: 700; color: var(--gray-800, #1f2937); }
|
||||||
margin: 0 auto;
|
.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-hero {
|
.welcome-grid { display: grid; gap: 32px; }
|
||||||
text-align: center;
|
.welcome-section h2 { font-size: 18px; margin-bottom: 16px; color: var(--gray-700, #374151); }
|
||||||
margin-bottom: 40px;
|
.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-title {
|
.welcome-card:hover { border-color: var(--primary-dark, #5661b3); box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); }
|
||||||
font-size: 32px;
|
.welcome-card-icon { font-size: 28px; margin-bottom: 8px; }
|
||||||
font-weight: 700;
|
.welcome-card h3 { font-size: 15px; margin-bottom: 4px; }
|
||||||
color: var(--gray-800, #1f2937);
|
.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-version {
|
.welcome-features { list-style: none; padding: 0; }
|
||||||
font-size: 14px;
|
.welcome-features li { padding: 6px 0; font-size: 14px; border-bottom: 1px solid var(--gray-100, #f3f4f6); }
|
||||||
color: var(--primary-dark, #5661b3);
|
.welcome-features strong { color: var(--primary-dark, #5661b3); }
|
||||||
margin-top: 4px;
|
.welcome-recent-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; }
|
||||||
font-weight: 500;
|
.welcome-recent-item:hover { background: var(--gray-100); }
|
||||||
}
|
.welcome-recent-name { font-size: 14px; font-weight: 500; display: block; }
|
||||||
.welcome-subtitle {
|
.welcome-recent-path { font-size: 12px; color: var(--gray-500); display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
font-size: 16px;
|
.welcome-muted { color: var(--gray-400); font-size: 14px; }
|
||||||
color: var(--gray-500, #6b7280);
|
.welcome-footer { margin-top: 32px; text-align: center; }
|
||||||
margin-top: 8px;
|
.welcome-checkbox { font-size: 13px; color: var(--gray-500); cursor: pointer; }
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|||||||
+48
-48
@@ -7,96 +7,96 @@ body.zen-mode .toolbar,
|
|||||||
body.zen-mode .status-bar,
|
body.zen-mode .status-bar,
|
||||||
body.zen-mode .sidebar,
|
body.zen-mode .sidebar,
|
||||||
body.zen-mode .breadcrumb-bar {
|
body.zen-mode .breadcrumb-bar {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main content fills viewport */
|
/* Main content fills viewport */
|
||||||
body.zen-mode .main-content {
|
body.zen-mode .main-content {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editor container takes full width */
|
/* Editor container takes full width */
|
||||||
body.zen-mode .editor-container {
|
body.zen-mode .editor-container {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide preview panes */
|
/* Hide preview panes */
|
||||||
body.zen-mode .preview-container,
|
body.zen-mode .preview-container,
|
||||||
body.zen-mode .preview-pane,
|
body.zen-mode .preview-pane,
|
||||||
body.zen-mode .pane-resizer {
|
body.zen-mode .pane-resizer {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Center and style editor content for comfortable reading width */
|
/* Center and style editor content for comfortable reading width */
|
||||||
body.zen-mode .cm-content {
|
body.zen-mode .cm-content {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
padding-top: 80px;
|
padding-top: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.zen-mode .cm-line {
|
body.zen-mode .cm-line {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.zen-mode .editor-wrapper {
|
body.zen-mode .editor-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating HUD pill */
|
/* Floating HUD pill */
|
||||||
.zen-hud {
|
.zen-hud {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zen-hud-stat {
|
.zen-hud-stat {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zen-hud-sep {
|
.zen-hud-sep {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Word goal progress bar */
|
/* Word goal progress bar */
|
||||||
.zen-progress-container {
|
.zen-progress-container {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zen-progress-bar {
|
.zen-progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(255, 178, 70, 0.85);
|
background: rgba(255, 178, 70, 0.85);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode — HUD stays dark in both themes */
|
/* Dark mode — HUD stays dark in both themes */
|
||||||
body[class*='dark'].zen-mode .zen-hud {
|
body[class*="dark"].zen-mode .zen-hud {
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
|||||||
+1813
-1998
File diff suppressed because it is too large
Load Diff
+124
-128
@@ -9,13 +9,13 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
-webkit-backdrop-filter: blur(4px);
|
-webkit-backdrop-filter: blur(4px);
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -23,23 +23,22 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: calc(var(--z-modal, 200) + 1);
|
z-index: calc(var(--z-modal, 200) + 1);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition:
|
transition: opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
|
||||||
opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
|
visibility var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
visibility var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
padding: var(--spacing-4, 1rem);
|
||||||
padding: var(--spacing-4, 1rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.open {
|
.modal.open {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -47,23 +46,23 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: hsl(var(--background, 0 0% 100%));
|
background: hsl(var(--background, 0 0% 100%));
|
||||||
border-radius: var(--radius-lg, 0.5rem);
|
border-radius: var(--radius-lg, 0.5rem);
|
||||||
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1));
|
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1));
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
transition: transform var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
transition: transform var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal.open .modal-content {
|
.modal.open .modal-content {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -71,19 +70,19 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
|
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
|
||||||
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
|
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
|
||||||
background: hsl(var(--muted, 210 40% 96.1%));
|
background: hsl(var(--muted, 210 40% 96.1%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-lg, 1.125rem);
|
font-size: var(--text-lg, 1.125rem);
|
||||||
font-weight: var(--font-semibold, 600);
|
font-weight: var(--font-semibold, 600);
|
||||||
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -91,32 +90,31 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius, 0.5rem);
|
border-radius: var(--radius, 0.5rem);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%));
|
color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%));
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition: background-color var(--transition-fast, 150ms),
|
||||||
background-color var(--transition-fast, 150ms),
|
color var(--transition-fast, 150ms);
|
||||||
color var(--transition-fast, 150ms);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
background: hsl(var(--accent, 210 40% 96.1%));
|
background: hsl(var(--accent, 210 40% 96.1%));
|
||||||
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close:focus-visible {
|
.modal-close:focus-visible {
|
||||||
outline: 2px solid hsl(var(--ring, 227 44% 52%));
|
outline: 2px solid hsl(var(--ring, 227 44% 52%));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -124,9 +122,9 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
padding: var(--spacing-6, 1.5rem);
|
padding: var(--spacing-6, 1.5rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -134,19 +132,19 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--spacing-3, 0.75rem);
|
gap: var(--spacing-3, 0.75rem);
|
||||||
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
|
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
|
||||||
border-top: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
|
border-top: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
|
||||||
background: hsl(var(--muted, 210 40% 96.1%));
|
background: hsl(var(--muted, 210 40% 96.1%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer .btn {
|
.modal-footer .btn {
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
/* styles-modern.css sets flex:1 on .btn-primary/.btn-secondary globally
|
/* styles-modern.css sets flex:1 on .btn-primary/.btn-secondary globally
|
||||||
* which causes footer buttons to stretch to full width. Override here. */
|
* which causes footer buttons to stretch to full width. Override here. */
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -154,60 +152,60 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-body .form-row {
|
.modal-body .form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3, 0.75rem);
|
gap: var(--spacing-3, 0.75rem);
|
||||||
margin-bottom: var(--spacing-3, 0.75rem);
|
margin-bottom: var(--spacing-3, 0.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .form-row label {
|
.modal-body .form-row label {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
font-size: var(--text-sm, 0.875rem);
|
font-size: var(--text-sm, 0.875rem);
|
||||||
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .form-row input,
|
.modal-body .form-row input,
|
||||||
.modal-body .form-row select {
|
.modal-body .form-row select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
|
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
|
||||||
border: 1px solid hsl(var(--input, 214.3 31.8% 91.4%));
|
border: 1px solid hsl(var(--input, 214.3 31.8% 91.4%));
|
||||||
border-radius: var(--radius, 0.5rem);
|
border-radius: var(--radius, 0.5rem);
|
||||||
font-size: var(--text-sm, 0.875rem);
|
font-size: var(--text-sm, 0.875rem);
|
||||||
background: hsl(var(--background, 0 0% 100%));
|
background: hsl(var(--background, 0 0% 100%));
|
||||||
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .form-row input:focus,
|
.modal-body .form-row input:focus,
|
||||||
.modal-body .form-row select:focus {
|
.modal-body .form-row select:focus {
|
||||||
outline: 2px solid hsl(var(--ring, 227 44% 52%));
|
outline: 2px solid hsl(var(--ring, 227 44% 52%));
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .export-section {
|
.modal-body .export-section {
|
||||||
margin-bottom: var(--spacing-4, 1rem);
|
margin-bottom: var(--spacing-4, 1rem);
|
||||||
padding-bottom: var(--spacing-4, 1rem);
|
padding-bottom: var(--spacing-4, 1rem);
|
||||||
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
|
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .export-section:last-child {
|
.modal-body .export-section:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .export-section label {
|
.modal-body .export-section label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: var(--font-medium, 500);
|
font-weight: var(--font-medium, 500);
|
||||||
margin-bottom: var(--spacing-2, 0.5rem);
|
margin-bottom: var(--spacing-2, 0.5rem);
|
||||||
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
color: hsl(var(--foreground, 222.2 84% 4.9%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body .checkbox-group label {
|
.modal-body .checkbox-group label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-2, 0.5rem);
|
gap: var(--spacing-2, 0.5rem);
|
||||||
margin-bottom: var(--spacing-2, 0.5rem);
|
margin-bottom: var(--spacing-2, 0.5rem);
|
||||||
font-weight: var(--font-normal, 400);
|
font-weight: var(--font-normal, 400);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -215,19 +213,19 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal-content.small {
|
.modal-content.small {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content.large {
|
.modal-content.large {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
min-width: 560px;
|
min-width: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content.full {
|
.modal-content.full {
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
min-width: min(95vw, 700px);
|
min-width: min(95vw, 700px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -235,18 +233,16 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.dark .modal-content,
|
.dark .modal-content,
|
||||||
[data-theme='dark'] .modal-content {
|
[data-theme="dark"] .modal-content {
|
||||||
background: hsl(var(--background));
|
background: hsl(var(--background));
|
||||||
box-shadow:
|
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.3);
|
||||||
0 20px 25px -5px rgb(0 0 0 / 0.4),
|
|
||||||
0 8px 10px -6px rgb(0 0 0 / 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .modal-header,
|
.dark .modal-header,
|
||||||
.dark .modal-footer,
|
.dark .modal-footer,
|
||||||
[data-theme='dark'] .modal-header,
|
[data-theme="dark"] .modal-header,
|
||||||
[data-theme='dark'] .modal-footer {
|
[data-theme="dark"] .modal-footer {
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -254,10 +250,10 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.modal,
|
.modal,
|
||||||
.modal-content {
|
.modal-content {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -265,5 +261,5 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.modal.hidden {
|
.modal.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-49
@@ -148,7 +148,7 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.dark,
|
.dark,
|
||||||
[data-theme='dark'] {
|
[data-theme="dark"] {
|
||||||
/* Background and foreground */
|
/* Background and foreground */
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
@@ -205,56 +205,24 @@
|
|||||||
* Semantic Color Classes
|
* Semantic Color Classes
|
||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.bg-background {
|
.bg-background { background-color: hsl(var(--background)); }
|
||||||
background-color: hsl(var(--background));
|
.bg-foreground { background-color: hsl(var(--foreground)); }
|
||||||
}
|
.bg-card { background-color: hsl(var(--card)); }
|
||||||
.bg-foreground {
|
.bg-primary { background-color: hsl(var(--primary)); }
|
||||||
background-color: hsl(var(--foreground));
|
.bg-secondary { background-color: hsl(var(--secondary)); }
|
||||||
}
|
.bg-muted { background-color: hsl(var(--muted)); }
|
||||||
.bg-card {
|
.bg-accent { background-color: hsl(var(--accent)); }
|
||||||
background-color: hsl(var(--card));
|
.bg-destructive { background-color: hsl(var(--destructive)); }
|
||||||
}
|
|
||||||
.bg-primary {
|
|
||||||
background-color: hsl(var(--primary));
|
|
||||||
}
|
|
||||||
.bg-secondary {
|
|
||||||
background-color: hsl(var(--secondary));
|
|
||||||
}
|
|
||||||
.bg-muted {
|
|
||||||
background-color: hsl(var(--muted));
|
|
||||||
}
|
|
||||||
.bg-accent {
|
|
||||||
background-color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
.bg-destructive {
|
|
||||||
background-color: hsl(var(--destructive));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-foreground {
|
.text-foreground { color: hsl(var(--foreground)); }
|
||||||
color: hsl(var(--foreground));
|
.text-primary { color: hsl(var(--primary)); }
|
||||||
}
|
.text-secondary { color: hsl(var(--secondary-foreground)); }
|
||||||
.text-primary {
|
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
|
||||||
color: hsl(var(--primary));
|
.text-destructive { color: hsl(var(--destructive)); }
|
||||||
}
|
|
||||||
.text-secondary {
|
|
||||||
color: hsl(var(--secondary-foreground));
|
|
||||||
}
|
|
||||||
.text-muted-foreground {
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
}
|
|
||||||
.text-destructive {
|
|
||||||
color: hsl(var(--destructive));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-border {
|
.border-border { border-color: hsl(var(--border)); }
|
||||||
border-color: hsl(var(--border));
|
.border-primary { border-color: hsl(var(--primary)); }
|
||||||
}
|
.border-input { border-color: hsl(var(--input)); }
|
||||||
.border-primary {
|
|
||||||
border-color: hsl(var(--primary));
|
|
||||||
}
|
|
||||||
.border-input {
|
|
||||||
border-color: hsl(var(--input));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
* Utility Classes
|
* Utility Classes
|
||||||
|
|||||||
+477
-498
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -22,10 +22,10 @@ GET /resources
|
|||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| --------- | ------- | -------- | ---------------------------- |
|
|-----------|------|----------|-------------|
|
||||||
| page | integer | No | Page number (default: 1) |
|
| page | integer | No | Page number (default: 1) |
|
||||||
| limit | integer | No | Items per page (default: 20) |
|
| limit | integer | No | Items per page (default: 20) |
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
@@ -82,9 +82,9 @@ DELETE /resources/:id
|
|||||||
|
|
||||||
## Error Codes
|
## Error Codes
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | --------------------- |
|
|------|-------------|
|
||||||
| 400 | Bad Request |
|
| 400 | Bad Request |
|
||||||
| 401 | Unauthorized |
|
| 401 | Unauthorized |
|
||||||
| 404 | Not Found |
|
| 404 | Not Found |
|
||||||
| 500 | Internal Server Error |
|
| 500 | Internal Server Error |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Blog Post Title
|
title: Blog Post Title
|
||||||
date: { { DATE } }
|
date: {{DATE}}
|
||||||
author: Your Name
|
author: Your Name
|
||||||
tags: []
|
tags: []
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,23 +3,18 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- New feature
|
- New feature
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated feature
|
- Updated feature
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Bug fix
|
- Bug fix
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Removed feature
|
- Removed feature
|
||||||
|
|
||||||
## [1.0.0] - {{DATE}}
|
## [1.0.0] - {{DATE}}
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Initial release
|
- Initial release
|
||||||
|
|||||||
+16
-20
@@ -9,54 +9,50 @@ Brief description of what is being compared and why.
|
|||||||
|
|
||||||
## Feature Comparison
|
## Feature Comparison
|
||||||
|
|
||||||
| Feature | Option A | Option B |
|
| Feature | Option A | Option B |
|
||||||
| --------- | -------- | -------- |
|
|---------|----------|----------|
|
||||||
| Feature 1 | Yes | Yes |
|
| Feature 1 | Yes | Yes |
|
||||||
| Feature 2 | Yes | No |
|
| Feature 2 | Yes | No |
|
||||||
| Feature 3 | No | Yes |
|
| Feature 3 | No | Yes |
|
||||||
| Feature 4 | Partial | Yes |
|
| Feature 4 | Partial | Yes |
|
||||||
|
|
||||||
## Pricing
|
## Pricing
|
||||||
|
|
||||||
| Plan | Option A | Option B |
|
| Plan | Option A | Option B |
|
||||||
| ---------- | -------- | -------- |
|
|------|----------|----------|
|
||||||
| Free | Limited | Limited |
|
| Free | Limited | Limited |
|
||||||
| Pro | $10/mo | $15/mo |
|
| Pro | $10/mo | $15/mo |
|
||||||
| Enterprise | Custom | Custom |
|
| Enterprise | Custom | Custom |
|
||||||
|
|
||||||
## Pros and Cons
|
## Pros and Cons
|
||||||
|
|
||||||
### Option A
|
### Option A
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
|
|
||||||
- Pro 1
|
- Pro 1
|
||||||
- Pro 2
|
- Pro 2
|
||||||
|
|
||||||
**Cons:**
|
**Cons:**
|
||||||
|
|
||||||
- Con 1
|
- Con 1
|
||||||
- Con 2
|
- Con 2
|
||||||
|
|
||||||
### Option B
|
### Option B
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
|
|
||||||
- Pro 1
|
- Pro 1
|
||||||
- Pro 2
|
- Pro 2
|
||||||
|
|
||||||
**Cons:**
|
**Cons:**
|
||||||
|
|
||||||
- Con 1
|
- Con 1
|
||||||
- Con 2
|
- Con 2
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
| Metric | Option A | Option B |
|
| Metric | Option A | Option B |
|
||||||
| ----------- | -------- | --------- |
|
|--------|----------|----------|
|
||||||
| Speed | Fast | Moderate |
|
| Speed | Fast | Moderate |
|
||||||
| Memory | Low | Medium |
|
| Memory | Low | Medium |
|
||||||
| Scalability | Good | Excellent |
|
| Scalability | Good | Excellent |
|
||||||
|
|
||||||
## Recommendation
|
## Recommendation
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
**Date:** {{DATE}}
|
**Date:** {{DATE}}
|
||||||
**Attendees:**
|
**Attendees:**
|
||||||
|
|
||||||
- Name 1
|
- Name 1
|
||||||
- Name 2
|
- Name 2
|
||||||
|
|
||||||
|
|||||||
@@ -12,48 +12,43 @@
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
### In Scope
|
### In Scope
|
||||||
|
|
||||||
- Item 1
|
- Item 1
|
||||||
- Item 2
|
- Item 2
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
- Item 1
|
- Item 1
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| Milestone | Target Date | Status |
|
| Milestone | Target Date | Status |
|
||||||
| --------- | ----------- | ----------- |
|
|-----------|-------------|--------|
|
||||||
| Kickoff | {{DATE}} | Not Started |
|
| Kickoff | {{DATE}} | Not Started |
|
||||||
| MVP | TBD | Not Started |
|
| MVP | TBD | Not Started |
|
||||||
| Launch | TBD | Not Started |
|
| Launch | TBD | Not Started |
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
| Role | Person | Allocation |
|
| Role | Person | Allocation |
|
||||||
| --------- | ------ | ---------- |
|
|------|--------|------------|
|
||||||
| Lead | Name | 100% |
|
| Lead | Name | 100% |
|
||||||
| Developer | Name | 50% |
|
| Developer | Name | 50% |
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
| Risk | Impact | Likelihood | Mitigation |
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
| ------ | ------ | ---------- | ---------- |
|
|------|--------|------------|------------|
|
||||||
| Risk 1 | High | Medium | Plan |
|
| Risk 1 | High | Medium | Plan |
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
### Phase 1: Planning
|
### Phase 1: Planning
|
||||||
|
|
||||||
- [ ] Define requirements
|
- [ ] Define requirements
|
||||||
- [ ] Create design documents
|
- [ ] Create design documents
|
||||||
|
|
||||||
### Phase 2: Development
|
### Phase 2: Development
|
||||||
|
|
||||||
- [ ] Implement core features
|
- [ ] Implement core features
|
||||||
- [ ] Write tests
|
- [ ] Write tests
|
||||||
|
|
||||||
### Phase 3: Launch
|
### Phase 3: Launch
|
||||||
|
|
||||||
- [ ] Deploy to production
|
- [ ] Deploy to production
|
||||||
- [ ] Monitor and iterate
|
- [ ] Monitor and iterate
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ const project = require('project-name');
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------- | ------- | ----------- |
|
|--------|---------|-------------|
|
||||||
| option1 | true | Description |
|
| option1 | true | Description |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ Brief description of what this feature does.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Functional Requirements
|
### Functional Requirements
|
||||||
|
|
||||||
1. Requirement 1
|
1. Requirement 1
|
||||||
2. Requirement 2
|
2. Requirement 2
|
||||||
|
|
||||||
### Non-Functional Requirements
|
### Non-Functional Requirements
|
||||||
|
|
||||||
1. Performance requirement
|
1. Performance requirement
|
||||||
2. Security requirement
|
2. Security requirement
|
||||||
|
|
||||||
@@ -29,9 +27,9 @@ Describe the technical approach.
|
|||||||
|
|
||||||
### Endpoints
|
### Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------- | ------------ |
|
|--------|------|-------------|
|
||||||
| GET | /api/resource | Get resource |
|
| GET | /api/resource | Get resource |
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
@@ -39,6 +37,6 @@ Describe how this will be tested.
|
|||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Phase | Duration | Deliverables |
|
| Phase | Duration | Deliverables |
|
||||||
| ------- | -------- | ------------------- |
|
|-------|----------|-------------|
|
||||||
| Phase 1 | 1 week | Core implementation |
|
| Phase 1 | 1 week | Core implementation |
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Walk through the implementation.
|
|||||||
```javascript
|
```javascript
|
||||||
// Example code
|
// Example code
|
||||||
function example() {
|
function example() {
|
||||||
return 'Hello';
|
return 'Hello';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -53,12 +53,10 @@ How to verify everything works.
|
|||||||
## Common Issues
|
## Common Issues
|
||||||
|
|
||||||
### Issue 1
|
### Issue 1
|
||||||
|
|
||||||
**Problem:** Description of the problem.
|
**Problem:** Description of the problem.
|
||||||
**Solution:** How to fix it.
|
**Solution:** How to fix it.
|
||||||
|
|
||||||
### Issue 2
|
### Issue 2
|
||||||
|
|
||||||
**Problem:** Description of the problem.
|
**Problem:** Description of the problem.
|
||||||
**Solution:** How to fix it.
|
**Solution:** How to fix it.
|
||||||
|
|
||||||
|
|||||||
+210
-211
@@ -1,246 +1,245 @@
|
|||||||
/**
|
/**
|
||||||
* ModalManager - Unified modal system with accessibility support
|
* ModalManager - Unified modal system with accessibility support
|
||||||
* @version 4.4.5
|
* @version 4.4.2
|
||||||
*/
|
*/
|
||||||
class ModalManager {
|
class ModalManager {
|
||||||
#modal;
|
#modal;
|
||||||
#backdrop;
|
#backdrop;
|
||||||
#options;
|
#options;
|
||||||
#lastFocusedElement;
|
#lastFocusedElement;
|
||||||
#focusableElements;
|
#focusableElements;
|
||||||
#eventListeners;
|
#eventListeners;
|
||||||
#isOpen;
|
#isOpen;
|
||||||
|
|
||||||
static #openModals = [];
|
static #openModals = [];
|
||||||
|
|
||||||
constructor(element, options = {}) {
|
constructor(element, options = {}) {
|
||||||
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
|
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
|
||||||
this.#options = {
|
this.#options = {
|
||||||
closeOnBackdrop: true,
|
closeOnBackdrop: true,
|
||||||
closeOnEscape: true,
|
closeOnEscape: true,
|
||||||
focusFirst: true,
|
focusFirst: true,
|
||||||
onOpen: null,
|
onOpen: null,
|
||||||
onClose: null,
|
onClose: null,
|
||||||
...options,
|
...options
|
||||||
};
|
};
|
||||||
this.#isOpen = false;
|
this.#isOpen = false;
|
||||||
this.#eventListeners = [];
|
this.#eventListeners = [];
|
||||||
this.#init();
|
this.#init();
|
||||||
}
|
|
||||||
|
|
||||||
#init() {
|
|
||||||
// Ensure modal has required attributes
|
|
||||||
if (!this.#modal.hasAttribute('role')) {
|
|
||||||
this.#modal.setAttribute('role', 'dialog');
|
|
||||||
}
|
|
||||||
if (!this.#modal.hasAttribute('aria-modal')) {
|
|
||||||
this.#modal.setAttribute('aria-modal', 'true');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create backdrop
|
#init() {
|
||||||
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
|
// Ensure modal has required attributes
|
||||||
|
if (!this.#modal.hasAttribute('role')) {
|
||||||
// Setup close triggers
|
this.#modal.setAttribute('role', 'dialog');
|
||||||
this.#setupCloseTriggers();
|
|
||||||
}
|
|
||||||
|
|
||||||
#setupCloseTriggers() {
|
|
||||||
// Close button
|
|
||||||
const closeBtn = this.#modal.querySelector('.modal-close');
|
|
||||||
if (closeBtn) {
|
|
||||||
const handler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
this.close();
|
|
||||||
};
|
|
||||||
closeBtn.addEventListener('click', handler);
|
|
||||||
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Elements with data-close attribute
|
|
||||||
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
|
|
||||||
closeTriggers.forEach((el) => {
|
|
||||||
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const handler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
this.close();
|
|
||||||
};
|
|
||||||
el.addEventListener('click', handler);
|
|
||||||
this.#eventListeners.push({ el, type: 'click', handler });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#getFocusableElements() {
|
|
||||||
const selector = [
|
|
||||||
'button:not([disabled])',
|
|
||||||
'input:not([disabled])',
|
|
||||||
'select:not([disabled])',
|
|
||||||
'textarea:not([disabled])',
|
|
||||||
'a[href]',
|
|
||||||
'[tabindex]:not([tabindex="-1"])',
|
|
||||||
].join(', ');
|
|
||||||
|
|
||||||
return Array.from(this.#modal.querySelectorAll(selector)).filter(
|
|
||||||
(el) => el.offsetParent !== null && !el.classList.contains('modal-backdrop')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#trapFocus(e) {
|
|
||||||
if (e.key !== 'Tab') return;
|
|
||||||
|
|
||||||
const focusable = this.#getFocusableElements();
|
|
||||||
if (focusable.length === 0) return;
|
|
||||||
|
|
||||||
const firstEl = focusable[0];
|
|
||||||
const lastEl = focusable[focusable.length - 1];
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
if (document.activeElement === firstEl) {
|
|
||||||
e.preventDefault();
|
|
||||||
lastEl.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (document.activeElement === lastEl) {
|
|
||||||
e.preventDefault();
|
|
||||||
firstEl.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleKeydown(e) {
|
|
||||||
if (e.key === 'Escape' && this.#options.closeOnEscape) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
this.#trapFocus(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
if (this.#isOpen) return;
|
|
||||||
|
|
||||||
// Store last focused element
|
|
||||||
this.#lastFocusedElement = document.activeElement;
|
|
||||||
|
|
||||||
// Track open modals
|
|
||||||
ModalManager.#openModals.push(this);
|
|
||||||
|
|
||||||
// Show modal: remove hidden first, force a reflow so the browser
|
|
||||||
// records opacity:0 as the start state, then add 'open' to trigger
|
|
||||||
// the CSS transition. Without the reflow, both class changes are
|
|
||||||
// batched into one style recalculation and the transition is skipped.
|
|
||||||
this.#modal.classList.remove('hidden');
|
|
||||||
void this.#modal.offsetHeight; // Force reflow — do not remove
|
|
||||||
this.#modal.classList.add('open');
|
|
||||||
this.#isOpen = true;
|
|
||||||
|
|
||||||
// Prevent body scroll
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
// Add keyboard listener
|
|
||||||
const keydownHandler = (e) => this.#handleKeydown(e);
|
|
||||||
document.addEventListener('keydown', keydownHandler);
|
|
||||||
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
|
|
||||||
|
|
||||||
// Focus first element
|
|
||||||
if (this.#options.focusFirst) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const focusable = this.#getFocusableElements();
|
|
||||||
if (focusable.length > 0) {
|
|
||||||
focusable[0].focus();
|
|
||||||
}
|
}
|
||||||
});
|
if (!this.#modal.hasAttribute('aria-modal')) {
|
||||||
|
this.#modal.setAttribute('aria-modal', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create backdrop
|
||||||
|
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
|
||||||
|
|
||||||
|
// Setup close triggers
|
||||||
|
this.#setupCloseTriggers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback
|
#setupCloseTriggers() {
|
||||||
if (this.#options.onOpen) {
|
// Close button
|
||||||
this.#options.onOpen(this);
|
const closeBtn = this.#modal.querySelector('.modal-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
const handler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.close();
|
||||||
|
};
|
||||||
|
closeBtn.addEventListener('click', handler);
|
||||||
|
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elements with data-close attribute
|
||||||
|
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
|
||||||
|
closeTriggers.forEach(el => {
|
||||||
|
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.close();
|
||||||
|
};
|
||||||
|
el.addEventListener('click', handler);
|
||||||
|
this.#eventListeners.push({ el, type: 'click', handler });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch custom event
|
#getFocusableElements() {
|
||||||
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
|
const selector = [
|
||||||
}
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'a[href]',
|
||||||
|
'[tabindex]:not([tabindex="-1"])'
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
close() {
|
return Array.from(this.#modal.querySelectorAll(selector))
|
||||||
if (!this.#isOpen) return;
|
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop'));
|
||||||
|
|
||||||
// Remove from open modals
|
|
||||||
const index = ModalManager.#openModals.indexOf(this);
|
|
||||||
if (index > -1) {
|
|
||||||
ModalManager.#openModals.splice(index, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start hide transition
|
#trapFocus(e) {
|
||||||
this.#modal.classList.remove('open');
|
if (e.key !== 'Tab') return;
|
||||||
this.#isOpen = false;
|
|
||||||
|
|
||||||
// Re-add 'hidden' after the CSS transition completes so the modal
|
const focusable = this.#getFocusableElements();
|
||||||
// is fully removed from rendering (display:none), not just invisible.
|
if (focusable.length === 0) return;
|
||||||
// We use both transitionend and a setTimeout fallback because
|
|
||||||
// transitionend never fires when prefers-reduced-motion disables transitions.
|
|
||||||
let hidden = false;
|
|
||||||
const addHidden = () => {
|
|
||||||
if (hidden || this.#isOpen) return;
|
|
||||||
hidden = true;
|
|
||||||
this.#modal.removeEventListener('transitionend', onTransitionEnd);
|
|
||||||
this.#modal.classList.add('hidden');
|
|
||||||
};
|
|
||||||
const onTransitionEnd = (e) => {
|
|
||||||
if (e.target !== this.#modal) return;
|
|
||||||
addHidden();
|
|
||||||
};
|
|
||||||
this.#modal.addEventListener('transitionend', onTransitionEnd);
|
|
||||||
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
|
|
||||||
|
|
||||||
// Restore body scroll if no modals open
|
const firstEl = focusable[0];
|
||||||
if (ModalManager.#openModals.length === 0) {
|
const lastEl = focusable[focusable.length - 1];
|
||||||
document.body.style.overflow = '';
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastEl.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove keyboard listener
|
#handleKeydown(e) {
|
||||||
const keydownListener = this.#eventListeners.find(
|
if (e.key === 'Escape' && this.#options.closeOnEscape) {
|
||||||
(l) => l.el === document && l.type === 'keydown'
|
e.preventDefault();
|
||||||
);
|
this.close();
|
||||||
if (keydownListener) {
|
}
|
||||||
document.removeEventListener('keydown', keydownListener.handler);
|
this.#trapFocus(e);
|
||||||
this.#eventListeners = this.#eventListeners.filter((l) => l !== keydownListener);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore focus
|
open() {
|
||||||
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
|
if (this.#isOpen) return;
|
||||||
this.#lastFocusedElement.focus();
|
|
||||||
|
// Store last focused element
|
||||||
|
this.#lastFocusedElement = document.activeElement;
|
||||||
|
|
||||||
|
// Track open modals
|
||||||
|
ModalManager.#openModals.push(this);
|
||||||
|
|
||||||
|
// Show modal: remove hidden first, force a reflow so the browser
|
||||||
|
// records opacity:0 as the start state, then add 'open' to trigger
|
||||||
|
// the CSS transition. Without the reflow, both class changes are
|
||||||
|
// batched into one style recalculation and the transition is skipped.
|
||||||
|
this.#modal.classList.remove('hidden');
|
||||||
|
void this.#modal.offsetHeight; // Force reflow — do not remove
|
||||||
|
this.#modal.classList.add('open');
|
||||||
|
this.#isOpen = true;
|
||||||
|
|
||||||
|
// Prevent body scroll
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Add keyboard listener
|
||||||
|
const keydownHandler = (e) => this.#handleKeydown(e);
|
||||||
|
document.addEventListener('keydown', keydownHandler);
|
||||||
|
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
|
||||||
|
|
||||||
|
// Focus first element
|
||||||
|
if (this.#options.focusFirst) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const focusable = this.#getFocusableElements();
|
||||||
|
if (focusable.length > 0) {
|
||||||
|
focusable[0].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
if (this.#options.onOpen) {
|
||||||
|
this.#options.onOpen(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch custom event
|
||||||
|
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback
|
close() {
|
||||||
if (this.#options.onClose) {
|
if (!this.#isOpen) return;
|
||||||
this.#options.onClose(this);
|
|
||||||
|
// Remove from open modals
|
||||||
|
const index = ModalManager.#openModals.indexOf(this);
|
||||||
|
if (index > -1) {
|
||||||
|
ModalManager.#openModals.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start hide transition
|
||||||
|
this.#modal.classList.remove('open');
|
||||||
|
this.#isOpen = false;
|
||||||
|
|
||||||
|
// Re-add 'hidden' after the CSS transition completes so the modal
|
||||||
|
// is fully removed from rendering (display:none), not just invisible.
|
||||||
|
// We use both transitionend and a setTimeout fallback because
|
||||||
|
// transitionend never fires when prefers-reduced-motion disables transitions.
|
||||||
|
let hidden = false;
|
||||||
|
const addHidden = () => {
|
||||||
|
if (hidden || this.#isOpen) return;
|
||||||
|
hidden = true;
|
||||||
|
this.#modal.removeEventListener('transitionend', onTransitionEnd);
|
||||||
|
this.#modal.classList.add('hidden');
|
||||||
|
};
|
||||||
|
const onTransitionEnd = (e) => {
|
||||||
|
if (e.target !== this.#modal) return;
|
||||||
|
addHidden();
|
||||||
|
};
|
||||||
|
this.#modal.addEventListener('transitionend', onTransitionEnd);
|
||||||
|
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
|
||||||
|
|
||||||
|
// Restore body scroll if no modals open
|
||||||
|
if (ModalManager.#openModals.length === 0) {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove keyboard listener
|
||||||
|
const keydownListener = this.#eventListeners.find(
|
||||||
|
l => l.el === document && l.type === 'keydown'
|
||||||
|
);
|
||||||
|
if (keydownListener) {
|
||||||
|
document.removeEventListener('keydown', keydownListener.handler);
|
||||||
|
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore focus
|
||||||
|
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
|
||||||
|
this.#lastFocusedElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
if (this.#options.onClose) {
|
||||||
|
this.#options.onClose(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch custom event
|
||||||
|
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch custom event
|
isOpen() {
|
||||||
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
|
return this.#isOpen;
|
||||||
}
|
|
||||||
|
|
||||||
isOpen() {
|
|
||||||
return this.#isOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
// Close if open
|
|
||||||
if (this.#isOpen) {
|
|
||||||
this.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove all event listeners
|
destroy() {
|
||||||
this.#eventListeners.forEach(({ el, type, handler }) => {
|
// Close if open
|
||||||
el.removeEventListener(type, handler);
|
if (this.#isOpen) {
|
||||||
});
|
this.close();
|
||||||
this.#eventListeners = [];
|
}
|
||||||
}
|
|
||||||
|
// Remove all event listeners
|
||||||
|
this.#eventListeners.forEach(({ el, type, handler }) => {
|
||||||
|
el.removeEventListener(type, handler);
|
||||||
|
});
|
||||||
|
this.#eventListeners = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in renderer - avoid duplicate declaration
|
// Export for use in renderer - avoid duplicate declaration
|
||||||
if (typeof window !== 'undefined' && !window.ModalManager) {
|
if (typeof window !== 'undefined' && !window.ModalManager) {
|
||||||
window.ModalManager = ModalManager;
|
window.ModalManager = ModalManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommonJS export
|
// CommonJS export
|
||||||
|
|||||||
+7
-9
@@ -1,14 +1,12 @@
|
|||||||
function createWelcomeContent(recentFiles = [], appVersion = '') {
|
function createWelcomeContent(recentFiles = [], appVersion = '') {
|
||||||
const recentHtml = recentFiles.length
|
const recentHtml = recentFiles.length
|
||||||
? recentFiles
|
? recentFiles.map(f => {
|
||||||
.map((f) => {
|
const name = f.split(/[/\\]/).pop();
|
||||||
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>`;
|
||||||
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>';
|
||||||
.join('')
|
|
||||||
: '<p class="welcome-muted">No recent files</p>';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
<div class="welcome-hero">
|
<div class="welcome-hero">
|
||||||
<h1 class="welcome-title">MarkdownConverter</h1>
|
<h1 class="welcome-title">MarkdownConverter</h1>
|
||||||
|
|||||||
+561
-645
File diff suppressed because it is too large
Load Diff
+235
-227
@@ -4,154 +4,172 @@
|
|||||||
* Toggle with F11, exit with Escape.
|
* Toggle with F11, exit with Escape.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { EditorView, ViewPlugin, Decoration } = require('@codemirror/view');
|
const {
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
} = require('@codemirror/view');
|
||||||
const { RangeSetBuilder } = require('@codemirror/state');
|
const { RangeSetBuilder } = require('@codemirror/state');
|
||||||
|
|
||||||
class ZenMode {
|
class ZenMode {
|
||||||
/**
|
/**
|
||||||
* @param {import('./renderer').TabManager} tabManager
|
* @param {import('./renderer').TabManager} tabManager
|
||||||
*/
|
*/
|
||||||
constructor(tabManager) {
|
constructor(tabManager) {
|
||||||
this.tabManager = tabManager;
|
this.tabManager = tabManager;
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this._hud = null;
|
this._hud = null;
|
||||||
this._timerInterval = null;
|
this._timerInterval = null;
|
||||||
this._sessionStart = null;
|
this._sessionStart = null;
|
||||||
this._extensions = [];
|
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
|
activate() {
|
||||||
this._createHUD();
|
if (this.active) return;
|
||||||
|
this.active = true;
|
||||||
|
|
||||||
// Start session timer
|
document.body.classList.add('zen-mode');
|
||||||
this._sessionStart = Date.now();
|
|
||||||
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
|
|
||||||
|
|
||||||
// Focus editor
|
// Collapse sidebar
|
||||||
if (tab?.editorView) tab.editorView.focus();
|
const sidebar = document.getElementById('sidebar');
|
||||||
}
|
if (sidebar) sidebar.classList.add('collapsed');
|
||||||
|
|
||||||
deactivate() {
|
// Hide preview pane for the active tab
|
||||||
if (!this.active) return;
|
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
||||||
this.active = false;
|
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');
|
||||||
|
|
||||||
document.body.classList.remove('zen-mode');
|
// Add CM6 extensions to the active editor
|
||||||
|
if (tab.editorView) {
|
||||||
// Restore preview pane for the active tab
|
this._extensions = [
|
||||||
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
typewriterScrollExtension,
|
||||||
if (tab) {
|
lineDimmingExtension,
|
||||||
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
];
|
||||||
if (previewContainer) previewContainer.classList.remove('hidden');
|
// Reconfigure is not straightforward with CM6's immutable state.
|
||||||
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
// Instead we dispatch effects or simply rebuild with new extensions.
|
||||||
if (editorPane) editorPane.classList.remove('full-width');
|
// Since we cannot hot-swap extensions on an existing EditorView,
|
||||||
|
// we store a reference and the CSS + updateListener handle the rest.
|
||||||
// Remove typewriter listener
|
// The typewriter + dimming plugins are applied via a compartment approach
|
||||||
if (tab.editorView && this._selectionListener) {
|
// would be ideal, but for simplicity we use DOM + updateListener.
|
||||||
window.removeEventListener('zen-typewriter', this._selectionListener);
|
this._applyTypewriterBehavior(tab.editorView);
|
||||||
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.
|
|
||||||
const scrollFn = () => {
|
|
||||||
if (!this.active) return;
|
|
||||||
const pos = view.state.selection.main.head;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
view.dispatch({
|
|
||||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// Ignore errors from destroyed views
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
// Initial scroll to center
|
// Create HUD
|
||||||
scrollFn();
|
this._createHUD();
|
||||||
}
|
|
||||||
|
|
||||||
_createHUD() {
|
// Start session timer
|
||||||
const hud = document.createElement('div');
|
this._sessionStart = Date.now();
|
||||||
hud.className = 'zen-hud';
|
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
|
||||||
hud.innerHTML = `
|
|
||||||
|
// 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-item" id="zen-words">0 words</span>
|
||||||
<span class="zen-hud-sep">·</span>
|
<span class="zen-hud-sep">·</span>
|
||||||
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
||||||
@@ -161,124 +179,114 @@ class ZenMode {
|
|||||||
<div class="zen-progress-bar" id="zen-progress-bar"></div>
|
<div class="zen-progress-bar" id="zen-progress-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(hud);
|
document.body.appendChild(hud);
|
||||||
this._hud = hud;
|
this._hud = hud;
|
||||||
this._updateHUD();
|
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
|
_updateHUD() {
|
||||||
const goalStr = localStorage.getItem('zen-word-goal');
|
if (!this.active || !this._hud) return;
|
||||||
const progressBar = document.getElementById('zen-progress-bar');
|
|
||||||
const progressContainer = document.getElementById('zen-progress-container');
|
const content = this.tabManager.getEditorContent();
|
||||||
if (progressBar && progressContainer) {
|
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0;
|
||||||
if (goalStr) {
|
const readingTime = Math.max(1, Math.ceil(words / 200));
|
||||||
const goal = parseInt(goalStr, 10);
|
|
||||||
if (goal > 0) {
|
const wordsEl = document.getElementById('zen-words');
|
||||||
const pct = Math.min(100, Math.round((words / goal) * 100));
|
const readingEl = document.getElementById('zen-reading-time');
|
||||||
progressBar.style.width = `${pct}%`;
|
const timerEl = document.getElementById('zen-timer');
|
||||||
progressContainer.style.display = 'block';
|
|
||||||
} else {
|
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
|
||||||
progressContainer.style.display = 'none';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
progressContainer.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typewriter scroll ViewPlugin
|
// Typewriter scroll ViewPlugin
|
||||||
const typewriterScrollExtension = ViewPlugin.fromClass(
|
const typewriterScrollExtension = ViewPlugin.fromClass(class {
|
||||||
class {
|
|
||||||
constructor(view) {
|
constructor(view) {
|
||||||
this.view = view;
|
this.view = view;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update) {
|
update(update) {
|
||||||
if (update.selectionSet || update.docChanged) {
|
if (update.selectionSet || update.docChanged) {
|
||||||
const pos = update.state.selection.main.head;
|
const pos = update.state.selection.main.head;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (this.view.dom && this.view.dom.isConnected) {
|
if (this.view.dom && this.view.dom.isConnected) {
|
||||||
this.view.dispatch({
|
this.view.dispatch({
|
||||||
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Line dimming ViewPlugin
|
// Line dimming ViewPlugin
|
||||||
function getOpacity(distance) {
|
function getOpacity(distance) {
|
||||||
if (distance === 0) return 1.0;
|
if (distance === 0) return 1.0;
|
||||||
if (distance === 1) return 0.7;
|
if (distance === 1) return 0.7;
|
||||||
if (distance === 2) return 0.6;
|
if (distance === 2) return 0.6;
|
||||||
if (distance === 3) return 0.45;
|
if (distance === 3) return 0.45;
|
||||||
return 0.3;
|
return 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineDimmingExtension = ViewPlugin.fromClass(
|
const lineDimmingExtension = ViewPlugin.fromClass(class {
|
||||||
class {
|
|
||||||
constructor(view) {
|
constructor(view) {
|
||||||
this.decorations = this.buildDecorations(view);
|
this.decorations = this.buildDecorations(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(update) {
|
update(update) {
|
||||||
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||||
this.decorations = this.buildDecorations(update.view);
|
this.decorations = this.buildDecorations(update.view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDecorations(view) {
|
buildDecorations(view) {
|
||||||
const builder = new RangeSetBuilder();
|
const builder = new RangeSetBuilder();
|
||||||
const pos = view.state.selection.main.head;
|
const pos = view.state.selection.main.head;
|
||||||
const activeLine = view.state.doc.lineAt(pos).number;
|
const activeLine = view.state.doc.lineAt(pos).number;
|
||||||
const doc = view.state.doc;
|
const doc = view.state.doc;
|
||||||
|
|
||||||
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
|
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
|
||||||
const line = doc.line(lineNum);
|
const line = doc.line(lineNum);
|
||||||
const distance = Math.abs(lineNum - activeLine);
|
const distance = Math.abs(lineNum - activeLine);
|
||||||
const opacity = getOpacity(distance);
|
const opacity = getOpacity(distance);
|
||||||
const deco = Decoration.line({
|
const deco = Decoration.line({
|
||||||
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
|
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
|
||||||
});
|
});
|
||||||
builder.add(line.from, line.from, deco);
|
builder.add(line.from, line.from, deco);
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.finish();
|
return builder.finish();
|
||||||
}
|
}
|
||||||
},
|
}, {
|
||||||
{
|
decorations: v => v.decorations,
|
||||||
decorations: (v) => v.decorations,
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = { ZenMode };
|
module.exports = { ZenMode };
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const animate = require('tailwindcss-animate')
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./src/renderer/index.html',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: { '2xl': '1400px' },
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
DEFAULT: '#e5461f',
|
||||||
|
dark: '#c93a18',
|
||||||
|
light: '#ff6b47',
|
||||||
|
},
|
||||||
|
success: '#1a7a56',
|
||||||
|
warning: 'hsl(45 93% 47%)',
|
||||||
|
danger: 'hsl(0 84% 60%)',
|
||||||
|
info: 'hsl(199 89% 48%)',
|
||||||
|
warm: {
|
||||||
|
50: '#fafbfc', 100: '#f5f5f5', 200: '#e3e3e3', 300: '#d1d1d1',
|
||||||
|
400: '#b0b0b0', 500: '#9a9696', 600: '#7a7878', 700: '#5a5858',
|
||||||
|
800: '#4e4e4e', 900: '#464646', 950: '#0d0b09',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
||||||
|
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [animate],
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for bundled external tools
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
describe('Bundled Pandoc binary', () => {
|
|
||||||
const rootDir = path.resolve(__dirname, '..');
|
|
||||||
const platform = process.platform;
|
|
||||||
const binaryPath = path.join(rootDir, 'bin', platform, 'pandoc');
|
|
||||||
const windowsBinaryPath = path.join(rootDir, 'bin', platform, 'pandoc.exe');
|
|
||||||
|
|
||||||
test('Pandoc binary exists for current platform', () => {
|
|
||||||
const exists = fs.existsSync(binaryPath) || fs.existsSync(windowsBinaryPath);
|
|
||||||
expect(exists).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Pandoc binary is executable', () => {
|
|
||||||
if (platform === 'win32') {
|
|
||||||
expect(fs.existsSync(windowsBinaryPath)).toBe(true);
|
|
||||||
} else {
|
|
||||||
expect(fs.existsSync(binaryPath)).toBe(true);
|
|
||||||
const stats = fs.statSync(binaryPath);
|
|
||||||
expect(stats.mode & 0o111).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+110
-110
@@ -8,10 +8,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
describe('CommandPalette', () => {
|
describe('CommandPalette', () => {
|
||||||
let CommandPalette;
|
let CommandPalette;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div class="command-palette-overlay hidden" id="command-palette-overlay">
|
<div class="command-palette-overlay hidden" id="command-palette-overlay">
|
||||||
<div class="command-palette">
|
<div class="command-palette">
|
||||||
<input type="text" id="command-palette-input">
|
<input type="text" id="command-palette-input">
|
||||||
@@ -19,126 +19,126 @@ describe('CommandPalette', () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
CommandPalette = require('../src/command-palette').CommandPalette;
|
CommandPalette = require('../src/command-palette').CommandPalette;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('starts hidden', () => {
|
test('starts hidden', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
expect(palette.isOpen()).toBe(false);
|
expect(palette.isOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opens and focuses input', () => {
|
test('opens and focuses input', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.open();
|
palette.open();
|
||||||
expect(palette.isOpen()).toBe(true);
|
expect(palette.isOpen()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closes', () => {
|
test('closes', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.open();
|
palette.open();
|
||||||
palette.close();
|
palette.close();
|
||||||
expect(palette.isOpen()).toBe(false);
|
expect(palette.isOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registers and renders commands', () => {
|
test('registers and renders commands', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.register('Test Command', 'Ctrl+T', () => {});
|
palette.register('Test Command', 'Ctrl+T', () => {});
|
||||||
palette.register('Another Command', '', () => {});
|
palette.register('Another Command', '', () => {});
|
||||||
palette.open();
|
palette.open();
|
||||||
const items = document.querySelectorAll('.command-item');
|
const items = document.querySelectorAll('.command-item');
|
||||||
expect(items.length).toBe(2);
|
expect(items.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('filters commands by search', () => {
|
test('filters commands by search', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.register('Save File', 'Ctrl+S', () => {});
|
palette.register('Save File', 'Ctrl+S', () => {});
|
||||||
palette.register('Open File', 'Ctrl+O', () => {});
|
palette.register('Open File', 'Ctrl+O', () => {});
|
||||||
palette.register('Bold Text', 'Ctrl+B', () => {});
|
palette.register('Bold Text', 'Ctrl+B', () => {});
|
||||||
palette.renderResults('file');
|
palette.renderResults('file');
|
||||||
const items = document.querySelectorAll('.command-item');
|
const items = document.querySelectorAll('.command-item');
|
||||||
expect(items.length).toBe(2);
|
expect(items.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('executes command', () => {
|
test('executes command', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
const action = jest.fn();
|
const action = jest.fn();
|
||||||
palette.register('Test', '', action);
|
palette.register('Test', '', action);
|
||||||
palette.open();
|
palette.open();
|
||||||
palette.executeSelected();
|
palette.executeSelected();
|
||||||
expect(action).toHaveBeenCalled();
|
expect(action).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('highlights matching text', () => {
|
test('highlights matching text', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
const result = palette.highlightMatch('Save File', 'save');
|
const result = palette.highlightMatch('Save File', 'save');
|
||||||
expect(result).toContain('<strong>');
|
expect(result).toContain('<strong>');
|
||||||
expect(result).toContain('Save');
|
expect(result).toContain('Save');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('highlightMatch returns original text when no query', () => {
|
test('highlightMatch returns original text when no query', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
const result = palette.highlightMatch('Save File', '');
|
const result = palette.highlightMatch('Save File', '');
|
||||||
expect(result).toBe('Save File');
|
expect(result).toBe('Save File');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('filters case-insensitively', () => {
|
test('filters case-insensitively', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.register('Save File', '', () => {});
|
palette.register('Save File', '', () => {});
|
||||||
palette.register('Open File', '', () => {});
|
palette.register('Open File', '', () => {});
|
||||||
palette.renderResults('SAVE');
|
palette.renderResults('SAVE');
|
||||||
const items = document.querySelectorAll('.command-item');
|
const items = document.querySelectorAll('.command-item');
|
||||||
expect(items.length).toBe(1);
|
expect(items.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows all commands when query is empty', () => {
|
test('shows all commands when query is empty', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.register('Cmd1', '', () => {});
|
palette.register('Cmd1', '', () => {});
|
||||||
palette.register('Cmd2', '', () => {});
|
palette.register('Cmd2', '', () => {});
|
||||||
palette.register('Cmd3', '', () => {});
|
palette.register('Cmd3', '', () => {});
|
||||||
palette.renderResults('');
|
palette.renderResults('');
|
||||||
const items = document.querySelectorAll('.command-item');
|
const items = document.querySelectorAll('.command-item');
|
||||||
expect(items.length).toBe(3);
|
expect(items.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays shortcut when provided', () => {
|
test('displays shortcut when provided', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.register('Save File', 'Ctrl+S', () => {});
|
palette.register('Save File', 'Ctrl+S', () => {});
|
||||||
palette.open();
|
palette.open();
|
||||||
const shortcut = document.querySelector('.command-shortcut');
|
const shortcut = document.querySelector('.command-shortcut');
|
||||||
expect(shortcut).not.toBeNull();
|
expect(shortcut).not.toBeNull();
|
||||||
expect(shortcut.textContent).toBe('Ctrl+S');
|
expect(shortcut.textContent).toBe('Ctrl+S');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not display shortcut when empty', () => {
|
test('does not display shortcut when empty', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.register('Test Command', '', () => {});
|
palette.register('Test Command', '', () => {});
|
||||||
palette.open();
|
palette.open();
|
||||||
const shortcut = document.querySelector('.command-shortcut');
|
const shortcut = document.querySelector('.command-shortcut');
|
||||||
expect(shortcut).toBeNull();
|
expect(shortcut).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closes on overlay click', () => {
|
test('closes on overlay click', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.open();
|
palette.open();
|
||||||
// Simulate click on the overlay itself
|
// Simulate click on the overlay itself
|
||||||
const event = new Event('click', { bubbles: true });
|
const event = new Event('click', { bubbles: true });
|
||||||
Object.defineProperty(event, 'target', { value: palette.overlay });
|
Object.defineProperty(event, 'target', { value: palette.overlay });
|
||||||
palette.overlay.dispatchEvent(event);
|
palette.overlay.dispatchEvent(event);
|
||||||
expect(palette.isOpen()).toBe(false);
|
expect(palette.isOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('executeSelected does nothing when no commands', () => {
|
test('executeSelected does nothing when no commands', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.open();
|
palette.open();
|
||||||
// Should not throw
|
// Should not throw
|
||||||
expect(() => palette.executeSelected()).not.toThrow();
|
expect(() => palette.executeSelected()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closes after executing command', () => {
|
test('closes after executing command', () => {
|
||||||
const palette = new CommandPalette();
|
const palette = new CommandPalette();
|
||||||
palette.register('Test', '', jest.fn());
|
palette.register('Test', '', jest.fn());
|
||||||
palette.open();
|
palette.open();
|
||||||
palette.executeSelected();
|
palette.executeSelected();
|
||||||
expect(palette.isOpen()).toBe(false);
|
expect(palette.isOpen()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { CodeMirrorEditor } from '@/components/editor/CodeMirrorEditor';
|
||||||
|
|
||||||
|
vi.mock('@/components/editor/CodeMirrorEditor', () => ({
|
||||||
|
CodeMirrorEditor: ({ initialContent }: { initialContent: string }) => (
|
||||||
|
<div data-testid="codemirror-mock">{initialContent}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('EditorPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state when no buffer is open', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the editor when a buffer is open', () => {
|
||||||
|
useEditorStore.getState().openBuffer('b1', '/x.md', '# hello');
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toHaveTextContent('# hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('AppHeader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the app title', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sidebar when sidebar button clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
|
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
|
||||||
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
|
||||||
|
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AppShell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all shell surfaces when sidebar and preview are visible', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no file selected/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sidebar when sidebarVisible is false', () => {
|
||||||
|
useAppStore.setState({ sidebarVisible: false });
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { StatusBar } from '@/components/layout/StatusBar';
|
||||||
|
|
||||||
|
describe('StatusBar', () => {
|
||||||
|
it('renders zero word count when no file is open', () => {
|
||||||
|
render(<StatusBar />);
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { TabBar } from '@/components/layout/TabBar';
|
||||||
|
|
||||||
|
describe('TabBar', () => {
|
||||||
|
it('renders an empty state when no tabs are open', () => {
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user